From 110ab33034ce68f0d21daec1c76a812f01408e89 Mon Sep 17 00:00:00 2001 From: Charan Date: Tue, 27 Oct 2026 14:01:44 +0530 Subject: [PATCH 1/2] fix(astro): Astro's base in every link, llms.txt slashes, phone header Sites served under Astro's `base` (GitHub Pages project sites) got broken links for anything Inkform printed from docs.json or MDX that wasn't a docs page: the navbar's `/` went to the domain root. Every site-root href now goes through one rewriter: docs pages through the docs linker (base, publicBase, trailing slash), anything else gets Astro's base, as images already did. Links already under the base are left alone, so hand-written workarounds keep working. Links written with the docs base (`/docs/x`) count as docs pages. llms.txt is built from the route table with the same linker, so its links follow `trailingSlash` and match the operation URLs the site serves. On screens 40rem and under the single-row header keeps to the screen: the tab row moves to the drawer (galley keeps its own row), the shortcut hint is hidden and a long site name gets an ellipsis. Stacked API operation pages no longer widen to their longest code sample. The sidebar group and TOC title labels read three CSS variables, so a theme can drop the uppercase without restating the rules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011YQ6fJxGHg3CxGkTzw6Yhc --- examples/astro-docs/e2e/docs.spec.ts | 13 ++++ packages/astro/CHANGELOG.md | 31 +++++++++- packages/astro/README.md | 42 ++++++++++++- packages/astro/src/components/Shell.astro | 3 +- .../astro/src/components/views/DocPage.astro | 2 +- packages/astro/src/routes/llms.txt.ts | 40 +++++++++--- .../astro/src/runtime/inline-styles.test.ts | 55 +++++++++++++++- packages/astro/src/runtime/inline-styles.ts | 62 ++++++++++++++++--- packages/astro/src/runtime/llms.test.ts | 58 +++++++++++++++++ packages/astro/src/runtime/llms.ts | 62 +++++++++++++++++++ packages/astro/src/runtime/nav.test.ts | 53 ++++++++++++++++ packages/astro/src/runtime/nav.ts | 36 ++++++++--- packages/astro/src/runtime/render.ts | 2 +- packages/astro/src/runtime/site.ts | 17 ++++- packages/astro/src/search-index.test.ts | 3 + packages/astro/src/styles/astro.css | 60 ++++++++++++++++++ packages/astro/src/themes/galley.css | 7 +++ 17 files changed, 510 insertions(+), 36 deletions(-) create mode 100644 packages/astro/src/runtime/llms.test.ts create mode 100644 packages/astro/src/runtime/llms.ts diff --git a/examples/astro-docs/e2e/docs.spec.ts b/examples/astro-docs/e2e/docs.spec.ts index 547669d..8473d97 100644 --- a/examples/astro-docs/e2e/docs.spec.ts +++ b/examples/astro-docs/e2e/docs.spec.ts @@ -110,6 +110,19 @@ test.describe('shell', () => { await expect(html).toHaveClass(/dark/); }); + test('pages fit a 375px phone without scrolling sideways', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + for (const path of ['/docs', '/docs/quickstart', '/docs/api-reference/operations/create-plant', '/docs/changelog']) { + await page.goto(path); + const widths = await page.evaluate(() => ({ + page: document.documentElement.scrollWidth, + header: document.querySelector('.fw-shell-header-inner')?.scrollWidth, + })); + expect(widths, path).toEqual({ page: 375, header: 375 }); + await expect(page.locator('.fw-theme-toggle'), path).toBeInViewport({ ratio: 1 }); + } + }); + test('the mobile drawer opens, closes on Escape, and holds the nav', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/docs/quickstart'); diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index fb35d27..1bb2d2a 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -3,7 +3,36 @@ Versions track `packages/astro/package.json`. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [0.1.0] — Unreleased +## [Unreleased] + +### Fixed + +- Every link the integration prints respects Astro's `base`. Site-root links + in docs.json (`navbarLinks`, `anchors`, `cta`, sidebar entries with an + `href`) and in MDX that aren't docs pages (`/`, `/pricing`) get the base in + front, as images already did; docs-page links still go through the docs + base. Links already under the base are left alone, so sites that wrote it + out by hand keep working. +- MDX links written with the docs base (`/docs/quickstart`) are recognized as + docs pages, so they get `publicBase` and the trailing slash too. +- `llms.txt` links follow Astro's `trailingSlash`, like the pages. The index is + built from the site's own route table now, so operation links also match + the pages for specs without `operationId`s. +- The header fits a 375px screen: on screens 40rem and under the tab row moves + to the drawer (galley keeps its own row), the ⌘K hint is hidden, and a long + site name gets an ellipsis. +- API operation pages no longer scroll sideways on phones: the stacked + column stays the width of the screen and long code samples scroll inside + their block. + +### Added + +- `--fw-label-transform`, `--fw-label-letter-spacing` and + `--fw-label-font-size` set the case, tracking and size of the sidebar's + group names and the "On this page" title, which stay small uppercase by + default. + +## [0.1.0] — 2026-10-23 First release. Needs `@inkform/framework` 0.8.0, which adds `./mdx-options`, `./routes`, `./mermaid/render`, `./openapi-render/try-it`, diff --git a/packages/astro/README.md b/packages/astro/README.md index 75d0545..bd644ca 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -18,7 +18,7 @@ the browser gets plain HTML and a few small scripts, and no React. | `/` and `//operations/` | The API Reference, when a docs.json tab has `openapi` (`` is the tab's label, slugified: `api-reference`) | | `/blog`, `/blog/` | When `content/blog/` has published posts | | `/changelog` | When `content/changelog/` has entries | -| `/llms.txt`, `/llms-full.txt` | The [llms.txt](https://llmstxt.org) index and the full corpus | +| `/llms.txt`, `/llms-full.txt` | The [llms.txt](https://llmstxt.org) index (its links match the pages', `base` and trailing slash included) and the full corpus | | `/_pagefind/` | The search index, written after `astro build` | Every route is prerendered, including in an `output: 'server'` app, so the @@ -101,7 +101,29 @@ build if the operation doesn't exist. Links in content written from the site root, the way content for a Next.js theme is (`[Quickstart](/quickstart)`), get the docs base added when they point -at a docs page. Links to anything else are left as written. +at a docs page. See [Astro's `base` and `trailingSlash`](#astros-base-and-trailingslash) +for the rest. + +## Astro's `base` and `trailingSlash` + +Write links from the site root, as if the site were served from `/`, and the +integration writes them for wherever it is served. With +`base: '/my-project'` and `trailingSlash: 'always'` in `astro.config`, and the +docs at `/docs`: + +| Written in docs.json or MDX | Becomes | +| --- | --- | +| `/quickstart` or `/docs/quickstart` (a docs page) | `/my-project/docs/quickstart/` | +| `/` (the site's home) | `/my-project/` | +| `/pricing`, `/screenshots/setup.png` (anything else on the site) | `/my-project/pricing`, `/my-project/screenshots/setup.png` | +| `https://…`, `//cdn…`, `#section`, `../relative` | unchanged | + +This covers docs.json's `navbarLinks`, `anchors`, `cta`, `logo`, `favicon` and +sidebar entries with an `href`, links and images in MDX, and the sidebar, +breadcrumbs, prev/next, search results and `llms.txt`. A link that already +starts with Astro's base is left alone. Docs-page links end in a slash when +`trailingSlash` is `'always'`; other paths keep the slash you wrote, since +they may be files. ## Theming @@ -134,6 +156,22 @@ The full token list is in §11 of the framework's Dark mode is the `dark` class on ``, set before first paint and saved in `localStorage['fw-theme']`, as in the Next.js themes. +The sidebar's group names and the "On this page" title are small tracked +capitals, as in the Next.js themes. Three variables change that, for example +to sentence case: + +```css +:root { + --fw-label-transform: none; /* default: uppercase */ + --fw-label-letter-spacing: normal; /* default: 0.07em */ + --fw-label-font-size: 0.8125rem; /* default: 0.7rem */ +} +``` + +On screens 40rem wide and under, the header keeps one row: the tabs and +navbar links move to the menu drawer, the ⌘K hint is hidden, and a long site +name is cut with an ellipsis. Galley keeps its row of tabs under the logo. + Galley names General Sans, Newsreader and JetBrains Mono but loads none of them, so nothing is fetched from a font CDN. To use them, add `@font-face` rules through `customCss` (for example from `@fontsource-variable/newsreader`). diff --git a/packages/astro/src/components/Shell.astro b/packages/astro/src/components/Shell.astro index 960d349..21dd146 100644 --- a/packages/astro/src/components/Shell.astro +++ b/packages/astro/src/components/Shell.astro @@ -33,8 +33,9 @@ const links = topNav(site.config, { hasChangelog: site.changelog.length > 0, active, link: site.link, + resolve: site.href, }); -const cta = site.config.cta; +const cta = site.config.cta ? { ...site.config.cta, href: site.href(site.config.cta.href) } : undefined; const ctaExternal = cta ? /^https?:\/\//.test(cta.href) : false; const hasSidebar = !!sidebar && sidebar.length > 0; const hasToc = !!toc && toc.length > 0; diff --git a/packages/astro/src/components/views/DocPage.astro b/packages/astro/src/components/views/DocPage.astro index aebd571..5b46e0d 100644 --- a/packages/astro/src/components/views/DocPage.astro +++ b/packages/astro/src/components/views/DocPage.astro @@ -34,7 +34,7 @@ const { prev, next } = docPagination(site.config, page.slug, site.link); title={page.title} description={description} active={{ kind: 'docs', tab: page.tab }} - sidebar={docsSidebar(site.config, page.tab, page.slug, site.link)} + sidebar={docsSidebar(site.config, page.tab, page.slug, site.link, site.href)} toc={headingsFromHtml(html)} contentType="doc" pageCss={renderer.styles.css} diff --git a/packages/astro/src/routes/llms.txt.ts b/packages/astro/src/routes/llms.txt.ts index 7eb4800..89eaa9d 100644 --- a/packages/astro/src/routes/llms.txt.ts +++ b/packages/astro/src/routes/llms.txt.ts @@ -1,20 +1,42 @@ /** - * `/llms.txt`: the llms.txt index (https://llmstxt.org) from the - * framework's builder, with links under this site's docs base. + * `/llms.txt`: the llms.txt index (https://llmstxt.org), with links + * written like every other link on this site (see ../runtime/llms.ts). */ import type { APIRoute } from 'astro'; -import config from 'virtual:@inkform/astro/config'; -import { buildLlmsTxt } from '@inkform/framework/llms-txt'; -import { joinHref } from '@inkform/framework/routes'; +import { loadDocPage } from '@inkform/framework/content'; +import { llmsIndex } from '../runtime/llms'; +import { BLOG_PATH, CHANGELOG_PATH, operationPath } from '../runtime/routes'; import { loadSite } from '../runtime/site'; export const prerender = true; +const text = (value: unknown) => (typeof value === 'string' ? value : null); + export const GET: APIRoute = async () => { const site = await loadSite(); - const body = await buildLlmsTxt({ - basePath: config.publicBase, - apiBasePath: site.api ? joinHref(config.publicBase, site.api.slug) : undefined, - }); + const api = site.api; + const body = llmsIndex( + { + name: site.config.name, + docs: site.routes.flatMap((route) => + route.kind === 'doc' + ? [{ title: route.page.title, path: route.path, description: text(loadDocPage(route.page.file)?.data.description) }] + : [], + ), + operations: api + ? api.tree.tagGroups.flatMap((group) => + group.operations.map((op) => ({ + title: op.summary || op.operationId, + path: operationPath(api.slug, op.operationId), + method: op.method, + apiPath: op.path, + })), + ) + : [], + changelog: site.changelog.length > 0 ? CHANGELOG_PATH : null, + blog: site.blog.map((post) => ({ title: post.title, path: `${BLOG_PATH}/${post.slug}`, description: post.description })), + }, + site.link, + ); return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } }); }; diff --git a/packages/astro/src/runtime/inline-styles.test.ts b/packages/astro/src/runtime/inline-styles.test.ts index 35fb658..4370e68 100644 --- a/packages/astro/src/runtime/inline-styles.test.ts +++ b/packages/astro/src/runtime/inline-styles.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { assetRewriter, docsLinkRewriter, InlineStyleSheet, splitDeclarations } from './inline-styles'; +import { assetRewriter, docsLinkRewriter, InlineStyleSheet, siteHrefRewriter, splitDeclarations } from './inline-styles'; +import { createLinker } from './routes'; describe('splitDeclarations', () => { it('splits on top-level semicolons only', () => { @@ -65,3 +66,55 @@ describe('assetRewriter', () => { expect(assetRewriter('/sub')('//cdn/logo.svg')).toBeNull(); }); }); + +describe('siteHrefRewriter', () => { + const pages = new Set(['', 'quickstart', 'guides/setup']); + + it('sends docs pages through the linker and puts the base in front of every other site-root path', () => { + const rewrite = siteHrefRewriter(pages, createLinker('/sub/docs', 'always'), { base: '/docs', siteBase: '/sub', trailingSlash: 'always' }); + expect(rewrite('/quickstart')).toBe('/sub/docs/quickstart/'); + expect(rewrite('/guides/setup#install')).toBe('/sub/docs/guides/setup/#install'); + expect(rewrite('/')).toBe('/sub/'); + expect(rewrite('/#top')).toBe('/sub/#top'); + expect(rewrite('/pricing')).toBe('/sub/pricing'); + expect(rewrite('/shots/a.png')).toBe('/sub/shots/a.png'); + }); + + it('leaves absolute, relative, hash-only and already-based hrefs alone', () => { + const rewrite = siteHrefRewriter(pages, createLinker('/sub/docs', 'always'), { base: '/docs', siteBase: '/sub', trailingSlash: 'always' }); + for (const href of ['https://example.com/', '//cdn.example.com/a', 'mailto:a@b.c', 'quickstart', '#install', '/sub', '/sub/', '/sub/pricing']) { + expect(rewrite(href), href).toBeNull(); + } + }); + + it('finds docs pages written with the docs base already in front', () => { + const rewrite = siteHrefRewriter(pages, createLinker('/sub/docs', 'always'), { base: '/docs', siteBase: '/sub', trailingSlash: 'always' }); + expect(rewrite('/docs/quickstart')).toBe('/sub/docs/quickstart/'); + expect(rewrite('/docs')).toBe('/sub/docs/'); + expect(rewrite('/docs/guides/setup?x=1')).toBe('/sub/docs/guides/setup/?x=1'); + // Not a page: Astro's base only. + expect(rewrite('/docs/nope')).toBe('/sub/docs/nope'); + }); + + it('writes the host home without a slash when Astro asks for none', () => { + const rewrite = siteHrefRewriter(pages, createLinker('/sub/docs', 'never'), { base: '/docs', siteBase: '/sub', trailingSlash: 'never' }); + expect(rewrite('/')).toBe('/sub'); + expect(rewrite('/?x=1')).toBe('/sub?x=1'); + expect(rewrite('/quickstart')).toBe('/sub/docs/quickstart'); + }); + + it('changes only docs links when the site has no base', () => { + const rewrite = siteHrefRewriter(pages, createLinker('/docs', 'ignore'), { base: '/docs', siteBase: '', trailingSlash: 'ignore' }); + expect(rewrite('/quickstart')).toBe('/docs/quickstart'); + expect(rewrite('/')).toBeNull(); + expect(rewrite('/pricing')).toBeNull(); + }); + + it('rewrites links in rendered content', () => { + const sheet = new InlineStyleSheet(); + const html = sheet.extract('homeqp', { + href: siteHrefRewriter(pages, createLinker('/sub/docs', 'always'), { base: '/docs', siteBase: '/sub', trailingSlash: 'always' }), + }); + expect(html).toBe('homeqp'); + }); +}); diff --git a/packages/astro/src/runtime/inline-styles.ts b/packages/astro/src/runtime/inline-styles.ts index d12158a..765c667 100644 --- a/packages/astro/src/runtime/inline-styles.ts +++ b/packages/astro/src/runtime/inline-styles.ts @@ -11,11 +11,13 @@ * its hash with Astro's CSP. The rules are `!important`, so they keep the * precedence an inline style had. * - * 2. Site-root links to docs pages gain the docs base (and, when Astro has a - * `base`, site-root images gain it). Content written for a - * Next.js theme (docs at `/`) links `/quickstart`; under `/docs` that has - * to be `/docs/quickstart`. Only links whose path is a page of this docs - * site change, so links to the host's own pages and assets are left alone. + * 2. Site-root links and images get the paths the browser should request. + * Content written for a Next.js theme (docs at `/`) links `/quickstart`; + * under `/docs` that has to be `/docs/quickstart`, so a link whose path is + * a page of this docs site goes through the docs linker. Any other + * site-root link or image (`/`, `/pricing`, `/shots/a.png`) gains Astro's + * `base`, the way `next/link` adds `basePath`, so it still reaches the + * host's page when the site is served from a subpath. */ import { fromHtml } from 'hast-util-from-html'; @@ -147,17 +149,57 @@ export function assetRewriter(siteBase: string): HrefRewriter { /** * A rewriter for site-root links to pages of this docs site: `/quickstart#x` - * → `link('quickstart') + '#x'` when `quickstart` is one of `paths`. The - * docs home (`/`) is left alone, since on a host site it means the host's home. + * → `link('quickstart') + '#x'` when `quickstart` is one of `paths`. A link + * that already starts with the docs `base` (`/docs/quickstart`) is found too, + * so it gets `publicBase` and the trailing slash like the rest. The site root + * (`/`) is left alone, since on a host site it means the host's home. */ -export function docsLinkRewriter(paths: ReadonlySet, link: (path: string) => string): HrefRewriter { +export function docsLinkRewriter(paths: ReadonlySet, link: (path: string) => string, base = ''): HrefRewriter { + const prefix = base.replace(/^\/+|\/+$/g, ''); return (href) => { if (!href.startsWith('/') || href.startsWith('//')) return null; const cut = href.search(/[?#]/); const pathname = cut === -1 ? href : href.slice(0, cut); const suffix = cut === -1 ? '' : href.slice(cut); const path = pathname.replace(/^\/+|\/+$/g, ''); - if (!path || !paths.has(path)) return null; - return link(path) + suffix; + if (!path) return null; + if (paths.has(path)) return link(path) + suffix; + if (prefix && (path === prefix || path.startsWith(`${prefix}/`))) { + const rest = path.slice(prefix.length + 1); + if (paths.has(rest)) return link(rest) + suffix; + } + return null; + }; +} + +export interface SiteHrefOptions { + /** The docs route base (`/docs`), normalized. */ + base: string; + /** Astro's own `base`, normalized. */ + siteBase: string; + trailingSlash: 'always' | 'never' | 'ignore'; +} + +/** + * Every site-root href Inkform prints, from docs.json (navbar links, anchors, + * the CTA, sidebar links) or from content: a page of this docs site goes + * through `link` (docs base, `publicBase`, trailing slash); any other + * site-root path gets Astro's `base` in front. `/` is the host's home, which + * under a base is `/` (`` with `trailingSlash: 'never'`). Absolute, + * protocol-relative, relative and hash-only hrefs, and paths already under + * Astro's base, are left alone. + */ +export function siteHrefRewriter( + paths: ReadonlySet, + link: (path: string) => string, + { base, siteBase, trailingSlash }: SiteHrefOptions, +): HrefRewriter { + const docs = docsLinkRewriter(paths, link, base); + const asset = assetRewriter(siteBase); + return (href) => { + const docsHref = docs(href); + if (docsHref !== null) return docsHref; + if (siteBase && trailingSlash === 'never' && /^\/(?=[?#]|$)/.test(href)) return siteBase + href.slice(1); + return asset(href); }; } diff --git a/packages/astro/src/runtime/llms.test.ts b/packages/astro/src/runtime/llms.test.ts new file mode 100644 index 0000000..06ba504 --- /dev/null +++ b/packages/astro/src/runtime/llms.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { llmsIndex, type LlmsIndexInput } from './llms'; +import { createLinker } from './routes'; + +const input: LlmsIndexInput = { + name: 'Plants', + docs: [ + { title: 'Intro', path: '', description: 'Start here.' }, + { title: 'Setup', path: 'guides/setup', description: null }, + ], + operations: [{ title: 'Get a plant', path: 'api-reference/operations/get-plantsid', method: 'get', apiPath: '/plants/{id}' }], + changelog: 'changelog', + blog: [{ title: 'Hello', path: 'blog/hello', description: 'First post.' }], +}; + +describe('llmsIndex', () => { + it('writes the framework’s llms.txt format', () => { + expect(llmsIndex(input, createLinker('/docs', 'ignore'))).toBe( + [ + '# Plants', + '', + '> Documentation for Plants.', + '', + '## Docs', + '', + '- [Intro](/docs): Start here.', + '- [Setup](/docs/guides/setup)', + '', + '## API Reference', + '', + '- [Get a plant](/docs/api-reference/operations/get-plantsid): GET /plants/{id}', + '', + '## Changelog', + '', + '- [Changelog](/docs/changelog)', + '', + '## Blog', + '', + '- [Hello](/docs/blog/hello): First post.', + '', + ].join('\n'), + ); + }); + + it('links with Astro’s base and trailing slash, like the pages', () => { + const text = llmsIndex(input, createLinker('/sub/docs', 'always')); + expect(text).toContain('- [Intro](/sub/docs/): Start here.'); + expect(text).toContain('- [Setup](/sub/docs/guides/setup/)'); + expect(text).toContain('(/sub/docs/api-reference/operations/get-plantsid/): GET /plants/{id}'); + expect(text).toContain('- [Changelog](/sub/docs/changelog/)'); + expect(text).toContain('- [Hello](/sub/docs/blog/hello/): First post.'); + }); + + it('leaves out empty sections', () => { + const text = llmsIndex({ name: 'Bare', docs: [{ title: 'Intro', path: '' }], operations: [], changelog: null, blog: [] }, createLinker('', 'never')); + expect(text).toBe('# Bare\n\n> Documentation for Bare.\n\n## Docs\n\n- [Intro](/)\n'); + }); +}); diff --git a/packages/astro/src/runtime/llms.ts b/packages/astro/src/runtime/llms.ts new file mode 100644 index 0000000..9a5a794 --- /dev/null +++ b/packages/astro/src/runtime/llms.ts @@ -0,0 +1,62 @@ +/** + * The llms.txt index (https://llmstxt.org): the site's name, then grouped + * Markdown links to its docs pages, API operations, changelog and blog posts, + * in the format of the framework's `buildLlmsTxt()`. + * + * It's built here, from the route table, rather than by the framework, + * so every link is written by the same linker as the pages themselves: under + * `publicBase`, ending in a slash when Astro's `trailingSlash` is `'always'`, + * and pointing at the operation URLs this site serves (a spec without + * operationIds names them differently in the framework's own index). Pure, + * so the output is unit-tested. + */ + +import type { Linker } from './routes'; + +export interface LlmsLink { + title: string; + /** Base-relative path, as in the route table (`''` is the docs home). */ + path: string; + description?: string | null; +} + +export interface LlmsOperation { + title: string; + path: string; + /** HTTP method, any case. */ + method: string; + /** The operation's path in the spec, e.g. `/plants/{id}`. */ + apiPath: string; +} + +export interface LlmsIndexInput { + name: string; + docs: LlmsLink[]; + operations: LlmsOperation[]; + /** The changelog page's path, or null when the site has no changelog. */ + changelog: string | null; + blog: LlmsLink[]; +} + +function section(title: string, lines: string[]): string { + return lines.length > 0 ? `## ${title}\n\n${lines.join('\n')}` : ''; +} + +function item(text: string, href: string, note?: string | null): string { + return `- [${text}](${href})${note ? `: ${note}` : ''}`; +} + +export function llmsIndex(input: LlmsIndexInput, link: Linker): string { + const parts = [ + `# ${input.name}`, + `> Documentation for ${input.name}.`, + section('Docs', input.docs.map((d) => item(d.title, link(d.path), d.description))), + section( + 'API Reference', + input.operations.map((op) => item(op.title, link(op.path), `${op.method.toUpperCase()} ${op.apiPath}`)), + ), + section('Changelog', input.changelog === null ? [] : [item('Changelog', link(input.changelog))]), + section('Blog', input.blog.map((p) => item(p.title, link(p.path), p.description))), + ]; + return parts.filter(Boolean).join('\n\n').trimEnd() + '\n'; +} diff --git a/packages/astro/src/runtime/nav.test.ts b/packages/astro/src/runtime/nav.test.ts index fab9e30..cbe39b2 100644 --- a/packages/astro/src/runtime/nav.test.ts +++ b/packages/astro/src/runtime/nav.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { DocsConfig } from '@inkform/framework/nav'; import type { OpenApiNavTree } from '@inkform/framework/openapi-engine/nav'; +import { siteHrefRewriter } from './inline-styles'; import { apiSidebar, docBreadcrumbs, docPagination, docsSidebar, topNav } from './nav'; import { createLinker } from './routes'; @@ -98,6 +99,58 @@ describe('topNav', () => { }); }); +describe('under an Astro base with trailing slashes', () => { + // Astro `base: '/sub'`, `trailingSlash: 'always'`: the docs are at /sub/docs/. + const subLink = createLinker('/sub/docs', 'always'); + const rewrite = siteHrefRewriter(new Set(['', 'setup', 'setup/deep', 'recipes/bake']), subLink, { base: '/docs', siteBase: '/sub', trailingSlash: 'always' }); + const resolve = (href: string) => rewrite(href) ?? href; + const based: DocsConfig = { + ...config, + navbarLinks: [ + { name: 'Home', href: '/' }, + { name: 'Setup', href: '/setup' }, + { name: 'Status', href: '/status' }, + { name: 'Already based', href: '/sub/pricing' }, + ], + }; + + it('puts every navbar link under the base: docs pages through the linker, host pages after the base', () => { + const links = topNav(based, { apiSlug: 'api-reference', hasBlog: true, hasChangelog: true, active: { kind: 'api' }, link: subLink, resolve }); + expect(links.map((l) => [l.label, l.href])).toEqual([ + ['Guides', '/sub/docs/'], + ['Recipes', '/sub/docs/recipes/bake/'], + ['API Reference', '/sub/docs/api-reference/'], + ['GitHub', 'https://github.com/x'], + ['Home', '/sub/'], + ['Setup', '/sub/docs/setup/'], + ['Status', '/sub/status'], + ['Already based', '/sub/pricing'], + ['Blog', '/sub/docs/blog/'], + ['Changelog', '/sub/docs/changelog/'], + ]); + }); + + it('resolves sidebar links that carry their own href', () => { + const withLink: DocsConfig = { + ...config, + tabs: [{ tab: 'Guides', navigation: [{ group: 'Start', pages: [{ title: 'Pricing', slug: 'p', file: '', href: '/pricing' }] }] }], + }; + const [group] = docsSidebar(withLink, 'Guides', null, subLink, resolve); + expect(group?.items.map((i) => [i.title, i.href, i.external])).toEqual([['Pricing', '/sub/pricing', false]]); + const [plain] = docsSidebar(config, 'Guides', null, subLink, resolve); + expect(plain?.items.map((i) => i.href)).toEqual(['/sub/docs/', '/sub/docs/setup/', '/sub/docs/setup/deep/', 'https://example.com/changes']); + }); + + it('writes breadcrumbs and prev/next with the base and the slash', () => { + const deep = { title: 'Deep', slug: 'setup/deep', file: 'setup/deep.mdx', depth: 2, group: 'Start', tab: 'Guides' }; + expect(docBreadcrumbs(config, deep, subLink)[1]).toEqual({ label: 'Setup', href: '/sub/docs/setup/' }); + expect(docPagination(config, 'setup', subLink)).toEqual({ + prev: { title: 'Intro', href: '/sub/docs/' }, + next: { title: 'Deep', href: '/sub/docs/setup/deep/' }, + }); + }); +}); + describe('docBreadcrumbs and docPagination', () => { const deep = { title: 'Deep', slug: 'setup/deep', file: 'setup/deep.mdx', depth: 2, group: 'Start', tab: 'Guides' }; diff --git a/packages/astro/src/runtime/nav.ts b/packages/astro/src/runtime/nav.ts index 4f9fa49..9d145de 100644 --- a/packages/astro/src/runtime/nav.ts +++ b/packages/astro/src/runtime/nav.ts @@ -30,8 +30,22 @@ export interface NavGroup { const isExternal = (href: string) => /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//'); -/** Sidebar groups for one docs tab. Nested `children` follow their parent, one depth level deeper. */ -export function docsSidebar(config: DocsConfig, tabName: string, activeSlug: string | null, link: Linker): NavGroup[] { +/** Turns an href written in docs.json into the one readers follow (see Site.href). */ +export type HrefResolver = (href: string) => string; + +const asWritten: HrefResolver = (href) => href; + +/** + * Sidebar groups for one docs tab. Nested `children` follow their parent, one + * depth level deeper. A page with its own `href` goes through `resolve`. + */ +export function docsSidebar( + config: DocsConfig, + tabName: string, + activeSlug: string | null, + link: Linker, + resolve: HrefResolver = asWritten, +): NavGroup[] { const tab = docTabs(config).find((t) => t.tab === tabName) ?? docTabs(config)[0]; return (tab?.navigation ?? []).map((g) => { const items: NavItem[] = []; @@ -40,8 +54,8 @@ export function docsSidebar(config: DocsConfig, tabName: string, activeSlug: str const external = !!p.href && isExternal(p.href); items.push({ title: p.title, - // A page with its own `href` is a link out, used as written. - href: p.href ?? link(p.slug), + // A page with its own `href` is a link, not an MDX page. + href: p.href ? resolve(p.href) : link(p.slug), active: !p.href && p.slug === activeSlug, depth, external, @@ -83,12 +97,20 @@ export type ActiveArea = { kind: 'docs'; tab: string } | { kind: 'api' } | { kin * The header row: one link per docs.json tab (a docs tab opens its first page, * the API tab its reference), then anchors and navbar links (anchors win when * both name the same URL), then Blog and Changelog when the site has them. + * Anchor and navbar hrefs go through `resolve`. */ export function topNav( config: DocsConfig, - options: { apiSlug: string | null; hasBlog: boolean; hasChangelog: boolean; active: ActiveArea; link: Linker }, + options: { + apiSlug: string | null; + hasBlog: boolean; + hasChangelog: boolean; + active: ActiveArea; + link: Linker; + resolve?: HrefResolver; + }, ): TopNavLink[] { - const { apiSlug, hasBlog, hasChangelog, active, link } = options; + const { apiSlug, hasBlog, hasChangelog, active, link, resolve = asWritten } = options; const out: TopNavLink[] = []; for (const t of docTabs(config)) { @@ -111,7 +133,7 @@ export function topNav( if (seen.has(l.href)) continue; seen.add(l.href); const external = isExternal(l.href); - out.push({ label: l.name, href: l.href, active: false, external }); + out.push({ label: l.name, href: external ? l.href : resolve(l.href), active: false, external }); } if (hasBlog) out.push({ label: 'Blog', href: link(BLOG_PATH), active: active.kind === 'blog', external: false }); diff --git a/packages/astro/src/runtime/render.ts b/packages/astro/src/runtime/render.ts index 9355147..b1361b9 100644 --- a/packages/astro/src/runtime/render.ts +++ b/packages/astro/src/runtime/render.ts @@ -19,7 +19,7 @@ import { InlineStyleSheet, type HrefRewriter } from './inline-styles'; import { siteComponents, type SiteComponentOptions } from './mdx-components'; export interface PageRendererOptions extends SiteComponentOptions { - /** Applied to every link in the rendered HTML (see docsLinkRewriter). */ + /** Applied to every link in the rendered HTML (see siteHrefRewriter). */ rewriteHref?: HrefRewriter; /** Applied to every image source (see assetRewriter). */ rewriteSrc?: HrefRewriter; diff --git a/packages/astro/src/runtime/site.ts b/packages/astro/src/runtime/site.ts index aacf512..8ac3a60 100644 --- a/packages/astro/src/runtime/site.ts +++ b/packages/astro/src/runtime/site.ts @@ -14,7 +14,7 @@ import { buildNavTree, type OpenApiNavTree } from '@inkform/framework/openapi-en import { parseOpenApiDocument } from '@inkform/framework/openapi-engine/parse'; import { apiTab, apiTabSlug } from '@inkform/framework/routes'; import { buildRoutes, createLinker, operationPath, routeIndex, type Linker, type SiteRoute } from './routes'; -import { assetRewriter, docsLinkRewriter } from './inline-styles'; +import { assetRewriter, siteHrefRewriter } from './inline-styles'; import type { OperationRef } from './mdx-components'; import { PageRenderer } from './render'; @@ -40,6 +40,12 @@ export interface Site { renderer: () => PageRenderer; /** A docs.json asset path (logo, favicon) as the browser should request it, under Astro's `base`. */ asset: (path: string) => string; + /** + * A docs.json href (navbar link, anchor, CTA, sidebar link) as readers + * follow it: docs pages through `link`, other site-root paths under + * Astro's `base`, everything else as written. + */ + href: (href: string) => string; } export class ContentError extends Error { @@ -105,12 +111,17 @@ async function readSite(): Promise { : null; const byPath = routeIndex(routes); - const rewriteHref = docsLinkRewriter(new Set(byPath.keys()), link); + const rewriteHref = siteHrefRewriter(new Set(byPath.keys()), link, { + base: config.base, + siteBase: config.siteBase, + trailingSlash: config.trailingSlash, + }); const rewriteSrc = assetRewriter(config.siteBase); const renderer = () => new PageRenderer({ mermaid: config.mermaid, operation, rewriteHref, rewriteSrc }); const asset = (path: string) => rewriteSrc(path) ?? path; + const href = (value: string) => rewriteHref(value) ?? value; - return { config: docs, api, blog, changelog, routes, byPath, link, operation, renderer, asset }; + return { config: docs, api, blog, changelog, routes, byPath, link, operation, renderer, asset, href }; } let cached: Promise | null = null; diff --git a/packages/astro/src/search-index.test.ts b/packages/astro/src/search-index.test.ts index 54ef51f..94aa352 100644 --- a/packages/astro/src/search-index.test.ts +++ b/packages/astro/src/search-index.test.ts @@ -11,6 +11,9 @@ describe('pageUrl', () => { expect(pageUrl(path.join('a', 'b.html'), '/docs', 'ignore')).toBe('/docs/a/b'); expect(pageUrl(path.join('quickstart', 'index.html'), '', 'ignore')).toBe('/quickstart'); expect(pageUrl(path.join('quickstart', 'index.html'), '/docs', 'always')).toBe('/docs/quickstart/'); + // Under Astro's base, publicBase carries it. + expect(pageUrl('index.html', '/sub/docs', 'always')).toBe('/sub/docs/'); + expect(pageUrl(path.join('quickstart', 'index.html'), '/sub/docs', 'always')).toBe('/sub/docs/quickstart/'); }); }); diff --git a/packages/astro/src/styles/astro.css b/packages/astro/src/styles/astro.css index 2af1549..65e919f 100644 --- a/packages/astro/src/styles/astro.css +++ b/packages/astro/src/styles/astro.css @@ -43,6 +43,66 @@ html:not(.dark) .fw-shell-brand-logo--dark { display: none; } +/* ── Header on phones ───────────────────────────────────────────────────── + One row has to hold the logo, the menu button, search and the theme + toggle in 375px. The tab row moves out (the drawer lists the same links), + the ⌘K hint goes (no keyboard to press it on), and a long site name ends + in an ellipsis instead of pushing the actions off screen. Themes that + give the tabs a row of their own (galley) show it again. */ + +@media (max-width: 40rem) { + .fw-shell-header-inner { + gap: var(--fw-space-2); + } + + .fw-shell-header-start, + .fw-shell-logo, + .fw-shell-brand { + min-width: 0; + flex-shrink: 1; + } + + .fw-shell-brand-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .fw-shell-topnav { + display: none; + } + + .fw-search-trigger kbd { + display: none; + } +} + +/* ── API operation pages, stacked ──────────────────────────────────────── + Below 80rem the docs and the code samples share one column. As `1fr` it + grows to the widest code sample; `minmax(0, 1fr)` keeps it to the screen + and lets each sample scroll inside its block. */ + +@media (max-width: 80rem) { + .fw-apiref-body-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +/* ── Small-caps labels: sidebar group names and "On this page" ─────────── + The framework sets these in tracked uppercase. A theme sets the three + variables to change that without restating the rules, for example + sentence case: + --fw-label-transform: none; + --fw-label-letter-spacing: normal; + --fw-label-font-size: 0.8125rem; */ + +.fw-sidebar-group-label, +.fw-toc-title { + font-size: var(--fw-label-font-size, 0.7rem); + letter-spacing: var(--fw-label-letter-spacing, 0.07em); + text-transform: var(--fw-label-transform, uppercase); +} + /* ── Sidebar heading (the active tab's label) ─────────────────────────── */ .fw-sidebar-title { diff --git a/packages/astro/src/themes/galley.css b/packages/astro/src/themes/galley.css index c36743c..fca54e5 100644 --- a/packages/astro/src/themes/galley.css +++ b/packages/astro/src/themes/galley.css @@ -127,6 +127,13 @@ html.dark { border-bottom: 1px solid var(--fw-border); } +/* The tabs have a row of their own, so they stay on phones too. */ +@media (max-width: 40rem) { + .fw-shell-topnav { + display: flex; + } +} + /* Search sits centered in the space between the logo and the other actions. */ .fw-shell-actions > .fw-search-trigger { flex: 1; From cd39fabca3308afa1974b4b56f56c572d4f07625 Mon Sep 17 00:00:00 2001 From: Charan Date: Tue, 27 Oct 2026 14:10:30 +0530 Subject: [PATCH 2/2] fix(deps): clear new high npm audit advisories CI's npm audit --audit-level=high gate started failing on advisories published after the last green main run (23 Oct). Lockfile only, via npm audit fix: - body-parser 2.3.0 -> 2.4.0 (high GHSA-27r3-jxmh-p828), with qs 6.16.0 -> 6.17.0 - undici 7.30.0 -> 7.30.1 (high GHSA-4qv8-2p9j-2685, GHSA-f72v-6w45-q472, GHSA-xjj6-f33q-929c) - fast-uri 3.1.8 -> 3.1.9 (four moderate advisories) npm audit now reports 0 vulnerabilities. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011YQ6fJxGHg3CxGkTzw6Yhc --- package-lock.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec3c204..2d19852 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4984,18 +4984,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -6649,9 +6649,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -9620,9 +9620,9 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", + "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -10855,9 +10855,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "license": "MIT", "engines": { "node": ">=20.18.1"