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
31 changes: 21 additions & 10 deletions site/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { baseRawLinks } from "./src/lib/base-raw-links.js"
import { citationLinks } from "./src/lib/citation-links.js"
import { focusableScrollers } from "./src/lib/focusable-scrollers.js"
import mermaid from "./src/lib/mermaid.js"
import { relativeMdLinks } from "./src/lib/relative-md-links.js"
import { robotsPolicyFile } from "./src/lib/robots.js"
import { rootTwin } from "./src/lib/root-twin.js"

Expand Down Expand Up @@ -60,6 +61,7 @@ const here = fileURLToPath(new URL(".", import.meta.url))
const REPO_ROOT = resolve(here, "..")
const TREE_ROOT = resolve(REPO_ROOT, "docs")
const MANIFEST = "src/content/docs/.sync-manifest.json"
const CONTENT_ROOT = resolve(here, "src/content/docs")

/** Where `scripts/gen-reference.mjs` writes the two SDK references, as absolute directories. */
const SDK_REFERENCE_DIRS = ["python", "typescript"].map(
Expand Down Expand Up @@ -204,6 +206,12 @@ export default defineConfig({
processor: satteri({
mdastPlugins: [
agentNotePlugin(),
/*
* Relative `.md` links become page routes here, before `starlightBasePath()` (pushed after
* these) prefixes the base onto them. Emitted as written, `rpc-tools.md` on
* `/reference/public-api/` resolves one directory too deep and 404s.
*/
relativeMdLinks(CONTENT_ROOT),
citationLinks({
commit: manifest.commit,
repoUrl: REPO_URL,
Expand Down Expand Up @@ -550,20 +558,23 @@ export default defineConfig({
/*
* The raw `.md` twins come from an injected dynamic route, so they are absent from the page
* list this validator checks against and every link to one reports `InvalidLink` while
* resolving perfectly in the browser. The exclusion is only allowed because two stricter gates
* replace it: `tests/raw-route-links.test.ts` and `tests/agent-surface.test.ts` resolve every
* link against the files actually present in `dist/`, which needs no side table at all.
* resolving perfectly in the browser. Only root-relative twin links are excluded: a relative
* `.md` link is one `relativeMdLinks` missed, and it reaches the check below. The exclusion is
* only allowed because stricter gates
* replace it: `tests/raw-route-links.test.ts` resolves every link in the twins and
* `tests/built-links.test.ts` every link on every built surface, each
* against the files actually present in `dist/`, which needs no side table at all.
*/
exclude: ({ link }) => link.endsWith(".md"),
exclude: ({ link }) => link.startsWith("/") && link.endsWith(".md")

/*
* The generated tree cross-links with relative targets — `../insights/contract-map.md` and
* siblings, hundreds of them — and that is the correct form here rather than something to
* migrate. A relative target is the ONE link shape that resolves on both surfaces under a base
* segment: Astro rewrites it to a route in the rendered page, and on the raw twin it resolves
* against the twin's own directory, which already carries the base.
* `errorOnRelativeLinks` keeps its default, `true`. The tree cross-links with relative `.md`
* targets, which resolve on GitHub and on the raw twins, and `relativeMdLinks` above rewrites
* each one to its page route before this validator reads the tree, because in the rendered
* page a relative target resolves one directory too deep. A relative link reaching this
* validator is therefore one that rewrite missed, and it fails the build.
* `tests/built-links.test.ts` then resolves every link on every built surface against `dist/`.
*/
errorOnRelativeLinks: false
}),

// A return-to-top control on long pages; Platform alone runs past a thousand lines of source.
Expand Down
30 changes: 22 additions & 8 deletions site/src/components/Head.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@
* the `/microvms-agentd` segment and yields a 404 that only appears in production.
* `import.meta.env.BASE_URL` is the accessor that includes it.
*/
import { getCollection } from "astro:content"
import Default from "@astrojs/starlight/components/Head.astro"

import { APPLE_TOUCH_SIZE, ICO_SIZES, ICON_PNG_SIZE } from "../branding/mark.js"
import { OG_HEIGHT, OG_WIDTH, ogAlt, ogSlug } from "../branding/og-card.js"
import { OG_HEIGHT, OG_WIDTH, ogAlt, ogId, ogSlug } from "../branding/og-card.js"
import { discoveryLinks, siteUrl } from "../lib/agent-surface.js"
import { jsonLdGraph, jsonLdText, type SiteDescription } from "../lib/jsonld.js"

Expand Down Expand Up @@ -72,6 +73,13 @@ const asset = (file: string): string => siteUrl(file, context).pathname

/* `og:image` is the one URL that must be absolute: a crawler resolves it against nothing. */
const card = siteUrl(`og/${ogSlug(entry.id)}`, context)
/*
* The card route renders one card per `docs` collection entry, keyed by `ogId`, which maps the root
* page's two id spellings to one card; this reads the collection the same way.
* Starlight's built-in 404 page is a fallback entry outside that collection, so it has no card and
* names none.
*/
const hasCard = (await getCollection("docs", ({ id }) => ogId(id) === ogId(entry.id))).length > 0
const cardAlt = ogAlt(entry.data.title)

const icoSizes = ICO_SIZES.map((size) => `${size}x${size}`).join(" ")
Expand Down Expand Up @@ -119,13 +127,19 @@ const graph = jsonLdGraph(
Starlight emits `twitter:card: summary_large_image` and no image (read from its `utils/head.ts`), so
these are additions and not a second `og:image` beside a default one.
*/}
<meta property="og:image" content={card.href} />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content={String(OG_WIDTH)} />
<meta property="og:image:height" content={String(OG_HEIGHT)} />
<meta property="og:image:alt" content={cardAlt} />
<meta name="twitter:image" content={card.href} />
<meta name="twitter:image:alt" content={cardAlt} />
{
hasCard && (
<>
<meta property="og:image" content={card.href} />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content={String(OG_WIDTH)} />
<meta property="og:image:height" content={String(OG_HEIGHT)} />
<meta property="og:image:alt" content={cardAlt} />
<meta name="twitter:image" content={card.href} />
<meta name="twitter:image:alt" content={cardAlt} />
</>
)
}

{
links.map((link) => (
Expand Down
9 changes: 5 additions & 4 deletions site/src/lib/jsonld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* for the site, joined in a single `@graph`.
*
* This module is pure and takes the site's origin and base segment as arguments rather than reading
* `import.meta.env`, so every URL and every node it emits is asserted in a unit test rather than only
* in a validator run against a deployed page.
* `import.meta.env`, so every URL it emits can be checked without a deployed page:
* `tests/built-links.test.ts` resolves every `url` and `@id` in the built graph against `dist/`.
*
* Every URL is built through `siteUrl`, which resolves with `new URL()`. A base segment joined to a
* path by string concatenation produces `//microvms-agentd/index.md` — a protocol-relative URL naming
Expand All @@ -26,7 +26,7 @@
* present one as asserted.
*/

import { type SiteContext, siteUrl } from "./agent-surface.js"
import { rawMarkdownUrl, type SiteContext, siteUrl } from "./agent-surface.js"

/**
* The site node, described once.
Expand Down Expand Up @@ -89,7 +89,8 @@ const pageUrl = (entryId: string, context: SiteContext): URL =>
* neither makes the other redundant, because they are read by different consumers.
*/
const markdownEncoding = (entryId: string, context: SiteContext) => {
const href = siteUrl(`${entryId === "index" ? "" : entryId}.md`, context).href
// The root twin is `index.md`: `rawMarkdownUrl` owns that mapping, as it does for the head link.
const href = rawMarkdownUrl(entryId, context).href
return {
"@type": "MediaObject" as const,
"@id": href,
Expand Down
111 changes: 111 additions & 0 deletions site/src/lib/relative-md-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: Apache-2.0
import { existsSync } from "node:fs"
import { posix, relative, sep } from "node:path"
import { fileURLToPath } from "node:url"

import { defineMdastPlugin, type MdastPluginDefinition } from "satteri"

/**
* Rewrites a relative link to another page's Markdown source into the route that page is served at.
*
* The tree cross-links with relative `.md` targets such as `rpc-tools.md` and
* `../internals/insights/contract-map.md`. That form resolves on GitHub and on the raw `.md` twins,
* because there each target sits beside its source. The rendered page sits one directory deeper than
* its source: `reference/public-api.md` is served at `/reference/public-api/`. Sätteri emits a link
* target as written, so the browser resolved `rpc-tools.md` on that page to
* `/reference/public-api/rpc-tools.md`, a 404. Every relative `.md` link in the rendered tree broke
* this way, 148 of them on 29 pages, and the llms bundles inherited each one, because
* `starlight-llms-txt` flattens the rendered page back to Markdown.
*
* This resolves the target against the source document's path under the content root and emits the
* target's route root-relative, `/reference/rpc-tools/`, with any fragment kept. `starlightBasePath()`
* then prefixes the base onto it, exactly as it does for an authored `/agents/`; its Sätteri pass is
* pushed after the plugins configured in `astro.config.ts`, and Sätteri applies each plugin's edits
* before the next one runs, so it reads the rewritten URL.
*
* The raw twins never pass through this processor and keep the relative form, which resolves there
* for every page except an `index.md`: its twin is served one level up (`reference/index.md` at
* `reference.md`), so a relative link written in an index page resolves one level too high on the
* twin. The index pages link root-relative, and `tests/built-links.test.ts` fails on the first
* relative link that breaks a twin.
*
* A relative `.md` target that names no file under the content root fails the build with the document
* and line. `scripts/sync-docs.mjs` already turns a link that leaves the published corpus into a
* permalink, so a target missing here is a defect, and emitting it would ship a 404.
*
* The route is the content path lowercased, which is the route Astro gives a file whose path
* segments use only `[a-z0-9_-]`: the glob loader runs each segment through github-slugger, which
* leaves those characters alone and drops or replaces most others (`v1.2-notes` becomes `v12-notes`).
* Every page in the tree meets that rule, so a target outside it fails the build here rather than
* through a guessed route. A frontmatter `slug:` override is not read; the link validator fails the
* build on the route it would leave unserved.
*
* Only `link` nodes are rewritten. A reference definition (`[x]: page.md`) would need the base added
* here as well, because `starlightBasePath()` rewrites `link` nodes only; the tree uses none, and
* `tests/built-links.test.ts` fails on the first one that ships unresolved.
*/

type LinkVisitor = NonNullable<MdastPluginDefinition["link"]>
type LinkNode = Parameters<LinkVisitor>[0]

/** A target this plugin owns: no scheme, not root-relative, not an in-page anchor, ending `.md`. */
const RELATIVE_MD = /^(?![a-z][a-z0-9+.-]*:)(?![/#])(?<path>[^?#]*\.md)(?<suffix>[?#].*)?$/i

/** A path segment whose github-slugger slug is itself once lowercased. */
const SLUG_SAFE = /^[a-z0-9][a-z0-9_-]*$/

/** A content path as the route it is served at: `reference/cli.md` is `/reference/cli/`. */
const routeOfContentPath = (contentPath: string): string =>
`/${contentPath.slice(0, -".md".length).toLowerCase()}/`.replace(/(^|\/)index\/$/, "$1")

/**
* @param contentRoot the absolute path of Starlight's content directory, `src/content/docs`. A
* document outside it is left alone: its route is not derived from its path.
*/
export const relativeMdLinks = (contentRoot: string): MdastPluginDefinition => {
const root = contentRoot.endsWith(sep) ? contentRoot : `${contentRoot}${sep}`

return defineMdastPlugin({
name: "docs-relative-md-links",
// Positions are read for the error message, which names the line the link sits on.
options: { position: true },

link(node: LinkNode, ctx) {
const groups = RELATIVE_MD.exec(node.url)?.groups
if (groups?.path === undefined || ctx.fileURL === undefined) return
const document = fileURLToPath(ctx.fileURL)
if (!document.startsWith(root)) return

const line = node.position?.start.line
const refuse = (why: string): never => {
throw new Error(
`docs-relative-md-links: ${line === undefined ? document : `${document}:${line}`}: ` +
`\`${node.url}\` ${why}`
)
}
let path = groups.path
try {
path = decodeURI(groups.path)
} catch {
refuse("is not valid percent-encoding.")
}
const from = posix.dirname(relative(root, document).split(sep).join("/"))
const target = posix.normalize(posix.join(from, path))
if (target.startsWith("../") || !existsSync(`${root}${target}`)) {
refuse(
`resolves to \`${target}\`, which is not a page under ${root}. Link to a published ` +
"page, or let `scripts/sync-docs.mjs` turn the link into a permalink."
)
}
const segments = target.slice(0, -".md".length).toLowerCase().split("/")
if (!segments.every((segment) => SLUG_SAFE.test(segment))) {
refuse(
`resolves to \`${target}\`, whose route this rewrite cannot derive: Astro slugs a path ` +
"segment with github-slugger, which changes characters outside `[a-z0-9_-]`. Rename " +
"the page, or link to its route."
)
}
ctx.setProperty(node, "url", `${routeOfContentPath(target)}${groups.suffix ?? ""}`)
}
})
}
Loading
Loading