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/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"
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;