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
1 change: 1 addition & 0 deletions examples/astro-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ npm run test:e2e --workspace=@inkform/example-astro-docs # Playwright: builds,
- [`src/mdx-components.tsx`](./src/mdx-components.tsx) is the site's own MDX
component (`<Screenshot>`), registered with the `components` option and
styled by `src/styles/docs.css` through `customCss`.
- `credits: true` turns on the "Built with Inkform" line under each page.
- [`e2e/docs.spec.ts`](./e2e/docs.spec.ts) checks that every page renders with
no CSP violation, that tabs, code groups, accordions, the TOC, the theme
toggle, the drawer, diagrams, the API sample pickers and Try It work, that
Expand Down
2 changes: 2 additions & 0 deletions examples/astro-docs/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export default defineConfig({
// The site's own MDX components, and the CSS they use.
components: './src/mdx-components.tsx',
customCss: ['./src/styles/docs.css'],
// "Built with Inkform" under each page. Off unless a site turns it on.
credits: true,
}),
],
// This site has no Markdown pages of its own. Docs code blocks are
Expand Down
2 changes: 2 additions & 0 deletions examples/astro-docs/content/changelog/2026-10-01-v0-0-1.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ date: 2026-10-01
version: 0.0.1
---

### Added

A route per `docs.json` page.

