From 20c51f3097866d7442af2c6add8d9bda50e46fda Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 15:44:20 -0500
Subject: [PATCH 01/17] feat(reference) Move each port's reference under its
own version
why: /reference/go/ put the Go API outside the Go documentation, above
the version it documents, and mixed three separately versioned packages
-- the core library, the Workspace Manager and the MCP server -- into
one tree. A reference documents one release of one package.
what:
- referenceUrl(port, version, product) and referenceSegment() build
every reference path, and productApiPath spells the product trees the
same way: mcp/reference and workspace/reference
- The reference route renders inside each port's shell, where Astro's
base is already ///, so its paths carry no port segment
and the core tree holds core symbols alone
- The Markdown twins and tree.json follow it; the root build keeps the
cross-port hub at /reference/ and the A-Z symbol index
- referenceHref resolves a cross-port link through the target port's
default version, which defaultVersionFor reads from the assembly's
LIBTMUX_DOCS_PORT_DEFAULTS
---
site/src/components/api/ApiTree.astro | 2 +-
.../components/widgets/PackageInstall.astro | 3 +-
site/src/lib/api-models.ts | 15 ++--
site/src/lib/page-port-links.ts | 2 +-
site/src/lib/ports.ts | 30 +++++--
site/src/lib/product-api.ts | 4 +-
site/src/lib/prompts.ts | 2 +-
site/src/lib/sidebar.ts | 9 +-
site/src/lib/versions.ts | 22 +++++
site/src/pages/index.astro | 4 +-
site/src/pages/page-links.json.ts | 3 +-
site/src/pages/reference/[...slug].astro | 83 +++++++++++--------
site/src/pages/reference/[...slug].md.ts | 35 ++++----
.../pages/reference/{[port] => }/tree.json.ts | 17 ++--
14 files changed, 151 insertions(+), 80 deletions(-)
rename site/src/pages/reference/{[port] => }/tree.json.ts (71%)
diff --git a/site/src/components/api/ApiTree.astro b/site/src/components/api/ApiTree.astro
index af631e10..5bf7cf9c 100644
--- a/site/src/components/api/ApiTree.astro
+++ b/site/src/components/api/ApiTree.astro
@@ -47,7 +47,7 @@ const {
port, portName, tree, currentRoot, currentBucket, currentTypeId, ownerNavId, ownerId,
members, withMembers, menuVersion, currentPath,
} = Astro.props as Props
-const base = withRoot(`/reference/${port}/`)
+const base = withRoot('/reference/')
const href = (slug: string) => `${base}${slug}/`
let seq = 0
diff --git a/site/src/components/widgets/PackageInstall.astro b/site/src/components/widgets/PackageInstall.astro
index e711a7c5..49bbdc1e 100644
--- a/site/src/components/widgets/PackageInstall.astro
+++ b/site/src/components/widgets/PackageInstall.astro
@@ -4,6 +4,7 @@ import { highlightInline } from '../../lib/highlight'
import { PORTS, referenceUrl } from '../../lib/ports'
import type { Quickstart } from '../../lib/quickstarts'
import { withPortRoot } from '../../lib/site-root'
+import { defaultVersionFor } from '../../lib/versions'
/**
* Package-manager picker for the eight libtmux ports' own install commands
@@ -176,7 +177,7 @@ const installHtml = new Map(
{snippets && (
)}
{snippets?.[p.slug] && (
diff --git a/site/src/lib/api-models.ts b/site/src/lib/api-models.ts
index 607de53f..d7b5363f 100644
--- a/site/src/lib/api-models.ts
+++ b/site/src/lib/api-models.ts
@@ -5,6 +5,8 @@ import jdkInv from '../data/inventories/jdk.entries.json'
import pythonInv from '../data/inventories/python.entries.json'
import dependencyInv from '../data/inventories/dependencies.entries.json'
import { withPortRoot } from './site-root'
+import { PORT_BY_SLUG, referenceUrl, type DocProduct } from './ports'
+import { defaultVersionFor } from './versions'
import cxxNav from '../data/api/cxx.nav.json'
import dotnetNav from '../data/api/dotnet.nav.json'
import goNav from '../data/api/go.nav.json'
@@ -297,17 +299,20 @@ export function indexFor(model: ApiModel, hrefFor: (s: ApiSymbol) => string): Sy
* Returns undefined when the port or the symbol is unknown, so a stale entry
* renders as text instead of a link to nothing.
*/
-export function referenceHref(port: string, publicId: string): string | undefined {
+export function referenceHref(port: string, publicId: string, version?: string): string | undefined {
const model = API_MODELS[port]
if (!model) return undefined
// Existence is still checked: a stale entry should render as text rather
// than link to a page that was never generated.
const symbol = model.symbols.find((s) => (s.publicId ?? s.id) === publicId)
if (!symbol) return undefined
- // withPortRoot: this is called from ApiEntry, which renders inside shared
- // prose, so it runs in every locale's build — while the reference itself is
- // generated only in the default locale's tree.
- return withPortRoot(`/reference/${port}/${symbol.slug ?? pageSlug(publicId)}/`)
+ const target = PORT_BY_SLUG[port]
+ if (!target) return undefined
+ // The version is the target port's own default unless a caller is rendering
+ // that port and knows better: this is called from ApiEntry, which renders
+ // inside shared prose and so runs in builds of every port and locale.
+ const product = (symbol.product ?? 'core') === 'core' || symbol.apiScope === 'internal' ? 'core' : symbol.product
+ return `${referenceUrl(target, version ?? defaultVersionFor(port), product as DocProduct | 'core')}${symbol.slug ?? pageSlug(publicId)}/`
}
/** Source-verified equivalents, shared by reference entries and page navigation. */
diff --git a/site/src/lib/page-port-links.ts b/site/src/lib/page-port-links.ts
index 51ddc858..cc2d5473 100644
--- a/site/src/lib/page-port-links.ts
+++ b/site/src/lib/page-port-links.ts
@@ -57,7 +57,7 @@ export function pagePortLinks({
if (!path) {
links = [{ href: portHomeUrl(port, targetVersion) }]
} else if ((isReference && !symbolSlug) || path === 'api') {
- if (API_MODELS[port.slug]) links = [{ href: referenceUrl(port) }]
+ if (API_MODELS[port.slug]) links = [{ href: referenceUrl(port, targetVersion) }]
} else if (symbol) {
for (const alternative of alternatives) {
const match = alternative.ports.find((p) => p.port === port.slug)
diff --git a/site/src/lib/ports.ts b/site/src/lib/ports.ts
index 80fb115e..f11d6442 100644
--- a/site/src/lib/ports.ts
+++ b/site/src/lib/ports.ts
@@ -599,9 +599,22 @@ export function productDescription(port: Port, product: DocProduct): string {
return `In development. ${DOC_PRODUCTS.mcp.description}`
}
+/**
+ * Where a reference tree sits under a port and version.
+ *
+ * Three packages, three trees. The core library answers at `reference/`, and
+ * the Workspace Manager and the MCP server — separately versioned packages
+ * that happen to be documented beside it — answer under their own section.
+ * One spelling for all three, because a reader who has found one should be
+ * able to guess the others.
+ */
+export function referenceSegment(product: DocProduct | 'core'): string {
+ return product === 'core' ? 'reference' : `${product}/reference`
+}
+
/** Language APIs for workspace builders belong to implementation documentation. */
export function productApiPath(product: DocProduct): string {
- return product === 'workspace' ? 'workspace/internals/api' : 'mcp/api'
+ return referenceSegment(product)
}
/**
@@ -623,16 +636,23 @@ export const VERSIONED_PORTS = PORTS.filter((p) => p.versionedDocs)
/** Ports whose language has a canonical reference of its own, too. */
export const ECOSYSTEM_PORTS = PORTS.filter((p) => p.ecosystemHost)
-/** URL of the reference extracted and rendered by this site. */
-export function referenceUrl(port: Port, _version?: string): string {
- return withPortRoot(`/reference/${port.slug}/`)
+/**
+ * URL of the reference extracted and rendered by this site.
+ *
+ * Versioned like every other page under a port, because it documents one
+ * release of one package: `/go/latest/reference/` is the Go core library's
+ * API, and its Workspace Manager and MCP server answer beside it rather than
+ * inside it.
+ */
+export function referenceUrl(port: Port, version: string, product: DocProduct | 'core' = 'core'): string {
+ return portPageUrl(port, version, referenceSegment(product))
}
/** URL of a prose page within a port and version; callers check availability. */
export function portPageUrl(port: Port, version: string, pagePath = ''): string {
const rest = pagePath.replace(/^\/+|\/+$/g, '')
- // Native API paths have no shared spelling; use the unified reference index.
+ // A port's own `api` page is the reference it now sits beside.
if (rest === 'api' || rest.startsWith('api/')) return referenceUrl(port, version)
const tail = rest ? `${rest}/` : ''
diff --git a/site/src/lib/product-api.ts b/site/src/lib/product-api.ts
index 30fa5870..9614366d 100644
--- a/site/src/lib/product-api.ts
+++ b/site/src/lib/product-api.ts
@@ -1,6 +1,6 @@
import { symbolsForProduct, type ApiModel, type ApiSymbol, type SymbolIndex } from '@libtmux/api-model'
import { API_MODELS, createApiIndex, referenceAlternatives } from './api-models'
-import { PORT_BY_SLUG, portPageUrl, productApiPath, type DocProduct } from './ports'
+import { PORT_BY_SLUG, portPageUrl, productApiPath, referenceUrl, type DocProduct } from './ports'
import { withPortRoot } from './site-root'
/** Keep core references stable while product declarations stay in their section. */
@@ -8,7 +8,7 @@ export function productApiHref(model: ApiModel, symbol: ApiSymbol, version: stri
if (symbol.product && symbol.product !== 'core' && symbol.apiScope !== 'internal') {
return portPageUrl(PORT_BY_SLUG[model.port], version, `${productApiPath(symbol.product)}/${symbol.slug}`)
}
- return withPortRoot(`/reference/${model.port}/${symbol.slug}/`)
+ return `${referenceUrl(PORT_BY_SLUG[model.port], version)}${symbol.slug}/`
}
/** Equivalent declarations stay in their product and target port's version. */
diff --git a/site/src/lib/prompts.ts b/site/src/lib/prompts.ts
index f47fde03..9fd7e2da 100644
--- a/site/src/lib/prompts.ts
+++ b/site/src/lib/prompts.ts
@@ -435,7 +435,7 @@ export function portParts(args: {
const reading: (readonly [string, string])[] = [
[portUrl(ctx, port, 'llms.txt'), `every page for ${port.name}, as a list`],
[portUrl(ctx, port, 'docs.json'), 'the same list with headings, as JSON'],
- [`${ctx.docsBase}/reference/${port.slug}/`, `the ${port.name} API reference`],
+ [portUrl(ctx, port, 'reference/'), `the ${port.name} API reference`],
]
if (port.ecosystemHost) {
reading.push([port.ecosystemHost.url, `${port.name} reference on ${port.ecosystemHost.name}`])
diff --git a/site/src/lib/sidebar.ts b/site/src/lib/sidebar.ts
index d2cb512e..23b1a066 100644
--- a/site/src/lib/sidebar.ts
+++ b/site/src/lib/sidebar.ts
@@ -18,7 +18,7 @@
*/
import { getCollection } from 'astro:content'
import type { CollectionEntry } from 'astro:content'
-import { PORT_BY_SLUG, portPageUrl, type DocProduct } from './ports'
+import { PORT_BY_SLUG, portPageUrl, referenceUrl, type DocProduct } from './ports'
import { withPortRoot } from './site-root'
import { DEFAULT_LOCALE, type Locale } from '../i18n/locales'
import { localeOf, sourceIdOf } from '../i18n/resolve'
@@ -107,10 +107,9 @@ export function referenceEntries(port: string, version: string): SidebarLinkItem
if (!p) throw new Error(`sidebar.ts: unknown port slug "${port}"`)
const entries: SidebarLinkItem[] = [
- // withPortRoot, not withRoot: the reference is built in the default
- // locale only, so a Japanese page reaches across to it rather than
- // expecting a copy under its own prefix.
- { type: 'link', label: 'API reference', href: withPortRoot(`/reference/${port}/`), external: false },
+ // The core library's reference, under this port and version — the
+ // Workspace Manager and MCP entries below carry their own.
+ { type: 'link', label: 'API reference', href: referenceUrl(p, version), external: false },
]
if (p.ecosystemHost) {
diff --git a/site/src/lib/versions.ts b/site/src/lib/versions.ts
index 65daa7be..b220fc80 100644
--- a/site/src/lib/versions.ts
+++ b/site/src/lib/versions.ts
@@ -206,3 +206,25 @@ export function buildTarget(env: Record) {
const isDefault = env.LIBTMUX_DOCS_IS_DEFAULT === 'true'
return { version, kind, isDefault }
}
+
+/**
+ * The version prefix another port's page lives under.
+ *
+ * A link that crosses ports — the concept map's equivalents, the prose
+ * linker, a symbol index spanning all eight — names a port this build is not
+ * rendering, so it cannot take the version from `buildTarget`. The assembly
+ * passes every port's default in `LIBTMUX_DOCS_PORT_DEFAULTS`; a build
+ * without it (`pnpm dev`, a bare `astro build`) targets each port's trunk.
+ */
+let portDefaults: Record | undefined
+
+export function defaultVersionFor(port: string): string {
+ if (!portDefaults) {
+ try {
+ portDefaults = JSON.parse(process.env.LIBTMUX_DOCS_PORT_DEFAULTS || '{}') as Record
+ } catch {
+ portDefaults = {}
+ }
+ }
+ return portDefaults[port] ?? 'latest'
+}
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 67d4f98f..594cef40 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -6,7 +6,7 @@ import { DOC_PRODUCTS, PORTS, PORT_BY_SLUG, portPageUrl, primaryInstall, product
import PortHero from '../components/PortHero.astro'
import DocCard from '../components/DocCard.astro'
import ApiReferences from '../components/ApiReferences.astro'
-import { buildTarget } from '../lib/versions'
+import { buildTarget, defaultVersionFor } from '../lib/versions'
import PackageInstall from '../components/widgets/PackageInstall.astro'
import AgentPrompt from '../components/widgets/AgentPrompt.astro'
import { QUICKSTARTS } from '../lib/quickstarts'
@@ -296,7 +296,7 @@ const noindexLanding = locale !== DEFAULT_LOCALE && !LandingContent
))
diff --git a/site/src/pages/page-links.json.ts b/site/src/pages/page-links.json.ts
index dd13a23b..6077623a 100644
--- a/site/src/pages/page-links.json.ts
+++ b/site/src/pages/page-links.json.ts
@@ -4,6 +4,7 @@ import { referenceAlternatives } from '../lib/api-models'
import { PORTS, referenceUrl } from '../lib/ports'
import { buildLocale } from '../i18n/resolve'
import { DEFAULT_LOCALE } from '../i18n/locales'
+import { defaultVersionFor } from '../lib/versions'
/** Verified reference targets for the shell injected into native API pages. */
export const GET: APIRoute = () => {
@@ -23,7 +24,7 @@ export const GET: APIRoute = () => {
}
return new Response(JSON.stringify({
schema: 1,
- indexes: Object.fromEntries(PORTS.map((port) => [port.slug, referenceUrl(port)])),
+ indexes: Object.fromEntries(PORTS.map((port) => [port.slug, referenceUrl(port, defaultVersionFor(port.slug))])),
symbols,
}), { headers: { 'Content-Type': 'application/json' } })
}
diff --git a/site/src/pages/reference/[...slug].astro b/site/src/pages/reference/[...slug].astro
index 60966c22..7f6bba90 100644
--- a/site/src/pages/reference/[...slug].astro
+++ b/site/src/pages/reference/[...slug].astro
@@ -8,10 +8,11 @@ import { pagePortsFor } from '../../lib/page-ports-for'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { withRoot } from '../../lib/site-root'
import { API_MODELS, API_NAV, OWNER_KINDS, PORT_NAME, indexFor, ownersOf, pageSlug, referenceHref, topLevelTypesOf } from '../../lib/api-models'
-import { PORT_BY_SLUG } from '../../lib/ports'
+import { PORT_BY_SLUG, referenceUrl } from '../../lib/ports'
import { symbolSource } from '../../lib/markdown-twins'
import { DEFAULT_LOCALE } from '../../i18n/locales'
import { buildLocale } from '../../i18n/resolve'
+import { defaultVersionFor } from '../../lib/versions'
import ApiDoc from '../../components/api/ApiDoc.astro'
import ApiTree from '../../components/api/ApiTree.astro'
import { navTree } from '../../lib/api-tree'
@@ -34,12 +35,9 @@ import '../../styles/vendor/gp-sphinx-api.css'
*/
export async function getStaticPaths() {
- // Only the root build. This route would otherwise render inside all
- // fourteen shell builds, the way `[port]/index.astro` used to.
- // The root build of the default locale only. The reference is not
- // translated — a `/ja/reference/` tree would be the English body under
- // another locale's prefix, which is what placeholders exist to avoid.
- if (process.env.LIBTMUX_DOCS_PORT) return []
+ // The default locale only. The reference is not translated — a
+ // `/ja/…/reference/` tree would be the English body under another locale's
+ // prefix, which is what placeholders exist to avoid.
if (buildLocale() !== DEFAULT_LOCALE) return []
// Typed explicitly: inferring from the first element gives every field the
@@ -47,27 +45,40 @@ export async function getStaticPaths() {
const paths: {
params: { slug: string | undefined }
props: { model: ApiModel | undefined; owner: ApiSymbol | undefined; searchable?: boolean }
- }[] = [{ params: { slug: undefined }, props: { model: undefined, owner: undefined } }]
-
- for (const [port, model] of Object.entries(API_MODELS)) {
- const productIds = new Set([
- ...symbolsForProduct(model, 'mcp'),
- ...symbolsForProduct(model, 'workspace'),
- ].map((symbol) => symbol.id))
- paths.push({ params: { slug: port }, props: { model, owner: undefined } })
- // Every symbol, not only the types with members. A method that lives as
- // an anchor on someone else's page cannot be listed in a sidebar, cannot
- // carry its own examples, and cannot be linked to as a page — which was
- // true of all but the owning types.
- //
- // `slug` comes from the model rather than from `pageSlug` because it has
- // to be injective; see its comment in model.ts.
- for (const owner of model.symbols) {
- paths.push({
- params: { slug: `${port}/${owner.slug ?? pageSlug(owner.publicId ?? owner.id)}` },
- props: { model, owner, searchable: !productIds.has(owner.id) },
- })
- }
+ }[] = []
+
+ // The root build carries the hub alone: one page listing every port's
+ // reference. Each port's own tree is built inside that port's shell, where
+ // `base` is already `///`, so these routes need no port
+ // segment of their own.
+ const buildPort = process.env.LIBTMUX_DOCS_PORT
+ if (!buildPort) return [{ params: { slug: undefined }, props: { model: undefined, owner: undefined } }]
+
+ const model = API_MODELS[buildPort]
+ if (!model) return []
+ paths.push({ params: { slug: undefined }, props: { model, owner: undefined } })
+
+ // Core only. The Workspace Manager and the MCP server are separately
+ // versioned packages with references of their own, under their own section
+ // of this port — `productApiRoutes` builds those. A declaration scoped
+ // `internal` stays here, because no product page lists it.
+ //
+ // Every symbol, not only the types with members. A method that lives as an
+ // anchor on someone else's page cannot be listed in a sidebar, cannot carry
+ // its own examples, and cannot be linked to as a page.
+ //
+ // `slug` comes from the model rather than from `pageSlug` because it has to
+ // be injective; see its comment in model.ts.
+ const productIds = new Set([
+ ...symbolsForProduct(model, 'mcp'),
+ ...symbolsForProduct(model, 'workspace'),
+ ].map((symbol) => symbol.id))
+ for (const owner of model.symbols) {
+ if (productIds.has(owner.id)) continue
+ paths.push({
+ params: { slug: owner.slug ?? pageSlug(owner.publicId ?? owner.id) },
+ props: { model, owner },
+ })
}
return paths
}
@@ -78,6 +89,10 @@ interface Props {
searchable?: boolean
}
const { model, owner, searchable = true } = Astro.props as Props
+
+/** What the hub counts per port: the core library, without its products. */
+const coreCount = (m: ApiModel) =>
+ m.symbols.length - new Set([...symbolsForProduct(m, 'mcp'), ...symbolsForProduct(m, 'workspace')].map((s) => s.id)).size
/*
* The route param is the page's own path below `/reference/`, so composing
* `pagePath` from it cannot disagree with the URL. Deriving it from `owner`
@@ -102,7 +117,7 @@ const paged = new Set(pageOwners.map((s) => s.id))
*/
const hrefFor = (s: ApiSymbol): string =>
model
- ? withRoot(`/reference/${model.port}/${s.slug ?? pageSlug(s.publicId ?? s.id)}/`)
+ ? withRoot(`/reference/${s.slug ?? pageSlug(s.publicId ?? s.id)}/`)
: withRoot('/reference/')
/*
* The stub is what the index route has instead of a model.
@@ -532,10 +547,10 @@ const title = owner
{Object.entries(API_MODELS).map(([port, m]) => (
{PORT_NAME[port] ?? port}
- {m.symbols.length} symbols
+ {coreCount(m)} symbols
{moduleAnchors.has(ownerModule) ? (
- {ownerModule}
+ {ownerModule}
) : (
ownerModule
)}
diff --git a/site/src/pages/reference/[...slug].md.ts b/site/src/pages/reference/[...slug].md.ts
index 9372741f..2a332791 100644
--- a/site/src/pages/reference/[...slug].md.ts
+++ b/site/src/pages/reference/[...slug].md.ts
@@ -1,10 +1,11 @@
import type { APIRoute } from 'astro'
import { API_MODELS } from '../../lib/api-models'
-import { pageSlug } from '@libtmux/api-model'
+import { pageSlug, symbolsForProduct } from '@libtmux/api-model'
import { symbolMarkdown } from '../../lib/symbol-markdown'
-import { PORT_BY_SLUG } from '../../lib/ports'
+import { PORT_BY_SLUG, referenceUrl } from '../../lib/ports'
import { DEFAULT_LOCALE } from '../../i18n/locales'
import { buildLocale } from '../../i18n/resolve'
+import { buildTarget } from '../../lib/versions'
/**
* `/reference//.md` — the page, as its source.
@@ -18,19 +19,25 @@ import { buildLocale } from '../../i18n/resolve'
* the reference is not rendered inside the fourteen shell builds.
*/
export async function getStaticPaths() {
- // The root build of the default locale only, matching the HTML route: the
- // reference is not translated, so a twin under another locale would be the
- // English body wearing that locale's prefix.
- if (process.env.LIBTMUX_DOCS_PORT) return []
+ // This port's shell, in the default locale only, matching the HTML route:
+ // the reference is not translated, so a twin under another locale would be
+ // the English body wearing that locale's prefix.
+ const port = process.env.LIBTMUX_DOCS_PORT
+ if (!port) return []
if (buildLocale() !== DEFAULT_LOCALE) return []
+ const model = API_MODELS[port]
+ if (!model) return []
+ const productIds = new Set([
+ ...symbolsForProduct(model, 'mcp'),
+ ...symbolsForProduct(model, 'workspace'),
+ ].map((symbol) => symbol.id))
const paths: { params: { slug: string }; props: { port: string; id: string } }[] = []
- for (const [port, model] of Object.entries(API_MODELS)) {
- for (const symbol of model.symbols) {
- paths.push({
- params: { slug: `${port}/${symbol.slug ?? pageSlug(symbol.publicId ?? symbol.id)}` },
- props: { port, id: symbol.id },
- })
- }
+ for (const symbol of model.symbols) {
+ if (productIds.has(symbol.id)) continue
+ paths.push({
+ params: { slug: symbol.slug ?? pageSlug(symbol.publicId ?? symbol.id) },
+ props: { port, id: symbol.id },
+ })
}
return paths
}
@@ -51,7 +58,7 @@ export const GET: APIRoute = ({ props, site }) => {
symbolMarkdown({
model,
symbol,
- canonical: `${origin}/reference/${port}/${slug}/`,
+ canonical: `${origin}${referenceUrl(PORT_BY_SLUG[port]!, buildTarget(process.env).version)}${slug}/`,
source: repo && rev && file
? `https://github.com/${repo}/blob/${rev}/${file}${symbol.source?.line ? `#L${symbol.source.line}` : ''}`
: undefined,
diff --git a/site/src/pages/reference/[port]/tree.json.ts b/site/src/pages/reference/tree.json.ts
similarity index 71%
rename from site/src/pages/reference/[port]/tree.json.ts
rename to site/src/pages/reference/tree.json.ts
index d160b14a..91515761 100644
--- a/site/src/pages/reference/[port]/tree.json.ts
+++ b/site/src/pages/reference/tree.json.ts
@@ -5,17 +5,18 @@
* when a reader first opens another bucket or type. Buckets carry their
* types, and members are keyed by type so a type opens without a second
* request. Slugs rather than URLs: the page knows its own root.
+ *
+ * One file per port shell, beside that port's reference, so the tree a page
+ * loads is the tree of the port it belongs to. The root build has no port and
+ * writes nothing.
*/
import type { APIRoute } from 'astro'
-import { API_MODELS } from '../../../lib/api-models'
-import { bucketTotal, firstEntry, membersByType, navTree, type TreeBucket } from '../../../lib/api-tree'
-
-export function getStaticPaths() {
- return Object.keys(API_MODELS).map((port) => ({ params: { port } }))
-}
+import { API_MODELS } from '../../lib/api-models'
+import { bucketTotal, firstEntry, membersByType, navTree, type TreeBucket } from '../../lib/api-tree'
-export const GET: APIRoute = ({ params }) => {
- const port = String(params.port)
+export const GET: APIRoute = () => {
+ const port = process.env.LIBTMUX_DOCS_PORT ?? ''
+ if (!API_MODELS[port]) return new Response('Not found', { status: 404 })
const members = membersByType(port)
const bucket = (b: TreeBucket): unknown => ({
id: b.id,
From ef11515084e5a341d7dacaa0af51963a08b23387 Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 15:47:58 -0500
Subject: [PATCH 02/17] feat(reference) Point prose links and inventories at
the new trees
why: A prose mention resolved through api-model, which cannot know which
version a port publishes or which package a symbol belongs to, so every
cross-reference in prose still named the old path. The intersphinx
inventories named it too.
what:
- MentionContext takes symbolHref and moduleHref; the site passes
builders that know the target port version and the three trees, and
tooling that only asks whether a span resolves keeps the default
- Every resolved mention now routes through productApiHref
- A port shell writes its own objects.inv beside its reference; the root
build keeps the per-port copies and the combined one, so an external
intersphinx mapping still resolves
- The assembly points its /api/ redirect stubs at the new location
---
packages/api-model/src/prose.ts | 19 ++++++-
scripts/build-site.sh | 15 +++---
site/src/integrations/inventory.ts | 74 +++++++++++++++++-----------
site/src/plugins/rehype-api-links.ts | 31 ++++++++----
4 files changed, 93 insertions(+), 46 deletions(-)
diff --git a/packages/api-model/src/prose.ts b/packages/api-model/src/prose.ts
index d32f72a3..d65ce0d1 100644
--- a/packages/api-model/src/prose.ts
+++ b/packages/api-model/src/prose.ts
@@ -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)}/`
@@ -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
}
/**
@@ -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
@@ -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,
}
diff --git a/scripts/build-site.sh b/scripts/build-site.sh
index 58fcb5fb..a6c2c515 100755
--- a/scripts/build-site.sh
+++ b/scripts/build-site.sh
@@ -538,7 +538,7 @@ 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
@@ -546,9 +546,9 @@ render_staged_reference() {
# `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" <
@@ -984,7 +984,8 @@ while IFS='|' read -r slug name versioned renderer generator checkout ecosystem_
continue
fi
- # One reference per port, at /reference//.
+ # One reference per port and version, at ///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
@@ -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
@@ -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")
diff --git a/site/src/integrations/inventory.ts b/site/src/integrations/inventory.ts
index 26560b8b..d8ac7135 100644
--- a/site/src/integrations/inventory.ts
+++ b/site/src/integrations/inventory.ts
@@ -26,12 +26,56 @@ import { API_MODELS, PORT_NAME, ownersOf, pageSlug } from '../lib/api-models'
* reader, so a wrong one breaks their links, not ours, and nothing here
* would have reported it.
*/
+/**
+ * A URI as intersphinx reads it: relative to the directory holding the
+ * inventory, never to the site root.
+ *
+ * `rooted` is what tells the two apart. The combined inventory sits at
+ * `/objects.inv` and needs the `reference//` prefix; a per-port one
+ * sits beside the pages it names and must not carry it, because intersphinx
+ * joins the URI onto the base URL it was configured with. With the prefix in
+ * both, a resolved C++ class came out as
+ * `libtmux.org/reference/cxx/reference/cxx/libtmux::pane/` — a link that
+ * Sphinx reported as resolved and that goes nowhere.
+ */
+function uriForModel(model: ApiModel, rooted = true) {
+ const paged = new Set(ownersOf(model).map((s) => s.id))
+ return (symbol: { id: string; publicId?: string; parent?: string }) => {
+ const anchor = symbol.publicId ?? symbol.id
+ const owner = paged.has(symbol.id)
+ ? anchor
+ : symbol.parent && paged.has(symbol.parent)
+ ? (model.symbols.find((s) => s.id === symbol.parent)?.publicId ?? symbol.parent)
+ : undefined
+ const page = owner ? `${pageSlug(owner)}/` : ''
+ const prefix = rooted ? `reference/${model.port}/` : ''
+ return `${prefix}${page}#${anchor}`
+ }
+}
+
export function inventory(): AstroIntegration {
return {
name: 'libtmux:inventory',
hooks: {
'astro:build:done': ({ dir, logger }) => {
- if (process.env.LIBTMUX_DOCS_PORT) return
+ // A port shell writes one inventory, beside the reference it just
+ // built: `///reference/objects.inv`, which is where
+ // that port's pages live now.
+ const buildPort = process.env.LIBTMUX_DOCS_PORT
+ if (buildPort) {
+ const model = API_MODELS[buildPort]
+ if (!model) return
+ const uri = uriForModel(model, false)
+ const path = join(dir.pathname, 'reference', 'objects.inv')
+ mkdirSync(dirname(path), { recursive: true })
+ writeFileSync(path, writeInventory(model, {
+ project: `libtmux for ${PORT_NAME[buildPort] ?? buildPort}`,
+ version: model.revision?.slice(0, 7) ?? 'latest',
+ uriFor: uri,
+ }))
+ logger.info(`objects.inv written for ${buildPort} (${model.symbols.length} symbols)`)
+ return
+ }
// The env var, not `buildLocale()` from i18n/resolve: this runs as an
// Astro integration, in the config context, where `astro:content` —
// which that module imports — does not exist.
@@ -39,33 +83,7 @@ export function inventory(): AstroIntegration {
const out = dir.pathname
let total = 0
- /**
- * A URI as intersphinx reads it: relative to the directory holding
- * the inventory, never to the site root.
- *
- * `rooted` is what tells the two apart. The combined inventory sits
- * at `/objects.inv` and needs the `reference//` prefix; a
- * per-port one sits at `/reference//objects.inv` and must not
- * carry it, because intersphinx joins the URI onto the base URL it
- * was configured with. With the prefix in both, a resolved C++ class
- * came out as
- * `libtmux.org/reference/cxx/reference/cxx/libtmux::pane/` — a link
- * that Sphinx reported as resolved and that goes nowhere.
- */
- const uriFor = (model: ApiModel, rooted = true) => {
- const paged = new Set(ownersOf(model).map((s) => s.id))
- return (symbol: { id: string; publicId?: string; parent?: string }) => {
- const anchor = symbol.publicId ?? symbol.id
- const owner = paged.has(symbol.id)
- ? anchor
- : symbol.parent && paged.has(symbol.parent)
- ? (model.symbols.find((s) => s.id === symbol.parent)?.publicId ?? symbol.parent)
- : undefined
- const page = owner ? `${pageSlug(owner)}/` : ''
- const prefix = rooted ? `reference/${model.port}/` : ''
- return `${prefix}${page}#${anchor}`
- }
- }
+ const uriFor = uriForModel
for (const [port, model] of Object.entries(API_MODELS)) {
const bytes = writeInventory(model, {
diff --git a/site/src/plugins/rehype-api-links.ts b/site/src/plugins/rehype-api-links.ts
index 6d7d80a0..3535fb11 100644
--- a/site/src/plugins/rehype-api-links.ts
+++ b/site/src/plugins/rehype-api-links.ts
@@ -2,11 +2,12 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { visit } from 'unist-util-visit'
-import { decideFilePath, decideMention, isLikelyReference, notASymbol, notApiReason, type ApiProduct, type MentionContext, type Resolver } from '@libtmux/api-model'
+import { decideFilePath, decideMention, isLikelyReference, notASymbol, notApiReason, type ApiProduct, type ApiSymbol, type MentionContext, type Resolver } from '@libtmux/api-model'
import { getResolver } from '../lib/prose-resolver'
import { API_MODELS, PORT_NAME } from '../lib/api-models'
import { withPortRoot } from '../lib/site-root'
import { productApiHref } from '../lib/product-api'
+import { PORT_BY_SLUG, referenceUrl } from '../lib/ports'
import { buildTarget } from '../lib/versions'
/**
@@ -143,6 +144,10 @@ export function rehypeApiLinks() {
const product = file?.data?.astro?.frontmatter?.product
let defaults: Record = {}
try { defaults = JSON.parse(process.env.LIBTMUX_DOCS_PORT_DEFAULTS || '{}') } catch { /* Local defaults are latest. */ }
+
+ /** The version a port publishes: this build's, when it is that port. */
+ const versionOf = (port: string) =>
+ port === process.env.LIBTMUX_DOCS_PORT ? buildTarget(process.env).version : (defaults[port] ?? 'latest')
const sections: { depth: number; port?: string }[] = []
const walk = (node: El, inLink: boolean, rowPort: string | undefined, fence: { lang?: string }, before: { text: string }) => {
@@ -189,7 +194,16 @@ export function rehypeApiLinks() {
}
if (child.tagName === 'code' && !inLink) {
const text = textOf(child).trim()
- const ctx = { pagePort: rowPort ?? fence.lang ?? sections.at(-1)?.port ?? buildPort, product, before: scope.text }
+ const ctx = {
+ pagePort: rowPort ?? fence.lang ?? sections.at(-1)?.port ?? buildPort,
+ product,
+ before: scope.text,
+ symbolHref: (port: string, symbol: ApiSymbol) => productApiHref(API_MODELS[port], symbol, versionOf(port)),
+ moduleHref: (port: string, module: string) => {
+ const target = PORT_BY_SLUG[port]
+ return target ? `${referenceUrl(target, versionOf(port))}#${module}` : `#${module}`
+ },
+ }
const wrapped = linkFor(text, ctx, r)
if (wrapped) {
kids[i] = { type: 'element', tagName: 'a', properties: wrapped.properties, children: [child] } as El
@@ -238,15 +252,14 @@ export function rehypeApiLinks() {
return undefined
}
if (d.kind !== 'link') return undefined
+ // Every reference link goes through productApiHref: it knows which of
+ // the three trees a symbol belongs to and which version of the target
+ // port publishes it. `d.href` survives only for what is not a symbol
+ // page — a federated inventory hit, or a module index.
let href = withPortRoot(d.href)
- if (ctx.product && !d.external) {
+ if (!d.external) {
const res = r.resolve(d.port, text, ctx.product)
- if ('symbol' in res) {
- const version = d.port === process.env.LIBTMUX_DOCS_PORT
- ? buildTarget(process.env).version
- : (defaults[d.port] ?? 'latest')
- href = productApiHref(API_MODELS[d.port], res.symbol, version)
- }
+ if ('symbol' in res) href = productApiHref(API_MODELS[d.port], res.symbol, versionOf(d.port))
}
return {
properties: {
From 4653ee2b4372ddabae1c84318a67b9ed2f0a5a44 Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 15:59:18 -0500
Subject: [PATCH 03/17] feat(reference) Give each product its own reference
section
why: The Workspace Manager's declarations answered under
workspace/internals/api and the MCP server's under mcp/api, two
spellings for the same thing and neither matching the core library's.
A reader who has found one reference should be able to guess the others.
what:
- The generated pages and their landing page move together:
//workspace/reference/ and .../mcp/reference/
- The workspace reference leaves Internals, because a package's API is
not an implementation note; Internals keeps its guides, topics and
examples, and workspaceRedirects lifts those alone
- A page's own port comes from the build rather than from the second
segment of its path, which no longer carries one
- Checks read the trees through scripts/reference-trees.mjs, which knows
a port publishes one version prefix or two and that each carries three
references
- Served checks, dev checks and unit expectations follow the new paths
---
scripts/check-api-fidelity.mjs | 4 +-
scripts/check-canonicals.mjs | 10 ++--
scripts/check-type-links.mjs | 3 +-
scripts/check-xrefs.mjs | 3 +-
scripts/reference-trees.mjs | 47 +++++++++++++++++++
site/scripts/check-dev.mjs | 9 ++--
site/scripts/check-fonts.mjs | 24 +++++-----
site/scripts/check-native-shell.mjs | 6 +--
site/scripts/check-style-parity.mjs | 4 +-
site/scripts/check-visual.mjs | 10 ++--
site/src/components/api/ApiTree.astro | 7 ++-
.../ports/cxx/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
.../ports/dotnet/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
.../ports/go/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
.../ports/java/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
.../ports/py/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
.../ports/rs/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
.../ports/swift/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
.../ports/ts/mcp/{api.md => reference.md} | 0
.../{internals/api.md => reference.md} | 0
site/src/lib/docs-paths.ts | 2 +-
site/src/lib/page-port-links.ts | 28 ++++++++---
site/src/pages/reference/[...slug].astro | 15 ++++--
site/test/api-fidelity.test.ts | 2 +-
site/test/inventory-federation.test.ts | 2 +-
site/test/native-switchers.test.ts | 6 +--
site/test/page-port-links.test.ts | 28 +++++------
site/test/product-reference.test.ts | 12 ++---
site/test/prompts.test.ts | 2 +-
site/test/prose-port-sections.test.ts | 10 ++--
site/test/search-index.test.ts | 10 ++--
38 files changed, 161 insertions(+), 83 deletions(-)
create mode 100644 scripts/reference-trees.mjs
rename site/src/content/docs/ports/cxx/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/cxx/workspace/{internals/api.md => reference.md} (100%)
rename site/src/content/docs/ports/dotnet/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/dotnet/workspace/{internals/api.md => reference.md} (100%)
rename site/src/content/docs/ports/go/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/go/workspace/{internals/api.md => reference.md} (100%)
rename site/src/content/docs/ports/java/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/java/workspace/{internals/api.md => reference.md} (100%)
rename site/src/content/docs/ports/py/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/py/workspace/{internals/api.md => reference.md} (100%)
rename site/src/content/docs/ports/rs/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/rs/workspace/{internals/api.md => reference.md} (100%)
rename site/src/content/docs/ports/swift/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/swift/workspace/{internals/api.md => reference.md} (100%)
rename site/src/content/docs/ports/ts/mcp/{api.md => reference.md} (100%)
rename site/src/content/docs/ports/ts/workspace/{internals/api.md => reference.md} (100%)
diff --git a/scripts/check-api-fidelity.mjs b/scripts/check-api-fidelity.mjs
index 365b38d4..2bc9d3b5 100755
--- a/scripts/check-api-fidelity.mjs
+++ b/scripts/check-api-fidelity.mjs
@@ -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
@@ -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
diff --git a/scripts/check-canonicals.mjs b/scripts/check-canonicals.mjs
index 0f2f1d21..25124523 100644
--- a/scripts/check-canonicals.mjs
+++ b/scripts/check-canonicals.mjs
@@ -21,6 +21,10 @@ 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')
@@ -49,8 +53,8 @@ if (siteDir === defaultSite && existsSync(lock)) {
}
}
-const root = join(siteDir, 'reference')
-if (!existsSync(root)) {
+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)
}
@@ -63,7 +67,7 @@ const walk = (dir) => {
else if (entry === 'index.html') pages.push(full)
}
}
-walk(root)
+for (const root of roots) walk(root)
const CANONICAL = / [...htmlFiles(dir)])) {
const html = readFileSync(file, 'utf8')
resolved += (html.match(TYPE_LINK) ?? []).length
for (const block of typeBlocks(html)) {
diff --git a/scripts/check-xrefs.mjs b/scripts/check-xrefs.mjs
index b89d10d7..c684eb99 100755
--- a/scripts/check-xrefs.mjs
+++ b/scripts/check-xrefs.mjs
@@ -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
@@ -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')) {
diff --git a/scripts/reference-trees.mjs b/scripts/reference-trees.mjs
new file mode 100644
index 00000000..dc4a6562
--- /dev/null
+++ b/scripts/reference-trees.mjs
@@ -0,0 +1,47 @@
+/*
+ * Where a built reference tree is, now that there are three per port.
+ *
+ * The core library answers under `//reference/`, and the
+ * Workspace Manager and MCP server under `///
+ * reference/`. A port publishes one version prefix today and Python two, so
+ * every check that reads the reference has to look for all of them rather
+ * than the single `reference//` it used to know.
+ *
+ * One module, because five checks ask the same question and the answer moved
+ * once already.
+ */
+import { existsSync, readdirSync, statSync } from 'node:fs'
+import { join } from 'node:path'
+
+/** Product sections carrying a reference of their own. */
+export const PRODUCT_SECTIONS = ['workspace', 'mcp']
+
+/** Version prefixes built for a port, newest-agnostic and sorted for stability. */
+export function versionsOf(site, port) {
+ const dir = join(site, port)
+ if (!existsSync(dir)) return []
+ return readdirSync(dir)
+ .filter((entry) => statSync(join(dir, entry)).isDirectory())
+ .filter((entry) => existsSync(join(dir, entry, 'reference')))
+ .sort()
+}
+
+/**
+ * Every reference directory for a port: its core tree per version, and each
+ * product's tree beside it.
+ *
+ * `products` is off by default because most checks measure the core library;
+ * the ones that cover the whole estate ask for it.
+ */
+export function referenceDirs(site, port, { products = false } = {}) {
+ const dirs = []
+ for (const version of versionsOf(site, port)) {
+ dirs.push(join(site, port, version, 'reference'))
+ if (!products) continue
+ for (const product of PRODUCT_SECTIONS) {
+ const dir = join(site, port, version, product, 'reference')
+ if (existsSync(dir)) dirs.push(dir)
+ }
+ }
+ return dirs
+}
diff --git a/site/scripts/check-dev.mjs b/site/scripts/check-dev.mjs
index fe353cac..b1a592e6 100644
--- a/site/scripts/check-dev.mjs
+++ b/site/scripts/check-dev.mjs
@@ -40,7 +40,7 @@ try {
const manifest = await page.request.get(`${base}/page-links.json`)
assert(manifest.ok(), `Native navigation manifest: HTTP ${manifest.status()}`)
assert.equal((await manifest.json()).schema, 1)
- const paths = ['concepts/server-session-window-pane', 'mcp/tools', 'reference/ts/session-session-panes',
+ const paths = ['concepts/server-session-window-pane', 'mcp/tools', 'ts/latest/reference/session-session-panes',
'ts/latest/workspace/internals/guides', 'py/stable/workspace/guides',
'ts/latest/mcp/tools', 'dotnet/latest/mcp/tools/tmux_capture_pane']
for (const path of paths) await retryReload(async () => {
@@ -51,9 +51,10 @@ try {
assert.equal(await page.locator('nav[aria-label="Language"] a').first().getAttribute('href'), '/en/py/stable/')
const switcher = page.locator('[data-page-port-switcher]')
const hasSwitcher = path !== 'mcp/tools'
+ const isReference = path.includes('/reference/')
const expected = path.includes('workspace/') ? `/en/${path}/`
- : path === 'dotnet/latest/mcp/tools/tmux_capture_pane' ? '/en/py/stable/mcp/tools/capture_pane/' : path.startsWith('reference/')
- ? '/en/reference/py/libtmux-session-panes/' : `/en/py/stable/${path.replace(/^ts\/latest\//, '')}/`
+ : path === 'dotnet/latest/mcp/tools/tmux_capture_pane' ? '/en/py/stable/mcp/tools/capture_pane/' : isReference
+ ? '/en/py/stable/reference/libtmux-session-panes/' : `/en/py/stable/${path.replace(/^ts\/latest\//, '')}/`
if (hasSwitcher) assert.equal(await switcher.locator('a').first().getAttribute('href'), expected)
if (path === 'py/stable/workspace/guides') {
assert.equal(await switcher.locator('a').count(), 1, 'Only Python has a workspace CLI guide')
@@ -66,7 +67,7 @@ try {
assert.equal(await switcher.locator('a').count(), 8)
assert.equal(await switcher.locator('a[aria-current="page"]').getAttribute('href'), `/en/${path}/`)
}
- if (path.startsWith('reference/')) {
+ if (isReference) {
assert.match(await switcher.locator('[aria-disabled="true"]').textContent(), /Java/)
const target = await page.request.get(base.replace(/\/en$/, '') + expected)
assert(target.ok(), `Equivalent target: HTTP ${target.status()}`)
diff --git a/site/scripts/check-fonts.mjs b/site/scripts/check-fonts.mjs
index 58cdc748..59100928 100755
--- a/site/scripts/check-fonts.mjs
+++ b/site/scripts/check-fonts.mjs
@@ -50,8 +50,8 @@ const FAMILIES = ['IBM Plex Sans', 'IBM Plex Mono']
const ARCHETYPES = [
['home', '/', true],
['port page', '/py/', true],
- ['reference entry', '/reference/py/libtmux-server/', true],
- ['reference index', '/reference/go/', true],
+ ['reference entry', '/py/stable/reference/libtmux-server/', true],
+ ['reference index', '/go/latest/reference/', true],
['symbol index', '/reference/symbols/p/', true],
['topic', '/topics/traversal/', true],
['example', '/examples/attach-and-send-keys/', true],
@@ -66,20 +66,20 @@ const ARCHETYPES = [
// `libtmux.Server`, not a `_compat` shim: the reference stopped publishing
// the vendored and compatibility modules, and a sample page has to be one
// the port actually exports if it is to keep being built.
- ['ref py', '/reference/py/libtmux-server/', true],
- ['ref ts', '/reference/ts/builder-applywindowcontext/', true],
- ['ref rs', '/reference/rs/blocking-runtime/', true],
- ['ref go', '/reference/go/tmux-activityaction/', true],
- ['ref java', '/reference/java/io-github-libtmux-batch-batch-batch/', true],
- ['ref dotnet', '/reference/dotnet/libtmux-attachsessionrequest/', true],
- ['ref cxx', '/reference/cxx/libtmux-argumentsensitivity/', true],
- ['ref swift', '/reference/swift/calleridentity/', true],
+ ['ref py', '/py/stable/reference/libtmux-server/', true],
+ ['ref ts', '/ts/latest/reference/builder-applywindowcontext/', true],
+ ['ref rs', '/rs/latest/reference/blocking-runtime/', true],
+ ['ref go', '/go/latest/reference/tmux-activityaction/', true],
+ ['ref java', '/java/latest/reference/io-github-libtmux-batch-batch-batch/', true],
+ ['ref dotnet', '/dotnet/latest/reference/libtmux-attachsessionrequest/', true],
+ ['ref cxx', '/cxx/latest/reference/libtmux-argumentsensitivity/', true],
+ ['ref swift', '/swift/latest/reference/calleridentity/', true],
// A member's own page, which is a different shape from its type's: the type
// lists its members, the member carries the signature. The italic type
// annotations in a signature live only here, so an archetype list without
// one reports Mono 400 italic as preloaded and unused.
- ['member py', '/reference/py/libtmux-server-wait_for/', true],
- ['member dotnet', '/reference/dotnet/libtmux-pane-clearhistoryasync/', true],
+ ['member py', '/py/stable/reference/libtmux-server-wait_for/', true],
+ ['member dotnet', '/dotnet/latest/reference/libtmux-pane-clearhistoryasync/', true],
]
/**
diff --git a/site/scripts/check-native-shell.mjs b/site/scripts/check-native-shell.mjs
index 77e852f4..5ea3badb 100644
--- a/site/scripts/check-native-shell.mjs
+++ b/site/scripts/check-native-shell.mjs
@@ -15,7 +15,7 @@ try {
assert(response?.ok(), `Native Session page: HTTP ${response?.status()}`)
const menu = page.locator('[data-page-port-switcher]')
await menu.waitFor()
- await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/reference/ts/session-session/"]'))
+ await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/ts/latest/reference/session-session/"]'))
assert(loaded.has(`${base}/_shell/shell.js`), 'Native shell script did not load from this locale')
assert(loaded.has(`${base}/_shell/tokens.css`), 'Native shell tokens did not load from this locale')
await menu.locator('summary').click()
@@ -27,9 +27,9 @@ try {
})
assert(bounds.sameRow && bounds.left >= 0 && bounds.right <= bounds.viewport, 'Native dropdowns split rows or leave the viewport')
await page.evaluate(() => { location.hash = 'sessions' })
- await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/reference/ts/session-session/"]'))
+ await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/ts/latest/reference/session-session/"]'))
await page.evaluate(() => { location.hash = 'libtmux.Session.windows' })
- await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/reference/ts/session-session-windows/"]'))
+ await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/ts/latest/reference/session-session-windows/"]'))
console.log('Native shell: script, tokens, phone dropdowns and class/member equivalents passed')
} finally {
await browser.close()
diff --git a/site/scripts/check-style-parity.mjs b/site/scripts/check-style-parity.mjs
index e16c72fd..343363db 100755
--- a/site/scripts/check-style-parity.mjs
+++ b/site/scripts/check-style-parity.mjs
@@ -31,7 +31,7 @@ const SPHINX = `${base}/py/stable/api/api/libtmux.server/`
* shows them did, and pointing at the old one reported all seven treatments
* as "not found on this site" rather than as different.
*/
-const OURS = `${base}/reference/py/libtmux-server-new_session/`
+const OURS = `${base}/py/stable/reference/libtmux-server-new_session/`
/*
* A second page, because a page is now one symbol.
@@ -41,7 +41,7 @@ const OURS = `${base}/reference/py/libtmux-server-new_session/`
* it — and now each symbol has its own, so no single URL carries both. A case
* names the page it needs; the default is the method page.
*/
-const OURS_ATTRIBUTE = `${base}/reference/py/libtmux-_internal-constants-hooks-after_capture_pane/`
+const OURS_ATTRIBUTE = `${base}/py/stable/reference/libtmux-_internal-constants-hooks-after_capture_pane/`
/**
* Equivalent elements, and the properties that carry the look.
diff --git a/site/scripts/check-visual.mjs b/site/scripts/check-visual.mjs
index 5d331abb..0e860d70 100755
--- a/site/scripts/check-visual.mjs
+++ b/site/scripts/check-visual.mjs
@@ -32,13 +32,13 @@ const base = process.argv.find((a) => a.startsWith('http')) ?? 'http://localhost
/** One page per rendering path, not one per port: the component is shared. */
const PAGES = [
- ['py-class', '/reference/py/libtmux-server/'],
+ ['py-class', '/py/stable/reference/libtmux-server/'],
// Rust carries the other prose path: fenced examples and `# Errors`
// rubrics, which no Python docstring in this estate uses.
- ['rs-class', '/reference/rs/server-server/'],
- ['cxx-class', '/reference/cxx/libtmux-pane/'],
- ['swift-class', '/reference/swift/server/'],
- ['port-index', '/reference/go/'],
+ ['rs-class', '/rs/latest/reference/server-server/'],
+ ['cxx-class', '/cxx/latest/reference/libtmux-pane/'],
+ ['swift-class', '/swift/latest/reference/server/'],
+ ['port-index', '/go/latest/reference/'],
['symbol-index', '/reference/symbols/p/'],
]
diff --git a/site/src/components/api/ApiTree.astro b/site/src/components/api/ApiTree.astro
index 5bf7cf9c..46cbba2b 100644
--- a/site/src/components/api/ApiTree.astro
+++ b/site/src/components/api/ApiTree.astro
@@ -19,7 +19,8 @@ import Sidebar from '../docs/Sidebar.astro'
import ApiTreeItems, { type TreeNode } from './ApiTreeItems.astro'
import type { NavEntry } from '../../lib/api-models'
import { bucketTotal, firstEntry, type TreeBucket } from '../../lib/api-tree'
-import { withRoot } from '../../lib/site-root'
+import { PORT_BY_SLUG, referenceUrl } from '../../lib/ports'
+import { buildTarget } from '../../lib/versions'
import '../../styles/api-tree.css'
interface Props {
@@ -47,7 +48,9 @@ const {
port, portName, tree, currentRoot, currentBucket, currentTypeId, ownerNavId, ownerId,
members, withMembers, menuVersion, currentPath,
} = Astro.props as Props
-const base = withRoot('/reference/')
+// The port's own reference root. `withRoot` would give the locale root:
+// Astro's `base` prefixes routes, not strings a component builds.
+const base = referenceUrl(PORT_BY_SLUG[port]!, buildTarget(process.env).version)
const href = (slug: string) => `${base}${slug}/`
let seq = 0
diff --git a/site/src/content/docs/ports/cxx/mcp/api.md b/site/src/content/docs/ports/cxx/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/cxx/mcp/api.md
rename to site/src/content/docs/ports/cxx/mcp/reference.md
diff --git a/site/src/content/docs/ports/cxx/workspace/internals/api.md b/site/src/content/docs/ports/cxx/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/cxx/workspace/internals/api.md
rename to site/src/content/docs/ports/cxx/workspace/reference.md
diff --git a/site/src/content/docs/ports/dotnet/mcp/api.md b/site/src/content/docs/ports/dotnet/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/dotnet/mcp/api.md
rename to site/src/content/docs/ports/dotnet/mcp/reference.md
diff --git a/site/src/content/docs/ports/dotnet/workspace/internals/api.md b/site/src/content/docs/ports/dotnet/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/dotnet/workspace/internals/api.md
rename to site/src/content/docs/ports/dotnet/workspace/reference.md
diff --git a/site/src/content/docs/ports/go/mcp/api.md b/site/src/content/docs/ports/go/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/go/mcp/api.md
rename to site/src/content/docs/ports/go/mcp/reference.md
diff --git a/site/src/content/docs/ports/go/workspace/internals/api.md b/site/src/content/docs/ports/go/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/go/workspace/internals/api.md
rename to site/src/content/docs/ports/go/workspace/reference.md
diff --git a/site/src/content/docs/ports/java/mcp/api.md b/site/src/content/docs/ports/java/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/java/mcp/api.md
rename to site/src/content/docs/ports/java/mcp/reference.md
diff --git a/site/src/content/docs/ports/java/workspace/internals/api.md b/site/src/content/docs/ports/java/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/java/workspace/internals/api.md
rename to site/src/content/docs/ports/java/workspace/reference.md
diff --git a/site/src/content/docs/ports/py/mcp/api.md b/site/src/content/docs/ports/py/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/py/mcp/api.md
rename to site/src/content/docs/ports/py/mcp/reference.md
diff --git a/site/src/content/docs/ports/py/workspace/internals/api.md b/site/src/content/docs/ports/py/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/py/workspace/internals/api.md
rename to site/src/content/docs/ports/py/workspace/reference.md
diff --git a/site/src/content/docs/ports/rs/mcp/api.md b/site/src/content/docs/ports/rs/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/rs/mcp/api.md
rename to site/src/content/docs/ports/rs/mcp/reference.md
diff --git a/site/src/content/docs/ports/rs/workspace/internals/api.md b/site/src/content/docs/ports/rs/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/rs/workspace/internals/api.md
rename to site/src/content/docs/ports/rs/workspace/reference.md
diff --git a/site/src/content/docs/ports/swift/mcp/api.md b/site/src/content/docs/ports/swift/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/swift/mcp/api.md
rename to site/src/content/docs/ports/swift/mcp/reference.md
diff --git a/site/src/content/docs/ports/swift/workspace/internals/api.md b/site/src/content/docs/ports/swift/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/swift/workspace/internals/api.md
rename to site/src/content/docs/ports/swift/workspace/reference.md
diff --git a/site/src/content/docs/ports/ts/mcp/api.md b/site/src/content/docs/ports/ts/mcp/reference.md
similarity index 100%
rename from site/src/content/docs/ports/ts/mcp/api.md
rename to site/src/content/docs/ports/ts/mcp/reference.md
diff --git a/site/src/content/docs/ports/ts/workspace/internals/api.md b/site/src/content/docs/ports/ts/workspace/reference.md
similarity index 100%
rename from site/src/content/docs/ports/ts/workspace/internals/api.md
rename to site/src/content/docs/ports/ts/workspace/reference.md
diff --git a/site/src/lib/docs-paths.ts b/site/src/lib/docs-paths.ts
index 629c53f5..f00c4289 100644
--- a/site/src/lib/docs-paths.ts
+++ b/site/src/lib/docs-paths.ts
@@ -28,7 +28,7 @@ export function docsRoutePath(
export function workspaceRedirects(paths: string[]): { path: string; target: string }[] {
const published = new Set(paths)
return paths.flatMap((target) => {
- const path = target.replace(/(^|\/)workspace\/internals\/(topics|guides|examples|api)(\/|$)/, '$1workspace/$2$3')
+ const path = target.replace(/(^|\/)workspace\/internals\/(topics|guides|examples)(\/|$)/, '$1workspace/$2$3')
return path !== target && !published.has(path) ? [{ path, target }] : []
})
}
diff --git a/site/src/lib/page-port-links.ts b/site/src/lib/page-port-links.ts
index cc2d5473..c3d6a4f1 100644
--- a/site/src/lib/page-port-links.ts
+++ b/site/src/lib/page-port-links.ts
@@ -5,9 +5,16 @@ import { docsPath, type DocsPage } from './docs-paths'
import { productApiHref } from './product-api'
import { MCP_REFERENCE, equivalentMcpTool } from './mcp-reference'
+/**
+ * Every symbol by the route it answers on, keyed by port.
+ *
+ * A reference page's path no longer carries its port — it sits under one —
+ * so the port comes from the build rather than from the path's second
+ * segment.
+ */
const symbolsByRoute = new Map(Object.entries(API_MODELS).flatMap(([port, model]) =>
model.symbols.map((symbol) => [
- `reference/${port}/${symbol.slug ?? pageSlug(symbol.publicId ?? symbol.id)}`,
+ `${port}/${symbol.slug ?? pageSlug(symbol.publicId ?? symbol.id)}`,
symbol,
] as const),
))
@@ -41,11 +48,13 @@ export function pagePortLinks({
docs: DocsPage[]
}): PagePortLink[] {
const path = pagePath.replace(/^\/+|\/+$/g, '')
- const [, referencePort, symbolSlug] = path.split('/')
const isReference = path === 'reference' || path.startsWith('reference/')
- const productReference = /^(mcp|workspace)\/(?:internals\/)?api\/(.+)$/.exec(path)
- const symbol = symbolsByRoute.get(productReference ? `reference/${portSlug}/${productReference[2]}` : path)
- const symbolPort = productReference ? portSlug : referencePort
+ const symbolSlug = isReference ? path.slice('reference/'.length) || undefined : undefined
+ const productReference = /^(mcp|workspace)\/reference\/(.+)$/.exec(path)
+ const symbol = portSlug
+ ? symbolsByRoute.get(`${portSlug}/${productReference ? productReference[2] : (symbolSlug ?? '')}`)
+ : undefined
+ const symbolPort = portSlug
const alternatives = symbol ? referenceAlternatives(symbolPort!, symbol.publicId ?? symbol.id) : []
const entries = docs.filter((entry) => docsPath(entry) === path)
const tool = path.startsWith('mcp/tools/') && portSlug
@@ -61,7 +70,10 @@ export function pagePortLinks({
} else if (symbol) {
for (const alternative of alternatives) {
const match = alternative.ports.find((p) => p.port === port.slug)
- const targetSymbol = productReference && match?.publicId
+ // Resolved here rather than taken from `match.href`, because the
+ // equivalent lives under the *target* port's version, which this
+ // caller knows and `referenceAlternatives` does not.
+ const targetSymbol = match?.publicId
? API_MODELS[port.slug]?.symbols.find((entry) => (entry.publicId ?? entry.id) === match.publicId) : undefined
const href = targetSymbol ? productApiHref(API_MODELS[port.slug], targetSymbol, targetVersion) : match?.href
if (href && !links.some((link) => link.href === href)) {
@@ -69,7 +81,9 @@ export function pagePortLinks({
}
}
if (port.slug === symbolPort) {
- const href = productReference ? portPageUrl(port, targetVersion, path) : referenceHref(symbolPort, symbol.publicId ?? symbol.id)
+ const href = productReference
+ ? portPageUrl(port, targetVersion, path)
+ : referenceHref(symbolPort, symbol.publicId ?? symbol.id, targetVersion)
if (href) links = [{ href }]
}
} else if (tool) {
diff --git a/site/src/pages/reference/[...slug].astro b/site/src/pages/reference/[...slug].astro
index 7f6bba90..a8cb5858 100644
--- a/site/src/pages/reference/[...slug].astro
+++ b/site/src/pages/reference/[...slug].astro
@@ -12,7 +12,7 @@ import { PORT_BY_SLUG, referenceUrl } from '../../lib/ports'
import { symbolSource } from '../../lib/markdown-twins'
import { DEFAULT_LOCALE } from '../../i18n/locales'
import { buildLocale } from '../../i18n/resolve'
-import { defaultVersionFor } from '../../lib/versions'
+import { buildTarget, defaultVersionFor } from '../../lib/versions'
import ApiDoc from '../../components/api/ApiDoc.astro'
import ApiTree from '../../components/api/ApiTree.astro'
import { navTree } from '../../lib/api-tree'
@@ -90,6 +90,11 @@ interface Props {
}
const { model, owner, searchable = true } = Astro.props as Props
+/** This port and version's reference root, which every link below hangs off. */
+const refBase = model && PORT_BY_SLUG[model.port]
+ ? referenceUrl(PORT_BY_SLUG[model.port], buildTarget(process.env).version)
+ : withRoot('/reference/')
+
/** What the hub counts per port: the core library, without its products. */
const coreCount = (m: ApiModel) =>
m.symbols.length - new Set([...symbolsForProduct(m, 'mcp'), ...symbolsForProduct(m, 'workspace')].map((s) => s.id)).size
@@ -117,7 +122,7 @@ const paged = new Set(pageOwners.map((s) => s.id))
*/
const hrefFor = (s: ApiSymbol): string =>
model
- ? withRoot(`/reference/${s.slug ?? pageSlug(s.publicId ?? s.id)}/`)
+ ? `${refBase}${s.slug ?? pageSlug(s.publicId ?? s.id)}/`
: withRoot('/reference/')
/*
* The stub is what the index route has instead of a model.
@@ -727,10 +732,10 @@ const title = owner
is a URL, and that is the whole feature.
*/}
diff --git a/site/src/content/docs/ports/py/workspace/internals/topics.md b/site/src/content/docs/ports/py/workspace/internals/topics.md
index 3d997408..eddc6e5b 100644
--- a/site/src/content/docs/ports/py/workspace/internals/topics.md
+++ b/site/src/content/docs/ports/py/workspace/internals/topics.md
@@ -34,6 +34,6 @@ build does not have universal transactional rollback. The CLI owns its
existing-session prompts and the attachment or client-switching workflow.
See the upstream [custom builder guide](https://tmuxp.git-pull.com/topics/custom-workspace-builders/)
-for extension configuration and the [API](../reference/) for interface contracts.
+for extension configuration and the [API](../../reference/) for interface contracts.
[Configuration loader](https://github.com/tmux-python/tmuxp/blob/618b398acc05506d3c682906c36cdeb29dcfa1ff/src/tmuxp/workspace/loader.py); [Classic builder](https://github.com/tmux-python/tmuxp/blob/618b398acc05506d3c682906c36cdeb29dcfa1ff/src/tmuxp/workspace/builder/classic.py).
diff --git a/site/src/content/docs/ports/ts/workspace/reference.md b/site/src/content/docs/ports/ts/workspace/reference.md
index 5f3d13bf..22706cc2 100644
--- a/site/src/content/docs/ports/ts/workspace/reference.md
+++ b/site/src/content/docs/ports/ts/workspace/reference.md
@@ -42,6 +42,6 @@ is not a transaction log of every effect or a receipt for shell commands.
The language API builds workspaces directly. Availability through an MCP
server is a separate protocol capability; consult this port's
-[MCP section](../../../mcp/) for its advertised tools.
+[MCP section](../../mcp/) for its advertised tools.
[Public exports](https://github.com/libtmux/libtmux-ts/blob/f85b8de551353f746d50eaf36bf0112f4fe5a528/packages/workspace/package.json)
diff --git a/site/src/data/mentions.json b/site/src/data/mentions.json
index c156fbcd..f9bf7b0c 100644
--- a/site/src/data/mentions.json
+++ b/site/src/data/mentions.json
@@ -1,5 +1,5 @@
{
- "generated": "2026-09-13T21:14:30.938Z",
+ "generated": "2026-09-13T21:20:41.972Z",
"mentions": [
{
"port": "cxx",
From f30b97e0c4b3a67d0e74029f1912fe379a4a2d44 Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 16:28:38 -0500
Subject: [PATCH 10/17] fix(reference) Canonicalise a reference page to its own
version
why: The route passed no port to the layout, so every page declared
/en/reference// -- the URL it used to have, which no longer
exists. 12,600 pages pointed there.
what:
- The route passes its port and version, so the canonical is built the
way every other port page builds one
- check-canonicals asserts the port rule it now follows: a page is
canonical to itself under the default version, and another version of
it points there
---
scripts/check-canonicals.mjs | 34 +++++++++++++++++-------
site/src/pages/reference/[...slug].astro | 2 ++
2 files changed, 27 insertions(+), 9 deletions(-)
diff --git a/scripts/check-canonicals.mjs b/scripts/check-canonicals.mjs
index 25124523..259a21dd 100644
--- a/scripts/check-canonicals.mjs
+++ b/scripts/check-canonicals.mjs
@@ -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
@@ -26,6 +27,16 @@ 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)
+/**
+ * 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 repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const defaultSite = join(repoRoot, '_site')
const siteDir = process.argv.slice(2).find((a) => !a.startsWith('--')) ?? defaultSite
@@ -87,8 +98,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) {
diff --git a/site/src/pages/reference/[...slug].astro b/site/src/pages/reference/[...slug].astro
index 66de631e..c8905e1c 100644
--- a/site/src/pages/reference/[...slug].astro
+++ b/site/src/pages/reference/[...slug].astro
@@ -469,6 +469,8 @@ const title = owner
: 'Types, methods, and functions in the libtmux language libraries.'
}
pagePath={slug ? `reference/${slug}` : 'reference'}
+ portSlug={model?.port}
+ version={buildTarget(process.env).version}
noindex={true}
searchable={searchable}
pageSource={owner && model ? symbolSource(model, owner) : undefined}
From efe6db06efdaeb6d7668a96d4ee1bf32307ea0ab Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 16:29:02 -0500
Subject: [PATCH 11/17] fix(scripts) Read the built versions after the site dir
is known
why: The defaults lookup ran above the argument it reads, so
check-canonicals threw before it checked anything -- and its negative
test is what caught it.
what:
- The lookup moves below the site directory it depends on
---
scripts/check-canonicals.mjs | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/scripts/check-canonicals.mjs b/scripts/check-canonicals.mjs
index 259a21dd..b65d0cab 100644
--- a/scripts/check-canonicals.mjs
+++ b/scripts/check-canonicals.mjs
@@ -27,15 +27,6 @@ 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)
-/**
- * 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 repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const defaultSite = join(repoRoot, '_site')
@@ -64,6 +55,16 @@ if (siteDir === defaultSite && existsSync(lock)) {
}
}
+/**
+ * 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}`)
From 9b55cd934618c7feaf20ac628d79e5457cdf600b Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 16:37:18 -0500
Subject: [PATCH 12/17] fix(docs) Link a reference from prose without naming a
version
why: The preview build publishes one version per port, so a prose link
that spelled /py/stable/reference/ broke there while passing on an
assembly that builds both. CI caught it: one broken link in 1,987,189.
what:
- Every same-port reference link in prose is relative to the page, so it
resolves under whichever version prefix the build produced
- The type-name ceilings move to the wider basis: the check reads all
three trees per port now, where before it read only the core tree and
never saw a product page
---
scripts/type-links-ceiling.json | 12 ++++++------
.../content/docs/ports/cxx/workspace/reference.md | 2 +-
.../docs/ports/dotnet/workspace/reference.md | 10 +++++-----
.../content/docs/ports/go/workspace/reference.md | 14 +++++++-------
.../ports/java/workspace/internals/examples.md | 2 +-
.../content/docs/ports/java/workspace/reference.md | 8 ++++----
.../docs/ports/py/workspace/internals/index.md | 2 +-
.../content/docs/ports/rs/workspace/reference.md | 4 ++--
.../docs/ports/swift/workspace/internals/guides.md | 2 +-
.../docs/ports/swift/workspace/internals/index.md | 2 +-
.../docs/ports/swift/workspace/reference.md | 8 ++++----
.../content/docs/ports/ts/workspace/reference.md | 12 ++++++------
site/src/data/mentions.json | 2 +-
13 files changed, 40 insertions(+), 40 deletions(-)
diff --git a/scripts/type-links-ceiling.json b/scripts/type-links-ceiling.json
index e1851404..1fc32dad 100644
--- a/scripts/type-links-ceiling.json
+++ b/scripts/type-links-ceiling.json
@@ -1,10 +1,10 @@
{
- "py": 138,
- "ts": 94,
- "rs": 510,
- "go": 107,
+ "py": 262,
+ "ts": 90,
+ "rs": 546,
+ "go": 112,
"java": 137,
"dotnet": 22,
- "cxx": 259,
- "swift": 1269
+ "cxx": 258,
+ "swift": 1603
}
diff --git a/site/src/content/docs/ports/cxx/workspace/reference.md b/site/src/content/docs/ports/cxx/workspace/reference.md
index 77b3fcc2..06685bb5 100644
--- a/site/src/content/docs/ports/cxx/workspace/reference.md
+++ b/site/src/content/docs/ports/cxx/workspace/reference.md
@@ -43,7 +43,7 @@ to parse YAML.
## Core operations
The returned session is a core libtmux value. Use the
-[C++ core reference](/cxx/latest/reference/) for subsequent inspection and mutation.
+[C++ core reference](../../reference/) for subsequent inspection and mutation.
Consumer source contracts remain the authority for the workspace types.
[Workspace header](https://github.com/libtmux/libtmux-cxx/blob/c7f1146d2ebd7a8323d9f9814517dc3cdf86b4ee/examples/workspace/include/libtmux_consumers/workspace.hpp); [YAML header](https://github.com/libtmux/libtmux-cxx/blob/c7f1146d2ebd7a8323d9f9814517dc3cdf86b4ee/examples/workspace/include/libtmux_consumers/tmuxp.hpp).
diff --git a/site/src/content/docs/ports/dotnet/workspace/reference.md b/site/src/content/docs/ports/dotnet/workspace/reference.md
index 0d103981..cf2c28e0 100644
--- a/site/src/content/docs/ports/dotnet/workspace/reference.md
+++ b/site/src/content/docs/ports/dotnet/workspace/reference.md
@@ -15,16 +15,16 @@ that uses a caller-supplied LibTmux `Server`.
## Configuration
-[`WorkspaceFile`](/dotnet/latest/workspace/reference/libtmux-workspace-workspacefile/) parses
+[`WorkspaceFile`](./libtmux-workspace-workspacefile/) parses
YAML and holds the session description. `WorkspaceWindow` and `WorkspacePane`
hold nested configuration. `WorkspaceFormatException` identifies unsupported
or invalid configuration.
## Builder options
-[`WorkspaceBuilder`](/dotnet/latest/workspace/reference/libtmux-workspace-workspacebuilder/)
+[`WorkspaceBuilder`](./libtmux-workspace-workspacebuilder/)
accepts a server, an optional positive readiness timeout, and a
-[`PaneReadiness`](/dotnet/latest/workspace/reference/libtmux-workspace-panereadiness/) policy.
+[`PaneReadiness`](./libtmux-workspace-panereadiness/) policy.
Its `BuildAsync` accepts the configuration and an optional cancellation token.
The default timeout is ten seconds. `Auto`, `Always`, and `Never` select which
@@ -33,11 +33,11 @@ heuristic and its limitations.
## Results and failures
-[`WorkspaceResult`](/dotnet/latest/workspace/reference/libtmux-workspace-workspaceresult/)
+[`WorkspaceResult`](./libtmux-workspace-workspaceresult/)
contains the created session, windows, and rejected layouts. A rejected layout
does not discard its window.
-[`WorkspaceBuildException`](/dotnet/latest/workspace/reference/libtmux-workspace-workspacebuildexception/)
+[`WorkspaceBuildException`](./libtmux-workspace-workspacebuildexception/)
keeps a `PartialResult` when state could be materialized before failure. It
can be null when no such result could be read. Inspect live tmux state before
retrying; a missing result does not prove that no command reached tmux.
diff --git a/site/src/content/docs/ports/go/workspace/reference.md b/site/src/content/docs/ports/go/workspace/reference.md
index 6ee20e6b..0fc0c83a 100644
--- a/site/src/content/docs/ports/go/workspace/reference.md
+++ b/site/src/content/docs/ports/go/workspace/reference.md
@@ -16,23 +16,23 @@ and cancellation.
## Parse configuration
-[`Parse`](/go/latest/workspace/reference/workspace-parse/) reads YAML into a
-[`Workspace`](/go/latest/workspace/reference/workspace-workspace/). Its errors match
+[`Parse`](./workspace-parse/) reads YAML into a
+[`Workspace`](./workspace-workspace/). Its errors match
`ErrInvalidWorkspace`. Inspect the individual diagnostics to locate unknown
keys or invalid values.
-[`Window`](/go/latest/workspace/reference/workspace-window/),
-[`Pane`](/go/latest/workspace/reference/workspace-pane/), and
-[`Command`](/go/latest/workspace/reference/workspace-command/) let applications construct the
+[`Window`](./workspace-window/),
+[`Pane`](./workspace-pane/), and
+[`Command`](./workspace-command/) let applications construct the
same data in Go. `Bool` preserves tmuxp's supported boolean spellings.
## Build a session
-[`Build`](/go/latest/workspace/reference/workspace-build/) creates the initial session and owns
+[`Build`](./workspace-build/) creates the initial session and owns
a temporary control connection for the duration of construction. It returns
a session and an error; a non-nil error can accompany a partial session.
-[`BuildInto`](/go/latest/workspace/reference/workspace-buildinto/) populates a supplied session
+[`BuildInto`](./workspace-buildinto/) populates a supplied session
and preserves the caller's connection ownership.
`Workspace.InitialSessionRequest`
produces the initial request for that workflow.
diff --git a/site/src/content/docs/ports/java/workspace/internals/examples.md b/site/src/content/docs/ports/java/workspace/internals/examples.md
index a64b577c..904742bb 100644
--- a/site/src/content/docs/ports/java/workspace/internals/examples.md
+++ b/site/src/content/docs/ports/java/workspace/internals/examples.md
@@ -18,7 +18,7 @@ fences and runs them against real tmux.
Within an application that already has a `Server`, import `Workspace` and
`WorkspaceBuilder` from [`io.github.libtmux.workspace`](https://github.com/libtmux/libtmux-java/tree/4f057d367a25dee818d70876fa283fc503a3a7eb/libtmux-workspace/src/main/java/io/github/libtmux/workspace), and `Session` from
-[`io.github.libtmux`](/java/latest/reference/). This excerpt uses the same configuration as the module's
+[`io.github.libtmux`](../../../reference/). This excerpt uses the same configuration as the module's
result example:
```java
diff --git a/site/src/content/docs/ports/java/workspace/reference.md b/site/src/content/docs/ports/java/workspace/reference.md
index fe83c880..2ab7a5bf 100644
--- a/site/src/content/docs/ports/java/workspace/reference.md
+++ b/site/src/content/docs/ports/java/workspace/reference.md
@@ -16,7 +16,7 @@ before building through a core `Server`.
## Builder facade
-[`WorkspaceBuilder`](/java/latest/workspace/reference/io-github-libtmux-workspace-workspacebuilder-workspacebuilder/)
+[`WorkspaceBuilder`](./io-github-libtmux-workspace-workspacebuilder-workspacebuilder/)
provides three static entry points:
- `read(Path)` reads a YAML file and wraps I/O errors in `UncheckedIOException`.
@@ -28,11 +28,11 @@ server's support for the requested layout.
## Configuration records
-[`Workspace`](/java/latest/workspace/reference/io-github-libtmux-workspace-workspace-workspace/)
+[`Workspace`](./io-github-libtmux-workspace-workspace-workspace/)
holds the session name and ordered windows.
-[`WindowSpec`](/java/latest/workspace/reference/io-github-libtmux-workspace-windowspec-windowspec/)
+[`WindowSpec`](./io-github-libtmux-workspace-windowspec-windowspec/)
holds the name, optional layout, and panes.
-[`PaneSpec`](/java/latest/workspace/reference/io-github-libtmux-workspace-panespec-panespec/)
+[`PaneSpec`](./io-github-libtmux-workspace-panespec-panespec/)
holds the ordered shell commands.
These records copy their lists so later changes to an input list do not alter
diff --git a/site/src/content/docs/ports/py/workspace/internals/index.md b/site/src/content/docs/ports/py/workspace/internals/index.md
index ddcaaf93..903af9a1 100644
--- a/site/src/content/docs/ports/py/workspace/internals/index.md
+++ b/site/src/content/docs/ports/py/workspace/internals/index.md
@@ -28,4 +28,4 @@ attachment or client switching.
The upstream [Internals documentation](https://tmuxp.git-pull.com/internals/)
contains the full architecture and module reference. Use the
-[libtmux Python API](/py/stable/reference/) for general tmux programming.
+[libtmux Python API](../../reference/) for general tmux programming.
diff --git a/site/src/content/docs/ports/rs/workspace/reference.md b/site/src/content/docs/ports/rs/workspace/reference.md
index a759b739..f65e2186 100644
--- a/site/src/content/docs/ports/rs/workspace/reference.md
+++ b/site/src/content/docs/ports/rs/workspace/reference.md
@@ -16,14 +16,14 @@ for actual tmux operations.
## Configuration
-[`Workspace`](/rs/latest/workspace/reference/config-workspace/) holds the session description.
+[`Workspace`](./config-workspace/) holds the session description.
`Workspace::from_yaml` parses it, and `to_yaml` emits its YAML representation.
`WindowConfig` and `PaneConfig` describe the nested objects; `ConfigError`
identifies invalid configuration.
## Builder
-[`WorkspaceBuilder`](/rs/latest/workspace/reference/src-workspacebuilder/) borrows a `Server`.
+[`WorkspaceBuilder`](./src-workspacebuilder/) borrows a `Server`.
`new` selects that server, `plan` returns the inert construction plan, and
`build` asynchronously creates the requested session. Keep the server alive
for the builder's lifetime.
diff --git a/site/src/content/docs/ports/swift/workspace/internals/guides.md b/site/src/content/docs/ports/swift/workspace/internals/guides.md
index 7dd6be2b..77369fe8 100644
--- a/site/src/content/docs/ports/swift/workspace/internals/guides.md
+++ b/site/src/content/docs/ports/swift/workspace/internals/guides.md
@@ -10,7 +10,7 @@ sidebar:
tableOfContents: true
---
-Add `TmuxWorkspace` and [`LibTmux`](/swift/latest/reference/) to a SwiftPM target. This isolated example
+Add `TmuxWorkspace` and [`LibTmux`](../../../reference/) to a SwiftPM target. This isolated example
also uses the public `TmuxFixture` product for server startup and cleanup. The
following dependency selects the source revision used by these examples:
diff --git a/site/src/content/docs/ports/swift/workspace/internals/index.md b/site/src/content/docs/ports/swift/workspace/internals/index.md
index af224c70..dc5a3580 100644
--- a/site/src/content/docs/ports/swift/workspace/internals/index.md
+++ b/site/src/content/docs/ports/swift/workspace/internals/index.md
@@ -32,7 +32,7 @@ handling, and attachment.
`TmuxWorkspace` builds a tmux session from Swift values or a
[tmuxp](https://tmuxp.git-pull.com)-style configuration. It is a SwiftPM
-library product beside the core [`LibTmux`](/swift/latest/reference/) product.
+library product beside the core [`LibTmux`](../../reference/) product.
Swift and JSON descriptions work without a YAML dependency. Enable the
`YAMLWorkspaces` package trait to add YAML decoding. Building uses the same
diff --git a/site/src/content/docs/ports/swift/workspace/reference.md b/site/src/content/docs/ports/swift/workspace/reference.md
index 8cb7f98d..7183d29c 100644
--- a/site/src/content/docs/ports/swift/workspace/reference.md
+++ b/site/src/content/docs/ports/swift/workspace/reference.md
@@ -10,12 +10,12 @@ sidebar:
tableOfContents: true
---
-Import `TmuxWorkspace` for the configuration and builder, and [`LibTmux`](/swift/latest/reference/) for
+Import `TmuxWorkspace` for the configuration and builder, and [`LibTmux`](../../reference/) for
the server and returned session value.
## Configuration values
-[`Workspace`](/swift/latest/workspace/reference/workspace/) contains the session name, optional
+[`Workspace`](./workspace/) contains the session name, optional
working directory, and ordered windows. `WindowPlan` contains its name,
directory, layout, and panes. `PanePlan` contains its directory and commands.
The values conform to `Sendable`, `Hashable`, and `Codable`.
@@ -26,14 +26,14 @@ existing description; it does not query tmux for a live export.
## Builder
-[`WorkspaceBuilder`](/swift/latest/workspace/reference/workspacebuilder/) exposes the async
+[`WorkspaceBuilder`](./workspacebuilder/) exposes the async
`build(_:on:)` operation. It creates a new session on the supplied server and
returns a `Session`. Keep using the server for live observation; session
properties do not refresh themselves.
## Typed errors
-[`WorkspaceBuilderError`](/swift/latest/workspace/reference/workspacebuildererror/) distinguishes
+[`WorkspaceBuilderError`](./workspacebuildererror/) distinguishes
an empty window list, an existing session name, a vanished session, underlying
tmux errors, and failure of rollback.
diff --git a/site/src/content/docs/ports/ts/workspace/reference.md b/site/src/content/docs/ports/ts/workspace/reference.md
index 22706cc2..4ad9b910 100644
--- a/site/src/content/docs/ports/ts/workspace/reference.md
+++ b/site/src/content/docs/ports/ts/workspace/reference.md
@@ -17,26 +17,26 @@ not choose a workspace or apply one automatically.
## Parse and validate
-[`parseWorkspace`](/ts/latest/workspace/reference/config-parseworkspace/) validates data already
+[`parseWorkspace`](./config-parseworkspace/) validates data already
read by your application.
-[`parseWorkspaceYaml`](/ts/latest/workspace/reference/config-parseworkspaceyaml/)
+[`parseWorkspaceYaml`](./config-parseworkspaceyaml/)
adds Bun's YAML parsing. Both produce the workspace configuration consumed by
the builder.
## Plan and apply
-[`planWorkspace`](/ts/latest/workspace/reference/builder-planworkspace/) returns a description
-of membership changes. [`WorkspacePlan`](/ts/latest/workspace/reference/planning-workspaceplan/)
+[`planWorkspace`](./builder-planworkspace/) returns a description
+of membership changes. [`WorkspacePlan`](./planning-workspaceplan/)
records creations, removals, renames, and retained surplus.
-[`applyWorkspace`](/ts/latest/workspace/reference/builder-applyworkspace/) performs the work and
+[`applyWorkspace`](./builder-applyworkspace/) performs the work and
resolves to the resulting `Session`. Its options control pruning and whether
commands run in existing panes. Planning accepts pruning policy; command
policy belongs to application.
## Handle failure
-[`WorkspaceApplyError`](/ts/latest/workspace/reference/builder-workspaceapplyerror/) preserves
+[`WorkspaceApplyError`](./builder-workspaceapplyerror/) preserves
the cause and completed milestones. Reinspect tmux before retrying; the error
is not a transaction log of every effect or a receipt for shell commands.
diff --git a/site/src/data/mentions.json b/site/src/data/mentions.json
index f9bf7b0c..7f63cbae 100644
--- a/site/src/data/mentions.json
+++ b/site/src/data/mentions.json
@@ -1,5 +1,5 @@
{
- "generated": "2026-09-13T21:20:41.972Z",
+ "generated": "2026-09-13T21:36:55.929Z",
"mentions": [
{
"port": "cxx",
From 40448bdaf4541a6220571b9572464258a4c48cb6 Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 16:44:19 -0500
Subject: [PATCH 13/17] test(site) Follow the product sections and their
structured data
why: A product reference is a section of its product now, so the
assembled suite looked for pages under the path it had, and the
structured-data rule named api and tools but not reference.
what:
- The product section list carries reference beside Internals rather
than inside it, and the workspace breadcrumb reads Reference
- Seo marks any reference page APIReference, the core tree included,
where before only a product api or tools page qualified
- The exported metadata assertion follows the same spelling
---
site/src/components/Seo.astro | 2 +-
site/test/product-docs.test.ts | 16 +++++++++-------
2 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/site/src/components/Seo.astro b/site/src/components/Seo.astro
index cdb46611..ee46a57f 100644
--- a/site/src/components/Seo.astro
+++ b/site/src/components/Seo.astro
@@ -148,7 +148,7 @@ const graph: Graph = {
...(pagePath
? [
{
- '@type': /^(mcp|workspace)\/(internals\/)?(api|tools)(\/|$)/.test(pagePath) ? 'APIReference' as const : 'TechArticle' as const,
+ '@type': /^(?:(?:mcp|workspace)\/)?(?:internals\/)?(?:reference|api|tools)(\/|$)/.test(pagePath) ? 'APIReference' as const : 'TechArticle' as const,
'@id': `${canonical}#article`,
url: canonical,
headline: title,
diff --git a/site/test/product-docs.test.ts b/site/test/product-docs.test.ts
index fb596601..d4aa62c5 100644
--- a/site/test/product-docs.test.ts
+++ b/site/test/product-docs.test.ts
@@ -40,10 +40,13 @@ const urlFor = (path: string) => new URL(`/${SITE_PREFIX}${path}`, 'https://libt
const productUrl = /\/(?:py|ts|rs|go|java|dotnet|cxx|swift)\/[^/]+\/(?:mcp|workspace)(?:\/|$)/
function sectionsFor(port: string, product: ProductPage['product']): string[] {
- if (product === 'mcp') return ['', 'topics', 'guides', 'examples', 'api']
+ // `reference` is a section of the product now, not a page inside Internals:
+ // the Workspace Manager and the MCP server are packages with APIs of their
+ // own, and Internals keeps the notes about building one.
+ if (product === 'mcp') return ['', 'topics', 'guides', 'examples', 'reference']
return port === 'py'
- ? ['', 'topics', 'guides', 'examples', 'internals', 'internals/topics', 'internals/examples', 'internals/api']
- : ['', 'internals', 'internals/topics', 'internals/guides', 'internals/examples', 'internals/api']
+ ? ['', 'topics', 'guides', 'examples', 'reference', 'internals', 'internals/topics', 'internals/examples']
+ : ['', 'reference', 'internals', 'internals/topics', 'internals/guides', 'internals/examples']
}
function pages(): ProductPage[] {
@@ -204,7 +207,7 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
})
it('links generated declarations and schema-bearing tools inside their product', () => {
- for (const page of pages().filter((entry) => entry.section === 'api' || entry.section === 'internals/api')) {
+ for (const page of pages().filter((entry) => entry.section === 'reference')) {
const prefix = `${page.port}/${page.version}/${page.product}/${page.section}/`
const declarations = inspect(page.path, (document) =>
[...document.querySelectorAll('[aria-labelledby="generated-api"] a[href]')]
@@ -222,11 +225,10 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
if (page.product === 'workspace') {
const labels = [...document.querySelectorAll('nav[aria-label="Breadcrumb"] a, nav[aria-label="Breadcrumb"] [aria-current="page"]')]
.map((item) => item.textContent.trim())
- expect(labels.slice(0, 3)).toEqual([page.name, 'Workspace Manager', 'Internals'])
+ expect(labels.slice(0, 3)).toEqual([page.name, 'Workspace Manager', 'Reference'])
expect(graph(document).find((entry) => entry['@type'] === 'BreadcrumbList')?.itemListElement?.map((item) => item.name)).toEqual(labels)
} else developmentStatus(document, samplePath)
})
- if (page.product === 'workspace') redirectsTo(samplePath.replace('/internals/api/', '/api/'), samplePath)
if (page.product !== 'mcp') continue
const toolsPath = `${page.port}/${page.version}/mcp/tools/`
const tools = inspect(toolsPath, (document) => {
@@ -344,7 +346,7 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
for (const product of products) {
const metadata = advertised.products.find((entry) => entry.slug === product)!
expect(metadata.inDevelopment, `${root}${port.slug} ${product} development status`).toBe(product === 'mcp' || port.slug !== 'py')
- const section = product === 'workspace' ? 'internals/api' : 'api'
+ const section = 'reference'
expect(new URL(metadata.reference, urlFor(root)).pathname).toBe(urlFor(`${port.slug}/${defaults[port.slug]}/${product}/${section}/`).pathname)
if (product === 'workspace') expect(metadata.cli, `${root}${port.slug} user CLI`).toBe(port.slug === 'py' ? 'tmuxp load' : null)
else expect(resolves(metadata.protocol!, urlFor(root).href), `${root}${port.slug} MCP protocol`).toBe(true)
From 48495388549d628b2fef61ee89a44f82646d0f55 Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 16:46:45 -0500
Subject: [PATCH 14/17] test(site) Clear the last old paths from checks and
fixtures
why: Four files still named workspace/internals/api, and the
intersphinx writer looked for an inventory at the root copy alone.
what:
- check-dev, the font audit and the product suite name the product
reference section
- add-intersphinx prefers the versioned inventory beside its reference,
falling back to nothing rather than to a path that no longer holds one
---
scripts/add-intersphinx.mjs | 13 ++++++++++---
site/scripts/check-dev.mjs | 4 ++--
site/test/font-audit.test.ts | 2 +-
site/test/product-docs.test.ts | 4 ++--
4 files changed, 15 insertions(+), 8 deletions(-)
diff --git a/scripts/add-intersphinx.mjs b/scripts/add-intersphinx.mjs
index cfa9f304..90bfe55f 100755
--- a/scripts/add-intersphinx.mjs
+++ b/scripts/add-intersphinx.mjs
@@ -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) {
@@ -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//`
+ // 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) {
diff --git a/site/scripts/check-dev.mjs b/site/scripts/check-dev.mjs
index b1a592e6..b643c89b 100644
--- a/site/scripts/check-dev.mjs
+++ b/site/scripts/check-dev.mjs
@@ -94,8 +94,8 @@ try {
}
})
for (const path of [
- 'py/stable/workspace/internals/api/tmuxp-workspace-builder-classicworkspacebuilder',
- 'java/latest/workspace/internals/api/io-github-libtmux-workspace-workspacebuilder-workspacebuilder',
+ 'py/stable/workspace/reference/tmuxp-workspace-builder-classicworkspacebuilder',
+ 'java/latest/workspace/reference/io-github-libtmux-workspace-workspacebuilder-workspacebuilder',
]) await retryReload(async () => {
const response = await page.goto(`${base}/${path}/`, { waitUntil: 'load' })
assert(response?.ok(), `${path}: HTTP ${response?.status()}`)
diff --git a/site/test/font-audit.test.ts b/site/test/font-audit.test.ts
index 1af66979..34e46a97 100644
--- a/site/test/font-audit.test.ts
+++ b/site/test/font-audit.test.ts
@@ -22,7 +22,7 @@ function audit(html: string) {
describe('font audit redirect exemptions', () => {
it('recognizes an Astro redirect with long paths in its title and code labels', () => {
- const target = '/en/dotnet/latest/workspace/internals/api/libtmux-workspace-workspacebuilder-buildsessionasync/'
+ const target = '/en/dotnet/latest/workspace/reference/libtmux-workspace-workspacebuilder-buildsessionasync/'
const source = target.replace('/internals/', '/')
const result = audit(`
Redirecting to: ${target}
diff --git a/site/test/product-docs.test.ts b/site/test/product-docs.test.ts
index d4aa62c5..b80836f2 100644
--- a/site/test/product-docs.test.ts
+++ b/site/test/product-docs.test.ts
@@ -283,7 +283,7 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
it('redirects previous workspace implementation URLs without replacing Python CLI docs', () => {
for (const page of pages().filter((entry) => entry.product === 'workspace'
- && (entry.section === 'internals/api' || (entry.port !== 'py' && entry.section.startsWith('internals/'))))) {
+ && (entry.section === 'reference' || (entry.port !== 'py' && entry.section.startsWith('internals/'))))) {
redirectsTo(page.path.replace('/internals/', '/'), page.path)
}
for (const page of pages().filter((entry) => entry.port === 'py' && entry.product === 'workspace'
@@ -330,7 +330,7 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
expect(index.pages.some((entry) => entry.url === url), `${url} in docs.json`).toBe(true)
expect(llms, `${url} in llms.txt`).toContain(`](${url})`)
expect(sitemap, `${url} in sitemap`).toContain(`${url}`)
- if (page.product === 'workspace' && (page.section === 'internals/api'
+ if (page.product === 'workspace' && (page.section === 'reference'
|| (page.port !== 'py' && page.section.startsWith('internals/')))) {
const legacy = url.replace('/workspace/internals/', '/workspace/')
expect(sitemap, `${legacy} redirect is not canonical`).not.toContain(`${legacy}`)
From 570d4799c57c68973fd284065bf0cdab846236d0 Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 17:00:04 -0500
Subject: [PATCH 15/17] fix(site) Advertise the reference where an agent can
fetch it
why: docs.json named /reference// for every port, so sixteen
manifest entries pointed at pages that no longer exist -- the one file
an agent reads first. The native navigation manifest test and the
workspace redirect expectations named the old shape too.
what:
- Each port entry, its Markdown twin and its inventory carry the port's
default version, the same one the pages are built under
- A product reference is a section of its product, so no legacy twin
redirects to it and the sitemap lists it like any other page
---
site/src/pages/docs.json.ts | 12 ++++++------
site/test/exports.test.ts | 2 +-
site/test/product-docs.test.ts | 8 +++++---
3 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/site/src/pages/docs.json.ts b/site/src/pages/docs.json.ts
index 8b8fe662..7614aec8 100644
--- a/site/src/pages/docs.json.ts
+++ b/site/src/pages/docs.json.ts
@@ -79,10 +79,10 @@ export const GET: APIRoute = async ({ site }) => {
description: `${model.symbols.length} symbols extracted from source, ${types.length} with their own page.`,
section: 'API reference',
// refBase, not base: the reference is generated in the default locale
- // only, so a Japanese manifest advertising /ja/reference/… names pages
- // nothing builds. Nothing parses this file, so nothing reported it.
- url: `${origin}${refBase}reference/${port}/`,
- markdownUrl: `${origin}${refBase}reference/${port}/index.md`,
+ // only, so a Japanese manifest advertising a locale-prefixed reference
+ // names pages nothing builds.
+ url: `${origin}${refBase}${port}/${defaults[port] ?? 'latest'}/reference/`,
+ markdownUrl: `${origin}${refBase}${port}/${defaults[port] ?? 'latest'}/reference/index.md`,
headings: types.slice(0, 200).map((t) => ({
id: t.publicId ?? t.id,
level: 2,
@@ -111,7 +111,7 @@ export const GET: APIRoute = async ({ site }) => {
name: p.name,
language: p.language,
package: p.packageName,
- reference: hasReference(p) ? referenceUrl(p, 'stable') : null,
+ reference: hasReference(p) ? referenceUrl(p, defaults[p.slug] ?? 'latest') : null,
products: Object.entries(DOC_PRODUCTS).map(([slug, product]) => ({
slug, name: product.label,
inDevelopment: productInDevelopment(p, slug as DocProduct),
@@ -125,7 +125,7 @@ export const GET: APIRoute = async ({ site }) => {
? {
symbols: API_MODELS[p.slug].symbols.length,
extractor: API_MODELS[p.slug].extractor,
- inventory: `${refBase}reference/${p.slug}/objects.inv`,
+ inventory: `${refBase}${p.slug}/${defaults[p.slug] ?? 'latest'}/reference/objects.inv`,
}
: null,
})),
diff --git a/site/test/exports.test.ts b/site/test/exports.test.ts
index b34591f7..77a28bf6 100644
--- a/site/test/exports.test.ts
+++ b/site/test/exports.test.ts
@@ -143,7 +143,7 @@ describeIfAssembled('published exports', () => {
expect(manifest.schema).toBe(1)
expect(Object.keys(manifest.indexes).sort()).toEqual(PORTS.map((port) => port.slug).sort())
expect(manifest.symbols.py['libtmux.Session.windows']).toEqual(expect.arrayContaining([
- expect.objectContaining({ port: 'ts', href: expect.stringMatching(/\/reference\/ts\/session-session-windows\/$/) }),
+ expect.objectContaining({ port: 'ts', href: expect.stringMatching(/\/ts\/[^/]+\/reference\/session-session-windows\/$/) }),
]))
const targets = new Set([
...Object.values(manifest.indexes as Record),
diff --git a/site/test/product-docs.test.ts b/site/test/product-docs.test.ts
index b80836f2..911e09ec 100644
--- a/site/test/product-docs.test.ts
+++ b/site/test/product-docs.test.ts
@@ -282,8 +282,11 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
})
it('redirects previous workspace implementation URLs without replacing Python CLI docs', () => {
+ // The reference is a section of the product now, not a page inside
+ // Internals, so it has no lifted twin to redirect. What remains under
+ // Internals still does, for a port with no workspace CLI of its own.
for (const page of pages().filter((entry) => entry.product === 'workspace'
- && (entry.section === 'reference' || (entry.port !== 'py' && entry.section.startsWith('internals/'))))) {
+ && entry.port !== 'py' && entry.section.startsWith('internals/'))) {
redirectsTo(page.path.replace('/internals/', '/'), page.path)
}
for (const page of pages().filter((entry) => entry.port === 'py' && entry.product === 'workspace'
@@ -330,8 +333,7 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
expect(index.pages.some((entry) => entry.url === url), `${url} in docs.json`).toBe(true)
expect(llms, `${url} in llms.txt`).toContain(`](${url})`)
expect(sitemap, `${url} in sitemap`).toContain(`${url}`)
- if (page.product === 'workspace' && (page.section === 'reference'
- || (page.port !== 'py' && page.section.startsWith('internals/')))) {
+ if (page.product === 'workspace' && page.port !== 'py' && page.section.startsWith('internals/')) {
const legacy = url.replace('/workspace/internals/', '/workspace/')
expect(sitemap, `${legacy} redirect is not canonical`).not.toContain(`${legacy}`)
}
From 749689f5c168991490fd029bcffabd0668641173 Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 17:08:49 -0500
Subject: [PATCH 16/17] test(site) Expect the breadcrumb and index of a product
reference
why: A product reference hangs directly off its product now, so a
symbol page has no Internals level in its breadcrumb, and the search
cases named two pages that no longer exist.
what:
- The breadcrumb assertion pins the product and the page, not the level
between them that moved
- The search cases name the trees that exist: a product declaration, the
core reference, its index and the hub
---
site/test/product-docs.test.ts | 5 ++++-
site/test/search-index.test.ts | 3 ---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/site/test/product-docs.test.ts b/site/test/product-docs.test.ts
index 911e09ec..3b8af8b8 100644
--- a/site/test/product-docs.test.ts
+++ b/site/test/product-docs.test.ts
@@ -225,7 +225,10 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => {
if (page.product === 'workspace') {
const labels = [...document.querySelectorAll('nav[aria-label="Breadcrumb"] a, nav[aria-label="Breadcrumb"] [aria-current="page"]')]
.map((item) => item.textContent.trim())
- expect(labels.slice(0, 3)).toEqual([page.name, 'Workspace Manager', 'Reference'])
+ // The reference is a section of the product, so its pages hang
+ // directly off it: no Internals level in between any more.
+ expect(labels.slice(0, 2)).toEqual([page.name, 'Workspace Manager'])
+ expect(labels.at(-1)).toBe(sample.title)
expect(graph(document).find((entry) => entry['@type'] === 'BreadcrumbList')?.itemListElement?.map((item) => item.name)).toEqual(labels)
} else developmentStatus(document, samplePath)
})
diff --git a/site/test/search-index.test.ts b/site/test/search-index.test.ts
index 61216d06..d28f554b 100644
--- a/site/test/search-index.test.ts
+++ b/site/test/search-index.test.ts
@@ -5,14 +5,11 @@ import { SITE_BUILT, sitePath } from './site-root'
it.skipIf(!SITE_BUILT)('indexes scoped product declarations while retaining core, internal, and index pages', () => {
const cases = [
- ['reference/go/workspace-build', false],
- ['go/latest/workspace/api/workspace-build', false],
['go/latest/workspace/reference/workspace-build', true],
['go/latest/workspace/guides', false],
['go/latest/workspace/internals/guides', true],
['py/latest/workspace/guides', true],
['py/latest/workspace/internals', true],
- ['reference/ts/mcp-startup-serverstartup', false],
['ts/latest/mcp/reference/mcp-startup-serverstartup', true],
['ts/latest/reference/builder-applywindowcontext', true],
['go/latest/reference/tmux-server', true],
From da8b0aa3036fcdef4fbcc4a9ac600d3e96c7347d Mon Sep 17 00:00:00 2001
From: Tony Narlock
Date: Sun, 13 Sep 2026 17:16:53 -0500
Subject: [PATCH 17/17] test(site) Measure cross-references across all three
trees
why: The check counted one tree per port and that tree held everything,
duplicates included. It reads the core reference and both product
references now, which is every page a port publishes -- so seven ports
rise, Python falls, and neither movement is a change in resolution.
Python falls because its 627 workspace and MCP declarations left the
core tree for product pages. Both render the same ApiEntry, but a core
reference page wraps it in examples, related entries and module links,
and those carry cross-references the product page has no room for. The
duplicates that used to be counted are gone with them.
what:
- py 13,027 -> 11,925, and the seven that rose take their new counts:
dotnet 3,975 -> 4,771, swift 3,298 -> 3,739, rs 3,750 -> 4,330
---
scripts/xref-floor.json | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/scripts/xref-floor.json b/scripts/xref-floor.json
index ad92298e..77a173c7 100644
--- a/scripts/xref-floor.json
+++ b/scripts/xref-floor.json
@@ -1,10 +1,10 @@
{
- "py": 13027,
- "ts": 2072,
- "rs": 3750,
- "go": 13194,
- "java": 1647,
- "dotnet": 3975,
- "cxx": 1901,
- "swift": 3298
+ "py": 11925,
+ "ts": 2094,
+ "rs": 4330,
+ "go": 13216,
+ "java": 1672,
+ "dotnet": 4771,
+ "cxx": 1982,
+ "swift": 3739
}