Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
20c51f3
feat(reference) Move each port's reference under its own version
tony Sep 13, 2026
ef11515
feat(reference) Point prose links and inventories at the new trees
tony Sep 13, 2026
4653ee2
feat(reference) Give each product its own reference section
tony Sep 13, 2026
44a9e24
test(site) Point every check at the reference where it lives now
tony Sep 13, 2026
67e3178
fix(reference) Keep a core page from linking a product symbol home
tony Sep 13, 2026
6e79c80
chore(site) Regenerate the mention index for the moved references
tony Sep 13, 2026
0e8b78e
docs(site) Name the reference where it lives now
tony Sep 13, 2026
e664e5f
fix(reference) Root a prose reference link once
tony Sep 13, 2026
d161856
fix(reference) Give every prose linker the same reference builder
tony Sep 13, 2026
f30b97e
fix(reference) Canonicalise a reference page to its own version
tony Sep 13, 2026
efe6db0
fix(scripts) Read the built versions after the site dir is known
tony Sep 13, 2026
9b55cd9
fix(docs) Link a reference from prose without naming a version
tony Sep 13, 2026
40448bd
test(site) Follow the product sections and their structured data
tony Sep 13, 2026
4849538
test(site) Clear the last old paths from checks and fixtures
tony Sep 13, 2026
570d479
fix(site) Advertise the reference where an agent can fetch it
tony Sep 13, 2026
749689f
test(site) Expect the breadcrumb and index of a product reference
tony Sep 13, 2026
da8b0aa
test(site) Measure cross-references across all three trees
tony Sep 13, 2026
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
2 changes: 1 addition & 1 deletion infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ them and trusts the KeyValueStore lookup to miss for anything that is not one.
| `/en/py/latest` | 3 | 301 to `/en/py/latest/`, never the KVS default — `parts[3]` is truthy |
| `/en/py/v0.46.2` | 3 (`2` is not an asset extension) | 301 to `/en/py/v0.46.2/` |
| `/en/dotnet/stable/api/libtmux.client` | 3 (`client` is not an asset extension) | 301 with a trailing slash |
| `/en/reference/py/objects.inv` | none (`inv` is an asset extension) | passes through — this is how an external Sphinx project resolves intersphinx into this site |
| `/en/py/stable/reference/objects.inv` | none (`inv` is an asset extension) | passes through — this is how an external Sphinx project resolves intersphinx into this site |
| `/en/py/stable/api/.buildinfo` | none (`buildinfo` is an asset extension) | passes through; a leading dot is a separator like any other |
| `/en/pagefind/pagefind.js` | none (`js` is an asset extension) | passes through |
| `/en/versions.json` | none (dot excludes rule 1, `json` is an asset extension) | passes through |
Expand Down
20 changes: 12 additions & 8 deletions notes/gp-sphinx-parity.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Where the reference differs from gp-sphinx, and why

The eight-port reference at `/reference/` is meant to be visually
The reference each port publishes at `/<slug>/<version>/reference/` is meant
to be visually
indistinguishable from gp-sphinx's rendering. It is not identical *code*, and
this is the list of every place it deliberately diverges. An unrecorded
difference is a defect; this file is what makes that statement checkable.
Expand All @@ -25,17 +26,20 @@ That choice is why entries are `dl.py` / `dt.sig` / `dd`. The brief left the
structure open, and this is the structure those rules match — also the one
docutils chose, because a reference entry *is* a definition list.

## One reference per port
## One reference per package, under the version it documents

Every port's `/<slug>/<version>/api/` redirects to `/reference/<slug>/`, and
Python is the only exception.
Every port's `/<slug>/<version>/api/` redirects to
`/<slug>/<version>/reference/`, and Python is the only exception. The
Workspace Manager and the MCP server are separately versioned packages and
answer beside it, at `/<slug>/<version>/workspace/reference/` and
`/<slug>/<version>/mcp/reference/`.

Five ports used to answer "the API" twice, in three different visual systems:
Sphinx+Breathe for C++, DocC for Swift, staged Markdown for TypeScript and
.NET, and this site's own components at `/reference/`. A reader arriving at
`/cxx/stable/api/` met a page with no cards, no badges, no source links and no
prose at all, while `/reference/cxx/` had all four. Whatever else parity means,
it cannot mean two answers.
.NET, and this site's own components. A reader arriving at `/cxx/stable/api/`
met a page with no cards, no badges, no source links and no prose at all,
while this site's reference had all four. Whatever else parity means, it
cannot mean two answers.

