Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[mdx]": {
"editor.defaultFormatter": "unifiedjs.vscode-mdx"
}
}
1 change: 1 addition & 0 deletions docs/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"dependencies": {
"@astrojs/starlight": "^0.31.1",
"astro": "^5.1.1",
"sharp": "^0.33.5"
"sharp": "^0.33.5",
"shiki": "^3.21.0"
}
}
5 changes: 4 additions & 1 deletion docs/src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ const base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 1rem;
width: 100%;
max-width: var(--sl-content-width);
margin: 0 auto;
padding: 0;
}

.header-left {
Expand Down
361 changes: 361 additions & 0 deletions docs/src/components/InstallSelector.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,361 @@
---
import { codeToTokens } from 'shiki';

interface InstallOption {
label: string;
command: string;
}

interface Props {
options: InstallOption[];
defaultIndex?: number;
}

const { options, defaultIndex = 0 } = Astro.props;

/**
* Highlights a shell command using Shiki and returns inline HTML.
*/
async function highlightCommand(cmd: string): Promise<string> {
const { tokens } = await codeToTokens(cmd, {
lang: 'bash',
theme: 'github-dark',
});

// Convert tokens to inline HTML spans (single line command)
return tokens[0]
.map(token => {
const escaped = token.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<span style="color: ${token.color}">${escaped}</span>`;
})
.join('');
}

// Pre-highlight all options server-side for client-side switching
const highlightedOptions = await Promise.all(
options.map(async (opt) => ({
label: opt.label,
command: opt.command,
highlighted: await highlightCommand(opt.command),
}))
);

const defaultOption = highlightedOptions[defaultIndex];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The component accesses highlightedOptions[defaultIndex] without bounds checking, which can cause a build-time crash if an out-of-bounds defaultIndex prop is provided.
Severity: MEDIUM

Suggested Fix

Add a bounds check before accessing highlightedOptions[defaultIndex]. Validate that defaultIndex is a valid index for the array (e.g., defaultIndex >= 0 && defaultIndex < highlightedOptions.length). If the index is invalid, either fall back to a safe default like 0 or throw a more informative error to guide the developer.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: docs/src/components/InstallSelector.astro#L46

Potential issue: The `InstallSelector` component accepts an optional `defaultIndex`
prop, which is used to access an element from the `highlightedOptions` array. There is
no validation to ensure that `defaultIndex` is within the bounds of the array. If a
consumer of this component provides a `defaultIndex` that is out of bounds, the
`defaultOption` variable will be `undefined`. This will cause a server-side rendering
error when properties of `defaultOption` are accessed, leading to a build failure for
the page.

Did we get this right? 👍 / 👎 to inform future reviews.

---

<div class="install-selector">
<div class="install-box">
<button class="dropdown-trigger" aria-expanded="false" aria-haspopup="listbox">
<span class="dropdown-label">{defaultOption.label}</span>
<svg class="chevron" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>

<div class="command-area" style={{marginTop: 0}}>
<code class="command-text" style={{backgroundColor: 'transparent'}} data-command={defaultOption.command} set:html={defaultOption.highlighted} />
<button class="copy-btn" data-command={defaultOption.command} aria-label="Copy to clipboard">
<svg class="copy-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
<svg class="check-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
</button>
</div>

<ul class="dropdown-menu" role="listbox" aria-label="Installation method">
{highlightedOptions.map((opt, index) => (
<li>
<button
class={`dropdown-option ${index === defaultIndex ? 'selected' : ''}`}
data-label={opt.label}
data-command={opt.command}
data-highlighted={opt.highlighted}
role="option"
aria-selected={index === defaultIndex ? 'true' : 'false'}
>
{opt.label}
</button>
</li>
))}
</ul>
</div>
</div>

<style>
.install-selector {
position: relative;
width: fit-content;
}

/* Unified container with shared border */
.install-box {
display: flex;
align-items: stretch;
position: relative;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9999px;
}

.dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.04);
border: none;
border-radius: 9999px 0 0 9999px;
padding: 0.6rem 1rem;
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
position: relative;
z-index: 1;
}

.dropdown-trigger:hover {
background: rgba(255, 255, 255, 0.08);
}

.chevron {
transition: transform 0.2s ease;
opacity: 0.6;
}

.install-box.open .chevron {
transform: rotate(180deg);
}

.command-area {
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
border: none;
padding: 0.6rem 1rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
line-height: 1;
}

.command-text {
white-space: nowrap;
background: transparent;
min-width: 47ch; /* Fixed width for longest command (curl) */
}

/* Remove any background from Shiki-generated spans */
.command-text :global(span) {
background: transparent !important;
}

.copy-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
padding: 0;
transition: color 0.2s ease;
flex-shrink: 0;
width: 16px;
height: 16px;
display: grid;
place-items: center;
}

.copy-btn:hover {
color: #fff;
}

.copy-btn .copy-icon,
.copy-btn .check-icon {
grid-area: 1 / 1;
pointer-events: none;
transition: opacity 0.15s ease;
}

.copy-btn .check-icon {
opacity: 0;
color: #22c55e;
transform: translateY(-6px);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check icon is vertically misaligned when shown

Low Severity

The .check-icon has transform: translateY(-6px) which shifts it 6 pixels upward from its centered grid position. When the copy button is clicked and the .copied class is added, only opacity changes to 1 — the transform remains, causing the checkmark to appear visually misaligned (higher) compared to where the copy icon was. This creates a jarring visual jump between the two icon states.

Fix in Cursor Fix in Web


.copy-btn.copied .copy-icon {
opacity: 0;
}

.copy-btn.copied .check-icon {
opacity: 1;
}

.dropdown-menu {
position: absolute;
top: 90%;
left: 0;
background: #1a1a1f;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
padding: 0.5rem !important;
list-style: none;
margin: 0;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.2s ease;
z-index: 100;
min-width: 120px;
}

.install-box.open .dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}

.dropdown-menu li {
margin: 0;
padding: 0;
}

.dropdown-option {
width: 100%;
margin-bottom: 0 !important;
display: block;
padding: 0.5rem 0.75rem;
background: none;
border: none;
border-radius: 0.5rem;
font-family: inherit;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
}

.dropdown-option:hover {
background: rgba(255, 255, 255, 0.08);
}

.dropdown-option.selected {
background: rgba(255, 255, 255, 0.1);
font-weight: 500;
}
</style>

<script is:inline>
(function() {
function initInstallSelector() {
document.querySelectorAll('.install-selector').forEach(function(selector) {
const installBox = selector.querySelector('.install-box');
const trigger = selector.querySelector('.dropdown-trigger');
const label = selector.querySelector('.dropdown-label');
const commandText = selector.querySelector('.command-text');
const copyBtn = selector.querySelector('.copy-btn');
const options = selector.querySelectorAll('.dropdown-option');

if (!trigger || !installBox) return;

// Toggle dropdown
trigger.addEventListener('click', function(e) {
e.stopPropagation();
const isOpen = installBox.classList.contains('open');

// Close all other dropdowns
document.querySelectorAll('.install-box.open').forEach(function(box) {
if (box !== installBox) box.classList.remove('open');
});

installBox.classList.toggle('open', !isOpen);
trigger.setAttribute('aria-expanded', (!isOpen).toString());
});

// Handle option selection
options.forEach(function(option) {
option.addEventListener('click', function() {
const newLabel = option.getAttribute('data-label');
const newCommand = option.getAttribute('data-command');
const newHighlighted = option.getAttribute('data-highlighted');

if (label && newLabel) label.textContent = newLabel;
if (commandText && newHighlighted) {
commandText.innerHTML = newHighlighted;
}
if (commandText && newCommand) {
commandText.setAttribute('data-command', newCommand);
}
if (copyBtn && newCommand) {
copyBtn.setAttribute('data-command', newCommand);
}

// Update selected state
options.forEach(function(opt) {
opt.classList.remove('selected');
opt.setAttribute('aria-selected', 'false');
});
option.classList.add('selected');
option.setAttribute('aria-selected', 'true');

// Close dropdown
installBox.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
});
});

// Copy functionality
if (copyBtn) {
copyBtn.addEventListener('click', async function() {
const command = copyBtn.getAttribute('data-command');
if (command) {
await navigator.clipboard.writeText(command);
copyBtn.classList.add('copied');
setTimeout(function() { copyBtn.classList.remove('copied'); }, 2000);
}
});
}
});

// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.install-selector')) {
document.querySelectorAll('.install-box.open').forEach(function(box) {
box.classList.remove('open');
const trigger = box.querySelector('.dropdown-trigger');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
}
});

// Close on escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.install-box.open').forEach(function(box) {
box.classList.remove('open');
const trigger = box.querySelector('.dropdown-trigger');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
}
});
Comment thread
betegon marked this conversation as resolved.
}

// Run when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initInstallSelector);
} else {
initInstallSelector();
}

// Re-run after Astro page transitions
document.addEventListener('astro:after-swap', initInstallSelector);
})();
</script>
Loading
Loading