diff --git a/src/components/TabsScript.astro b/src/components/TabsScript.astro index 5a0f781b..c9db5216 100644 --- a/src/components/TabsScript.astro +++ b/src/components/TabsScript.astro @@ -312,6 +312,8 @@ // Check if the code content is tall enough to collapse if (scrollArea.scrollHeight <= CODE_COLLAPSE_HEIGHT) return; + // Highlighted lines are the point of the block, so never hide them. + if (block.querySelector('.shiki.has-highlighted')) return; // Mark as collapsed block.setAttribute('data-code-collapsed', 'true'); diff --git a/src/components/docs/ExampleLinkBar.astro b/src/components/docs/ExampleLinkBar.astro index 7ac9ed7c..7897e1f1 100644 --- a/src/components/docs/ExampleLinkBar.astro +++ b/src/components/docs/ExampleLinkBar.astro @@ -1,17 +1,17 @@ --- -// "Prefer to read code?" bar with a View-on-GitHub button, extracted from the -// inline HTML in the agentOS quickstart so every product's quickstart can use it. +// "See the full example on GitHub" bar with a View-on-GitHub button. Every +// docs page and guide that has a complete example links it with this bar, so +// the wording stays the same everywhere. interface Props { href: string; - label?: string; } -const { href, label = "Prefer to read code? Clone the example repository." } = Astro.props; +const { href } = Astro.props; ---
- {label} + See the full example on GitHub. (); + if (typeof ranges === "string") { + for (const range of ranges.split(",")) { + const [start, end = start] = range.split("-").map(Number); + for (let line = start; line <= end; line++) lines.add(line); + } + } + return { + name: "highlight-lines", + pre(node) { + if (lines.size > 0) this.addClassToHast(node, "has-highlighted"); + }, + line(node, line) { + if (lines.has(line)) this.addClassToHast(node, "highlighted"); + }, + }; +} + function rehypeShiki() { return async (tree) => { await docsHighlighter(); @@ -236,7 +263,10 @@ function rehypeShiki() { const result = highlighter.codeToHtml(textNode.value, { lang, themes: { light: theme.name, dark: 'github-dark' }, - transformers: [transformerNotationFocus()], + transformers: [ + transformerNotationFocus(), + transformerHighlightLines(node.properties.highlightLines), + ], }); // Store the highlighted HTML in a property instead of the text node // This prevents MDX from interpreting the HTML as JSX diff --git a/src/mdx/remark-code-snippet.ts b/src/mdx/remark-code-snippet.ts index 23c9b971..e64d2bdf 100644 --- a/src/mdx/remark-code-snippet.ts +++ b/src/mdx/remark-code-snippet.ts @@ -21,6 +21,10 @@ import { resolve, extname } from "node:path"; * `// docs:start name` … `// docs:end name` (markers are stripped, region is * dedented). * + * Highlight regions with `highlight="name"` (or `highlight="a,b"`): the snippet + * still shows the whole file (or `region`), and every other line is dimmed so + * the reader sees where the highlighted code sits in context. + * * Paths are relative to the repo that owns the page. Product docs are symlinked * in from their own repos, so an agentOS page's `examples/...` means agentOS's * examples; the caller supplies a resolver that maps the MDX file's path to the @@ -96,7 +100,8 @@ export function loadSnippet( root: string, file: string, region?: string, -): { lang: string; value: string } { + highlight?: string, +): { lang: string; value: string; highlightLines?: string } { const abs = resolve(root, file); let source: string; try { @@ -110,9 +115,68 @@ export function loadSnippet( return { lang: LANG_BY_EXT[extname(abs).toLowerCase()] ?? "text", value: extractRegion(source, region, file), + highlightLines: highlight + ? highlightedLines(source, region, highlight, file) + : undefined, }; } +/** + * The 1-based line numbers, as ranges like `3-7,12-14`, that the regions in + * `highlight` occupy in the code `extractRegion` returns for `region`. + */ +function highlightedLines( + source: string, + region: string | undefined, + highlight: string, + file: string, +): string { + const names = new Set( + highlight + .split(",") + .map((name) => name.trim()) + .filter(Boolean), + ); + const found = new Set(); + const open = new Set(); + const ranges: Array<[number, number]> = []; + // With `region`, numbering starts inside that region. + let shown = region === undefined; + let lineNumber = 0; + + for (const line of source.split("\n")) { + const m = line.match(MARKER); + if (m) { + const [, kind, name] = m; + if (name === region) shown = kind === "start"; + if (name && names.has(name)) { + if (kind === "start") { + open.add(name); + found.add(name); + } else { + open.delete(name); + } + } + continue; + } + if (!shown) continue; + lineNumber += 1; + if (open.size === 0) continue; + const last = ranges.at(-1); + if (last && last[1] === lineNumber - 1) last[1] = lineNumber; + else ranges.push([lineNumber, lineNumber]); + } + + for (const name of names) { + if (!found.has(name)) { + throw new Error( + `: highlight region "${name}" not found in ${file}`, + ); + } + } + return ranges.map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(","); +} + function getAttr( node: { attributes?: Array<{ type?: string; name?: string; value?: unknown }>; @@ -168,13 +232,17 @@ export function remarkCodeSnippet(resolveRoot?: RootResolver) { const region = getAttr(el, "region"); const title = getAttr(el, "title"); const lang = getAttr(el, "lang"); + const highlight = getAttr(el, "highlight"); - const snippet = loadSnippet(embedRoot, file, region); + const snippet = loadSnippet(embedRoot, file, region, highlight); // `file=` in the meta drives the basename tab label + GitHub source // link in the Code component; `title=` overrides the label. const meta = [`file="${file}"`]; if (title) meta.push(`title="${title}"`); + if (snippet.highlightLines) { + meta.push(`highlight="${snippet.highlightLines}"`); + } parent.children[index] = { type: "code", diff --git a/src/sitemap/products.ts b/src/sitemap/products.ts index 952c6e7a..53449bbf 100644 --- a/src/sitemap/products.ts +++ b/src/sitemap/products.ts @@ -87,15 +87,47 @@ function productSidebars(meta: ProductMetadata) { const bundle = bundleSidebars(meta.id); // A shared bundle's sidebar is authored against its source product's routes // (`/agentos/docs/...`); serve it under this product's instead. + const ownDocs = mirrorGuides(bundle.docs, bundle.guides ?? []); const docs = meta.bundleOf - ? rerootPrefix(bundle.docs, `/${meta.bundleOf}/docs`, `/${meta.id}/docs`) - : bundle.docs; + ? rerootPrefix(ownDocs, `/${meta.bundleOf}/docs`, `/${meta.id}/docs`) + : ownDocs; return { docs, integrations: bundle.integrations ?? [], }; } +/** + * Fills a `"mirror": "guides"` group in a bundle's docs sidebar with the + * bundle's own guides, so a docs tab can list them without repeating them: + * + * { "title": "Guides", "collapsible": true, "mirror": "guides" } + * + * The pages keep their `/guides/...` hrefs, so they open on the Guides tab. + */ +function mirrorGuides(node: T, guides: SidebarItem[]): T { + if (Array.isArray(node)) { + return node.map((item) => mirrorGuides(item, guides)) as unknown as T; + } + if (!node || typeof node !== "object") return node; + const { mirror, ...rest } = node as Record; + if (mirror === undefined) { + return Object.fromEntries( + Object.entries(rest).map(([key, value]) => [ + key, + mirrorGuides(value, guides), + ]), + ) as T; + } + if (mirror !== "guides") { + throw new Error(`sidebar.json: unknown "mirror" value ${JSON.stringify(mirror)}`); + } + return { + ...rest, + pages: guides.flatMap((group) => ("pages" in group ? group.pages : [group])), + } as T; +} + function rerootPrefix(node: T, from: string, to: string): T { if (Array.isArray(node)) { return node.map((item) => rerootPrefix(item, from, to)) as unknown as T; @@ -146,10 +178,13 @@ export function deploySidebar(): SidebarItem[] { ]; } +/** Guides tab groups in sidebar order, after General. Groups not listed follow in product order. */ +const GUIDE_GROUP_ORDER = ["Architecture", "Agents", "Guides"]; + /** * The Guides tab's sidebar: the website's overview, then every bundle's - * `guides` groups in product order. Groups with the same title merge into one, - * at the position the title first appears, keeping each bundle's page order. + * `guides` groups, ordered by `GUIDE_GROUP_ORDER`. Groups with the same title + * merge into one, keeping each bundle's page order. */ export function guidesSidebar(): SidebarItem[] { const overview: SidebarSection = { @@ -169,6 +204,11 @@ export function guidesSidebar(): SidebarItem[] { else merged.push({ ...item, pages: [...item.pages] }); } } + const rank = (group: SidebarSection) => { + const index = GUIDE_GROUP_ORDER.indexOf(group.title); + return index === -1 ? GUIDE_GROUP_ORDER.length : index; + }; + merged.sort((a, b) => rank(a) - rank(b)); return [overview, ...merged]; } diff --git a/src/styles/main.css b/src/styles/main.css index 01204686..755f6034 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -90,6 +90,10 @@ @apply opacity-100; } +.shiki.has-highlighted .line:not(.highlighted) { + @apply opacity-40; +} + @layer utilities { /* Cursor-following border glow for porcelain pills (deploy row, stack chips). Lights ONLY the pill's 1px border ring as the pointer moves — not the fill.