Python keeps its generated tree because `/py/stable/api/` is not a duplicate:
it is gp-sphinx rendering upstream's own documentation, which is a different
Expand Down
19 changes: 17 additions & 2 deletions packages/api-model/src/prose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function pageSlug(id: string): string {
* a member-less type and a free function all resolve the same way, where
* before each was a separate branch and two of them were wrong often enough
* to produce 114 links to pages that were never generated.
*
* Unversioned, and so not what the site renders: a reference page lives under
* its port's version and its package's section, which this package cannot
* know. `MentionContext.symbolHref` is how the site supplies the real one.
*/
export function hrefFor(port: string, _model: ApiModel, symbol: ApiSymbol): string {
return `/reference/${port}/${symbol.slug ?? pageSlug(symbol.publicId ?? symbol.id)}/`
Expand Down Expand Up @@ -187,6 +191,16 @@ export interface MentionContext {
pagePort?: string
product?: ApiProduct
before?: string
/**
* Where this site puts a symbol and a module index.
*
* A reference URL carries a port, a version and a product, and only the
* site knows which version a port is publishing. Tooling that asks whether
* a span resolves at all — `check-api-links` — leaves these unset and takes
* the unversioned default below.
*/
symbolHref?: (port: string, symbol: ApiSymbol) => string
moduleHref?: (port: string, module: string) => string
}

/**
Expand All @@ -209,7 +223,8 @@ export function decideMention(
return { kind: 'link', port, href: res.href, title: `${text}: ${res.project}`, external: true }
}
if (res.how === 'module-index') {
return { kind: 'link', port, href: `/reference/${port}/#${res.module}`, title: `${res.module}: module`, external: false }
const href = ctx.moduleHref?.(port, res.module) ?? `/reference/${port}/#${res.module}`
return { kind: 'link', port, href, title: `${res.module}: module`, external: false }
}
// Every outcome that carries a symbol, not just the two most common.
// `module` and `chained` resolve to a real symbol too — dropping them
Expand All @@ -220,7 +235,7 @@ export function decideMention(
return {
kind: 'link',
port,
href: hrefFor(port, model, res.symbol),
href: ctx.symbolHref?.(port, res.symbol) ?? hrefFor(port, model, res.symbol),
title: `${res.symbol.publicId ?? res.symbol.id}: ${PORT_NAME[port] ?? port}`,
external: false,
}
Expand Down
13 changes: 10 additions & 3 deletions scripts/add-intersphinx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { referenceDirs } from './reference-trees.mjs'

const [confDir, siteDir, baseUrl, skipPort] = process.argv.slice(2)
if (!confDir || !siteDir || !baseUrl) {
Expand All @@ -43,9 +44,15 @@ const PORTS = PORT_DEFS.map((p) => p.slug)
const entries = []
for (const port of PORTS) {
if (port === skipPort) continue
const inv = join(siteDir, 'reference', port, 'objects.inv')
if (!existsSync(inv)) continue
entries.push([`libtmux-${port}`, `${baseUrl.replace(/\/*$/, '')}/reference/${port}/`, inv])
// A port's inventory sits beside the reference it describes, under the
// version that published it. The root build's copy at `reference/<port>/`
// stays for consumers configured before the move.
for (const dir of referenceDirs(siteDir, port)) {
const inv = join(dir, 'objects.inv')
if (!existsSync(inv)) continue
entries.push([`libtmux-${port}`, `${baseUrl.replace(/\/*$/, '')}/${dir.slice(siteDir.length).replace(/^\/+/, '')}/`, inv])
break
}
}

if (!entries.length) {
Expand Down
20 changes: 12 additions & 8 deletions scripts/build-site.sh
Original file line number Diff line number Diff line change
Expand Up @@ -538,17 +538,17 @@ render_staged_reference() {
# The status line is cached alongside the tree. It is what the summary prints,
# and a hit that reported "built" for a generator that had been skipped would
# be a lie that survives until someone reads the site.
# write_reference_redirect SLUG DEST
# write_reference_redirect SLUG VERSION DEST
#
# A static site has no server to answer 301 with, so the redirect is a page:
# a meta refresh for the browser, a canonical link so a crawler follows the
# same edge, and a visible link for anyone whose browser refuses the refresh.
# `noindex` keeps the placeholder out of search results while the canonical
# still points at the real page.
write_reference_redirect() {
local slug="$1" dest="$2"
local slug="$1" version="$2" dest="$3"
# Through the site root, like every other link the assembly emits.
local path="${LIBTMUX_DOCS_ROOT%/}/reference/$slug/"
local path="${LIBTMUX_DOCS_ROOT%/}/$slug/$version/reference/"
local target="${site_origin%/}$path"
cat >"$dest" <<HTML
<!doctype html>
Expand Down Expand Up @@ -984,7 +984,8 @@ while IFS='|' read -r slug name versioned renderer generator checkout ecosystem_
continue
fi

# One reference per port, at /reference/<slug>/.
# One reference per port and version, at /<slug>/<version>/reference/,
# rendered by that port's own shell build above.
#
# Five ports used to answer "the API" twice, in three different visual
# systems: Breathe for C++, DocC for Swift, staged Markdown for TypeScript
Expand All @@ -1001,8 +1002,8 @@ while IFS='|' read -r slug name versioned renderer generator checkout ecosystem_
# port's own pipeline uploads.
if [ "$own_api" != "own-api" ]; then
mkdir -p "$port_out/api"
write_reference_redirect "$slug" "$port_out/api/index.html"
summary_rows+=("$slug|$version|redirect|redirected|to $LIBTMUX_DOCS_PORT_ROOT/reference/$slug/")
write_reference_redirect "$slug" "$version" "$port_out/api/index.html"
summary_rows+=("$slug|$version|redirect|redirected|to $LIBTMUX_DOCS_PORT_ROOT/$slug/$version/reference/")
continue
fi

Expand All @@ -1017,7 +1018,7 @@ while IFS='|' read -r slug name versioned renderer generator checkout ecosystem_
node "$script_dir/normalize-native-shell.mjs" "$port_out/api" "$LIBTMUX_DOCS_PORT_ROOT"
elif [ "$ref_status" = "skipped" ]; then
mkdir -p "$port_out/api"
write_reference_redirect "$slug" "$port_out/api/index.html"
write_reference_redirect "$slug" "$version" "$port_out/api/index.html"
fi

summary_rows+=("$slug|$version|$renderer|$ref_status|[$generator] $ref_reason")
Expand Down Expand Up @@ -1141,7 +1142,10 @@ while IFS='|' read -r slug _name _versioned renderer _rest; do
*) continue ;;
esac
for version in "${versions[@]}"; do
vendored_args+=(--vendored "${LIBTMUX_DOCS_LOCALES_ROOT#/}/$locale/$slug/$version")
# The native tree alone, not the whole port: this repo's own reference
# now lives under `$slug/$version/reference/`, and excusing the port
# would excuse the one class of link worth failing on.
vendored_args+=(--vendored "${LIBTMUX_DOCS_LOCALES_ROOT#/}/$locale/$slug/$version/api")
done
done < <(list_ports)

Expand Down
4 changes: 3 additions & 1 deletion scripts/check-api-fidelity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { readFileSync, globSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { sourceUrl } from '../packages/api-model/src/products.ts'
import { referenceDirs } from './reference-trees.mjs'

const root = process.argv[2]
// `root` above is the assembled site this run measures; the port list comes
Expand Down Expand Up @@ -88,7 +89,8 @@ for (const port of PORTS) {
const symbols = new Map(model.symbols.flatMap((symbol) => [
[symbol.id, symbol], [symbol.publicId ?? symbol.id, symbol],
]))
const pages = globSync(`reference/${port}/**/index.html`, { cwd: root })
const pages = referenceDirs(root, port, { products: true })
.flatMap((dir) => globSync('**/index.html', { cwd: dir }).map((page) => join(dir.slice(root.length + 1), page)))
if (!pages.length) {
failures.push(`${port}: no reference pages built`)
continue
Expand Down
45 changes: 33 additions & 12 deletions scripts/check-canonicals.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
#!/usr/bin/env node
/*
* Every reference page's canonical URL is its own URL.
* Every reference page canonicalises to itself under the port's default
* version.
*
* The reference tree carries no version and no locale, so unlike the port
* prose there is no legitimate reason for one of its pages to canonicalise
* anywhere but itself. That makes the invariant exact, and exactness is what
* this tree needed: `pagePath` was composed from the symbol rather than from
* the route, so every page in all eight ports declared a canonical without
* the port segment — a URL that does not exist, and the same one for any two
* ports sharing a symbol slug.
* A reference lives under the version it documents, so its canonical follows
* the rule the rest of a port's pages follow: the default version is the one
* URL, and another version of the same page points at it. What is never
* legitimate is pointing outside the port, or at a version that was not
* built — `pagePath` was once composed from the symbol rather than from the
* route, so every page in all eight ports declared a canonical without the
* port segment, the same URL for any two ports sharing a symbol slug.
*
* Nothing caught it. The pages are noindex today, so no ranking moved; the
* links in them all resolve, so check-links passed; and no test asserts a
Expand All @@ -21,6 +22,11 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { referenceDirs } from './reference-trees.mjs'

const { PORTS: PORT_DEFS } = await import(`file://${join(dirname(fileURLToPath(import.meta.url)), '../site/src/lib/ports.ts')}`)
const PORTS = PORT_DEFS.map((p) => p.slug)


const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const defaultSite = join(repoRoot, '_site')
Expand Down Expand Up @@ -49,8 +55,18 @@ if (siteDir === defaultSite && existsSync(lock)) {
}
}

const root = join(siteDir, 'reference')
if (!existsSync(root)) {
/**
* Each port's default version, read from the tree that was built: a port
* publishing one prefix is its own default, and Python's two make `stable`
* the canonical one.
*/
const DEFAULTS = Object.fromEntries(PORTS.map((port) => {
const built = referenceDirs(siteDir, port).map((dir) => dir.split('/').at(-2))
return [port, built.includes('stable') ? 'stable' : built[0]]
}).filter(([, version]) => version))

const roots = PORTS.flatMap((port) => referenceDirs(siteDir, port, { products: true }))
if (!roots.length) {
console.error(`check-canonicals: no reference tree under ${siteDir}`)
process.exit(1)
}
Expand All @@ -63,7 +79,7 @@ const walk = (dir) => {
else if (entry === 'index.html') pages.push(full)
}
}
walk(root)
for (const root of roots) walk(root)

const CANONICAL = /<link\s+rel="canonical"\s+href="([^"]+)"/i
const wrong = []
Expand All @@ -83,8 +99,13 @@ for (const file of pages) {
const declared = new URL(found[1]).pathname.replace(new RegExp(`^/${LOCALE}/`), '/')
// The page's own path, as served: the directory holding its index.html.
const own = `${file.slice(siteDir.length, -'index.html'.length)}`
// Its canonical twin: the same page under this port's default version.
const [, port, version] = own.split('/')
const want = DEFAULTS[port] && DEFAULTS[port] !== version
? own.replace(`/${port}/${version}/`, `/${port}/${DEFAULTS[port]}/`)
: own
checked += 1
if (declared !== own) wrong.push({ file, want: own, got: declared })
if (declared !== want) wrong.push({ file, want, got: declared })
}

