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
13 changes: 13 additions & 0 deletions examples/astro-docs/e2e/docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

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

31 changes: 30 additions & 1 deletion packages/astro/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
42 changes: 40 additions & 2 deletions packages/astro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ the browser gets plain HTML and a few small scripts, and no React.
| `<base>/<api-tab>` and `<base>/<api-tab>/operations/<operationId>` | The API Reference, when a docs.json tab has `openapi` (`<api-tab>` is the tab's label, slugified: `api-reference`) |
| `<base>/blog`, `<base>/blog/<slug>` | When `content/blog/` has published posts |
| `<base>/changelog` | When `content/changelog/` has entries |
| `<base>/llms.txt`, `<base>/llms-full.txt` | The [llms.txt](https://llmstxt.org) index and the full corpus |
| `<base>/llms.txt`, `<base>/llms-full.txt` | The [llms.txt](https://llmstxt.org) index (its links match the pages', `base` and trailing slash included) and the full corpus |
| `<base>/_pagefind/` | The search index, written after `astro build` |

Every route is prerendered, including in an `output: 'server'` app, so the
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -134,6 +156,22 @@ The full token list is in §11 of the framework's
Dark mode is the `dark` class on `<html>`, 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`).
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/components/Shell.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/components/views/DocPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
40 changes: 31 additions & 9 deletions packages/astro/src/routes/llms.txt.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
/**
* `<base>/llms.txt`: the llms.txt index (https://llmstxt.org) from the
* framework's builder, with links under this site's docs base.
* `<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' } });
};
55 changes: 54 additions & 1 deletion packages/astro/src/runtime/inline-styles.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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('<a href="/">home</a><a href="/quickstart">q</a><a href="/pricing">p</a>', {
href: siteHrefRewriter(pages, createLinker('/sub/docs', 'always'), { base: '/docs', siteBase: '/sub', trailingSlash: 'always' }),
});
expect(html).toBe('<a href="/sub/">home</a><a href="/sub/docs/quickstart/">q</a><a href="/sub/pricing">p</a>');
});
});
Loading
Loading