```ts
Expand Down
6 changes: 6 additions & 0 deletions examples/astro-docs/content/changelog/2026-10-23-v0-1-0.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ date: 2026-10-23
version: 0.1.0
---

### Added

- Docs pages, the API Reference, blog and changelog under one base path.
- Pagefind search, built after `astro build`.
- `llms.txt` and `llms-full.txt`.

### Changed

- Rendered without React in the browser: tabs, code groups and search are small scripts.
24 changes: 24 additions & 0 deletions examples/astro-docs/e2e/docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,30 @@ test.describe('components', () => {
});
});

test.describe('changelog', () => {
test('lists each version with its date, and every id on the page is unique', async ({ page }) => {
await page.goto('/docs/changelog');
await expect(page.locator('.fw-changelog-title')).toHaveText(['The Astro integration', 'First sketch']);
await expect(page.locator('.fw-changelog-entry .fw-page-meta')).toHaveText(['2026-10-23 · v0.1.0', '2026-10-01 · v0.0.1']);
await expect(page.locator('.fw-toc-link')).toHaveText(['The Astro integration', 'First sketch']);
await expect(page.locator('.fw-toc-link').first()).toHaveAttribute('href', '#v0-1-0');
// Both entries have an "Added" section; each keeps its own id.
await expect(page.locator('#v0-1-0-added')).toHaveText('Added');
await expect(page.locator('#v0-0-1-added')).toHaveText('Added');
const ids = await page.locator('[id]').evaluateAll((els) => els.map((el) => el.id));
expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]);
});
});

test.describe('credits', () => {
test('the "Built with Inkform" line links to the repository and stays out of search', async ({ page }) => {
await page.goto('/docs/quickstart');
const credit = page.locator('main .fw-credits');
await expect(credit.getByRole('link', { name: 'Built with Inkform' })).toHaveAttribute('href', 'https://github.com/inkform-dev/framework');
await expect(credit).toHaveAttribute('data-pagefind-ignore', 'all');
});
});

test.describe('API reference', () => {
test('the sample picker and response tabs switch', async ({ page }) => {
await page.goto('/docs/api-reference/operations/create-plant');
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Versions track `packages/astro/package.json`. Format loosely follows

### Fixed

- Headings inside changelog entries no longer share ids across entries: each
entry's heading and footnote ids are scoped to it (`#v0-2-0-added`), and
two entries whose files share a name get distinct anchors. A `version`
written with its `v` (`v1.2.0`) no longer shows as `vv1.2.0`, and the date
is a `<time>` element.
- 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
Expand All @@ -32,6 +37,9 @@ Versions track `packages/astro/package.json`. Format loosely follows
kit name it reuses replaces the kit's component. A `.tsx`/`.jsx` module
works in a site without `@astrojs/react`: the integration has Vite compile
its JSX for React when no UI integration does.
- `credits` option: a small "Built with Inkform" link to the Inkform repository
under every docs page. Off by default.
- README: how to write changelog entries (file names, frontmatter, anchors).
- `--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
Expand Down
51 changes: 50 additions & 1 deletion packages/astro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ the browser gets plain HTML and a few small scripts, and no React.
| `<base>` and `<base>/<slug>` | Every page listed in `content/docs/docs.json` |
| `<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>/changelog` | When `content/changelog/` has entries (see [Changelog](#changelog)) |
| `<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` |

Expand Down Expand Up @@ -81,6 +81,7 @@ content/
| `search` | `true` | Build a Pagefind index after `astro build` and show the search dialog. |
| `mermaid` | `false` | Draw ```` ```mermaid ```` fences as diagrams in the browser. Needs `npm install mermaid`. Off, they show their source. |
| `llmsTxt` | `true` | Serve `llms.txt` and `llms-full.txt`. |
| `credits` | `false` | Show a small "Built with Inkform" link, to the [Inkform repository](https://github.com/inkform-dev/framework), under every docs page. |

## Components

Expand Down Expand Up @@ -152,6 +153,54 @@ theme is (`[Quickstart](/quickstart)`), get the docs base added when they point
at a docs page. See [Astro's `base` and `trailingSlash`](#astros-base-and-trailingslash)
for the rest.

## Changelog

`content/changelog/` turns on `<base>/changelog`: one page with every entry,
newest first, each under its date and version, and the entries listed in the
on-this-page rail. Write one MDX file per release, named
`YYYY-MM-DD-<slug>.mdx`:

```
content/changelog/
2026-10-23-v0-2-0.mdx
2026-09-02-v0-1-0.mdx
```

```mdx
---
title: Faster search
date: 2026-10-23
version: 0.2.0
---

### Added

- Results highlight the words you searched for.

### Fixed

- Links under a base path.
```

| Frontmatter | What |
| --- | --- |
| `title` | The entry's heading. |
| `date` | `YYYY-MM-DD`. Entries are sorted by it, newest first, and it's shown above the title. |
| `version` | Optional. Shown after the date as `v0.2.0`; a `v` you write isn't doubled. |
| `status` | Optional. `published` (the default); `draft` leaves the entry out; `scheduled` leaves it out of builds before its `date`. |
| `tags` | Optional. Read by the framework's loader, not shown. |

- The file name without its date is the entry's anchor:
`<base>/changelog#v0-2-0`. When two files share a name, the oldest entry
keeps it and newer ones get `-2`, `-3`.
- Group changes under `###` headings (Added, Changed, Fixed). Their ids are
scoped to the entry (`#v0-2-0-fixed`), so every release can use the same
headings.
- The body is MDX, with the kit's components and your own.
- The header gets a Changelog link, `llms.txt` links the page, `llms-full.txt`
includes every entry, and search finds entries under the Changelog filter.
- The folder and format are the same as the Next.js themes' `/changelog`.

## Astro's `base` and `trailingSlash`

Write links from the site root, as if the site were served from `/`, and the
Expand Down
14 changes: 13 additions & 1 deletion packages/astro/src/components/Shell.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
---
/**
* The three-zone docs shell: header (logo, tabs, search, theme toggle, CTA),
* sidebar, article, on-this-page rail. Same markup and `fw-shell*` classes as
* sidebar, article, on-this-page rail, and the "Built with Inkform" line
* under the article when the `credits` option is on. Same markup and `fw-shell*` classes as
* the framework's DocsShell, so @inkform/framework/styles.css and theme
* tokens style it unchanged. The mobile drawer, TOC highlight and theme
* toggle are wired up by ../client/shell.ts.
Expand Down Expand Up @@ -41,6 +42,8 @@ const hasSidebar = !!sidebar && sidebar.length > 0;
const hasToc = !!toc && toc.length > 0;
const showMenu = hasSidebar || links.length > 0 || !!cta;
const TYPE_LABEL = { doc: 'Docs', api: 'API', changelog: 'Changelog', blog: 'Blog' } as const;
// Where the `credits` link goes: the open-source repository this package lives in.
const INKFORM_URL = 'https://github.com/inkform-dev/framework';
---

<div class:list={['fw-shell', !hasSidebar && 'fw-shell--no-sidebar', !hasToc && 'fw-shell--no-toc']}>
Expand Down Expand Up @@ -115,6 +118,15 @@ const TYPE_LABEL = { doc: 'Docs', api: 'API', changelog: 'Changelog', blog: 'Blo
<main class="fw-shell-main" id="main-content" data-pagefind-body data-pagefind-filter={`type:${TYPE_LABEL[contentType]}`}>
<div class="fw-shell-content">
<slot />
{
config.credits ? (
<footer class="fw-credits" data-pagefind-ignore="all">
<a class="fw-credits-link" href={INKFORM_URL}>
Built with Inkform
</a>
</footer>
) : null
}
</div>
</main>

Expand Down
26 changes: 20 additions & 6 deletions packages/astro/src/components/views/ChangelogPage.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
---
/** Every changelog entry, newest first, each with an anchor (`#<slug>`). */
/**
* Every changelog entry, newest first, each with an anchor (`#<slug>`). The
* headings inside an entry get ids scoped to it (`#<slug>-added`), so two
* releases with an "Added" section don't share an id.
*/
import Layout from '../Layout.astro';
import PageHeader from '../PageHeader.astro';
import Prose from '../Prose.astro';
import { changelogAnchors, versionLabel } from '../../runtime/changelog';
import { scopeIds } from '../../runtime/headings';
import type { Site } from '../../runtime/site';

export interface Props {
Expand All @@ -11,10 +17,14 @@ export interface Props {

const { site } = Astro.props;
const renderer = site.renderer();
const anchors = changelogAnchors(site.changelog.map((e) => e.slug));
const entries = await Promise.all(
site.changelog.map(async (entry) => ({ entry, html: await renderer.mdx(entry.content) })),
site.changelog.map(async (entry, i) => {
const anchor = anchors[i] ?? entry.slug;
return { entry, anchor, version: versionLabel(entry.version), html: scopeIds(await renderer.mdx(entry.content), anchor) };
}),
);
const toc = site.changelog.map((e) => ({ depth: 2, text: e.title, slug: e.slug }));
const toc = entries.map(({ entry, anchor }) => ({ depth: 2, text: entry.title, slug: anchor }));
---

<Layout
Expand All @@ -29,10 +39,14 @@ const toc = site.changelog.map((e) => ({ depth: 2, text: e.title, slug: e.slug }
>
<PageHeader title="Changelog" />
{
entries.map(({ entry, html }) => (
entries.map(({ entry, anchor, version, html }) => (
<article class="fw-changelog-entry">
<p class="fw-page-meta">{[entry.date, entry.version ? `v${entry.version}` : null].filter(Boolean).join(' · ')}</p>
<h2 class="fw-changelog-title" id={entry.slug}>
<p class="fw-page-meta">
{entry.date ? <time datetime={entry.date}>{entry.date}</time> : null}
{entry.date && version ? ' · ' : null}
{version ? <span class="fw-changelog-version">{version}</span> : null}
</p>
<h2 class="fw-changelog-title" id={anchor}>
{entry.title}
</h2>
<Prose html={html} />
Expand Down
7 changes: 6 additions & 1 deletion packages/astro/src/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('normalizeBase', () => {
});

describe('resolveOptions', () => {
it('defaults to /docs, ./content, galley, search on, mermaid off', () => {
it('defaults to /docs, ./content, galley, search on, mermaid and credits off', () => {
const r = resolveOptions({}, host);
expect(r).toMatchObject({
base: '/docs',
Expand All @@ -36,6 +36,7 @@ describe('resolveOptions', () => {
search: true,
mermaid: false,
llmsTxt: true,
credits: false,
csp: false,
cspAlgorithm: 'SHA-256',
});
Expand Down Expand Up @@ -63,6 +64,10 @@ describe('resolveOptions', () => {
expect(resolveOptions({ components: './src/mdx-components.tsx' }, host).components).toBe(path.resolve('/site', 'src/mdx-components.tsx'));
});

it('shows the credit only when asked', () => {
expect(resolveOptions({ credits: true }, host).credits).toBe(true);
});

it('keeps built-in theme names', () => {
expect(resolveOptions({ theme: 'plain' }, host).theme).toBe('plain');
});
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export interface InkformOptions {
mermaid?: boolean;
/** Serve `<base>/llms.txt` and `<base>/llms-full.txt`. Default `true`. */
llmsTxt?: boolean;
/**
* Show a small "Built with Inkform" link, to the Inkform repository, at the
* foot of every docs page. Default `false`.
*/
credits?: boolean;
}

/** Facts about the host's Astro config that the options resolve against. */
Expand Down Expand Up @@ -91,6 +96,7 @@ export interface ResolvedOptions {
search: boolean;
mermaid: boolean;
llmsTxt: boolean;
credits: boolean;
trailingSlash: HostConfig['trailingSlash'];
csp: boolean;
cspAlgorithm: CspAlgorithm;
Expand Down Expand Up @@ -136,6 +142,7 @@ export function resolveOptions(options: InkformOptions, host: HostConfig): Resol
search: options.search ?? true,
mermaid: options.mermaid ?? false,
llmsTxt: options.llmsTxt ?? true,
credits: options.credits ?? false,
trailingSlash: host.trailingSlash,
csp: host.csp,
cspAlgorithm: host.cspAlgorithm ?? 'SHA-256',
Expand Down
57 changes: 57 additions & 0 deletions packages/astro/src/runtime/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { changelogAnchors, versionLabel } from './changelog';
import { headingsFromHtml, scopeIds } from './headings';
import { PageRenderer } from './render';

describe('changelogAnchors', () => {
it('uses each entry’s slug', () => {
expect(changelogAnchors(['v0-2-0', 'v0-1-0'])).toEqual(['v0-2-0', 'v0-1-0']);
});

it('keeps the oldest entry’s anchor when slugs repeat, so newer entries never take it', () => {
// Newest first, as the loader returns them.
expect(changelogAnchors(['update', 'release', 'update', 'update'])).toEqual(['update-3', 'release', 'update-2', 'update']);
expect(changelogAnchors(['update', 'update-2', 'update'])).toEqual(['update-3', 'update-2', 'update']);
expect(changelogAnchors([''])).toEqual(['entry']);
});
});

describe('versionLabel', () => {
it('prints one v in front of a version number', () => {
expect(versionLabel('1.2.0')).toBe('v1.2.0');
expect(versionLabel('v1.2.0')).toBe('v1.2.0');
expect(versionLabel('V2')).toBe('v2');
});

it('shows anything else as written, and nothing for no version', () => {
expect(versionLabel('Spring 2026')).toBe('Spring 2026');
expect(versionLabel(null)).toBeNull();
expect(versionLabel(' ')).toBeNull();
});
});

describe('scopeIds', () => {
const renderer = () => new PageRenderer({ mermaid: false, operation: null });
const entry = '### Added\n\n- A thing[^1]\n\n### Fixed\n\n- See [Added](#added)\n\n[^1]: A note.\n';

it('gives two entries’ headings and footnotes their own ids, and keeps links to them working', async () => {
const r = renderer();
const a = scopeIds(await r.mdx(entry), 'v0-2-0');
const b = scopeIds(await r.mdx(entry), 'v0-1-0');
// GFM's footnote section has its own (visually hidden) heading.
expect(headingsFromHtml(a).map((h) => h.slug)).toEqual(['v0-2-0-added', 'v0-2-0-fixed', 'v0-2-0-footnote-label']);
expect(headingsFromHtml(b).map((h) => h.slug)).toEqual(['v0-1-0-added', 'v0-1-0-fixed', 'v0-1-0-footnote-label']);
expect(a).toMatch(/aria-describedby="v0-2-0-footnote-label"/);
expect(a).toContain('href="#v0-2-0-added"');

const ids = (html: string) => [...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]);
const shared = ids(a).filter((id) => ids(b).includes(id));
expect(shared).toEqual([]);
// Every in-page link in an entry lands on an id in that entry.
for (const [, target] of a.matchAll(/href="#([^"]+)"/g)) expect(ids(a)).toContain(target);
});

it('returns HTML without headings or footnotes unchanged', () => {
expect(scopeIds('<p>Plain <a href="#elsewhere">link</a></p>', 'x')).toBe('<p>Plain <a href="#elsewhere">link</a></p>');
});
});
32 changes: 32 additions & 0 deletions packages/astro/src/runtime/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* What the changelog page needs beyond the framework's loader: an anchor per
* entry that stays unique and stable, and the version label. Pure, so it's
* unit-tested.
*/

/**
* One anchor per entry, from its slug (the file name without the date
* prefix). `entries` are newest first, as the loader returns them. When two
* entries share a slug (`2026-01-05-update.mdx`, `2026-02-09-update.mdx`),
* the oldest keeps the plain slug and later ones get `-2`, `-3`, so a link to
* an entry doesn't move to another one when a newer entry is added.
*/
export function changelogAnchors(slugs: readonly string[]): string[] {
const used = new Set<string>();
const out = new Array<string>(slugs.length);
for (let i = slugs.length - 1; i >= 0; i--) {
const slug = slugs[i] || 'entry';
let anchor = slug;
for (let n = 2; used.has(anchor); n++) anchor = `${slug}-${n}`;
used.add(anchor);
out[i] = anchor;
}
return out;
}

/** `1.2.0` and `v1.2.0` → `v1.2.0`; anything that isn't a version number is shown as written. */
export function versionLabel(version: string | null): string | null {
const v = version?.trim();
if (!v) return null;
return /^v?\d/i.test(v) ? `v${v.replace(/^v/i, '')}` : v;
}
Loading
Loading