if (wrong.length) {
Expand Down
14 changes: 7 additions & 7 deletions scripts/check-canonicals.negative.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const ORIGIN = 'https://libtmux.org'
/** A reference tree whose canonical for each page is `canonicalFor(path)`. */
function site(canonicalFor) {
const dir = mkdtempSync(join(tmpdir(), 'check-canonicals-'))
const pages = ['reference', 'reference/py', 'reference/ts', 'reference/py/pane', 'reference/ts/pane']
const pages = ['reference', 'py/latest/reference', 'ts/latest/reference', 'py/latest/reference/pane', 'ts/latest/reference/pane']
for (const page of pages) {
mkdirSync(join(dir, page), { recursive: true })
const href = `${ORIGIN}${canonicalFor(`/${page}/`)}`
Expand Down Expand Up @@ -55,10 +55,10 @@ const check = (name, ok, detail) => {
}

{
// The real defect: the port segment dropped, so /reference/py/pane/ claims
// /reference/pane/ — which does not exist, and which /reference/ts/pane/
// claims too.
const dir = site((p) => p.replace(/^\/reference\/(py|ts)\//, '/reference/'))
// The real defect: the version segment dropped, so /py/latest/reference/pane/
// claims /py/reference/pane/ — which does not exist, and which every other
// version of that page would claim too.
const dir = site((p) => p.replace(/^\/(py|ts)\/latest\//, '/$1/'))
const { code, out } = run(dir)
check(
'a dropped port segment fails',
Expand All @@ -77,8 +77,8 @@ const check = (name, ok, detail) => {

{
const dir = mkdtempSync(join(tmpdir(), 'check-canonicals-bare-'))
mkdirSync(join(dir, 'reference'), { recursive: true })
writeFileSync(join(dir, 'reference', 'index.html'), '<html><head></head></html>')
mkdirSync(join(dir, 'py', 'latest', 'reference'), { recursive: true })
writeFileSync(join(dir, 'py', 'latest', 'reference', 'index.html'), '<html><head></head></html>')
const { code, out } = run(dir)
check('a page with no canonical at all fails', code !== 0 && out.includes('none'), `exited ${code}:\n${out}`)
rmSync(dir, { recursive: true, force: true })
Expand Down
7 changes: 4 additions & 3 deletions scripts/check-sidebar-refs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,10 @@ for (const port of PORTS) {
* prefixed build, and asserting the prefixed form would fail on an
* unprefixed one.
*/
const ours = sidebar.findIndex((l) => pathOf(l.href) === `/reference/${port}/`)
if (ours === -1) failures.push(`${port}: sidebar does not link /reference/${port}/`)
else if (ours !== 0) failures.push(`${port}: /reference/${port}/ is entry ${ours}, not first`)
const REFERENCE = new RegExp(`/${port}/[^/]+/reference/$`)
const ours = sidebar.findIndex((l) => REFERENCE.test(pathOf(l.href)))
if (ours === -1) failures.push(`${port}: sidebar does not link /${port}/<version>/reference/`)
else if (ours !== 0) failures.push(`${port}: /${port}/<version>/reference/ is entry ${ours}, not first`)

const host = ECOSYSTEM[port]
if (host) {
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-sidebar-refs.negative.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ fails=0
rs=$(page_for rs) || { echo 'no rs shell page under _site — run ./scripts/build-site.sh' >&2; exit 1; }
py=$(page_for py) || { echo 'no py shell page under _site — run ./scripts/build-site.sh' >&2; exit 1; }

drop 'our reference removed' "$rs" '/reference/rs/' 'rs: sidebar does not link /reference/rs/' || fails=1
drop 'our reference removed' "$rs" '/rs/latest/reference/' 'rs: sidebar does not link /rs/<version>/reference/' || fails=1
drop 'ecosystem link removed' "$rs" 'docs.rs' 'rs: sidebar does not link docs.rs' || fails=1
drop 'upstream reference gone' "$py" '/api/' 'py: sidebar does not link the upstream gp-sphinx reference' || fails=1

Expand Down
3 changes: 2 additions & 1 deletion scripts/check-type-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { referenceDirs } from './reference-trees.mjs'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
/* `--ceiling` points at a different record so the negative test can drive this
Expand Down Expand Up @@ -96,7 +97,7 @@ function measure(port) {
let resolved = 0
const unresolved = new Map()
const mangled = []
for (const file of htmlFiles(join(site, 'reference', port))) {
for (const file of referenceDirs(site, port, { products: true }).flatMap((dir) => [...htmlFiles(dir)])) {
const html = readFileSync(file, 'utf8')
resolved += (html.match(TYPE_LINK) ?? []).length
for (const block of typeBlocks(html)) {
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-type-links.negative.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const usr = () =>
function site({ n = 0, mangled = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'check-type-links-'))
for (const p of PORTS) {
const d = join(dir, 'reference', p, 'thing')
const d = join(dir, p, 'latest', 'reference', 'thing')
mkdirSync(d, { recursive: true })
const body =
linked('Server') +
Expand Down
3 changes: 2 additions & 1 deletion scripts/check-xrefs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
import { referenceDirs } from './reference-trees.mjs'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
/* `--floor` points at a different record so `check-xrefs.negative.mjs` can
Expand Down Expand Up @@ -52,7 +53,7 @@ function countIn(dir) {
}

const counts = Object.fromEntries(
PORTS.map((p) => [p, countIn(join(site, 'reference', p))]),
PORTS.map((p) => [p, referenceDirs(site, p, { products: true }).reduce((n, dir) => n + countIn(dir), 0)]),
)

if (args.includes('--update')) {
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-xrefs.negative.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const PORTS = PORT_DEFS.map((p) => p.slug)
function site(n) {
const dir = mkdtempSync(join(tmpdir(), 'check-xrefs-'))
for (const p of PORTS) {
const d = join(dir, 'reference', p, 'thing')
const d = join(dir, p, 'latest', 'reference', 'thing')
mkdirSync(d, { recursive: true })
const anchors = Array.from({ length: n }, () => '<a class="api-xref" href="/x/">x</a>').join('')
writeFileSync(join(d, 'index.html'), `<html><body>${anchors}</body></html>`)
Expand Down
Loading