Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
977c1f9
fix(site) Keep port docs in their own language
tony Sep 29, 2026
d5cffbd
fix(site) Resolve native guide and API links
tony Sep 29, 2026
802c2a1
docs(workspace) Teach native CLI tasks in each port
tony Sep 29, 2026
1539597
docs(workspace) Complete native task guides
tony Sep 29, 2026
f2997cb
fix(docs) Preserve code while staging guide links
tony Sep 29, 2026
e7f041c
fix(site) Remove the homepage article toolbar
tony Sep 29, 2026
949f8c3
fix(search) Start with the current port selected
tony Sep 29, 2026
125fccd
fix(site) Integrate older native references
tony Sep 29, 2026
915a0cf
fix(docs[api]) restore the drawer after navigation
tony Sep 29, 2026
e1c3640
fix(site[native]) Render navigation before first paint
tony Sep 29, 2026
76a14f4
fix(site[native]) Keep tablet navigation compact
tony Sep 29, 2026
b8d8012
fix(site[links]) Open wrapper source directories
tony Sep 29, 2026
5ca071b
test(site[links]) Check wrapper source directories
tony Sep 29, 2026
767da8f
fix(docs[Go]) Correct the transport example
tony Sep 30, 2026
e34ebbe
docs(site[wrappers]) Provide complete starter programs
tony Sep 30, 2026
8893bab
fix(scripts[staging]) Keep links on the guide revision
tony Sep 30, 2026
6e2f2a1
docs(writing) Require complete runnable examples
tony Sep 30, 2026
2c4fb70
fix(staging) Defer selected guides to fresh export
tony Sep 30, 2026
11c465a
test(site[footer]) Handle preview paths
tony Sep 30, 2026
84dbdf1
docs(examples) Publish complete capture programs
tony Sep 30, 2026
94f3606
test(prose) Check API links in routine gates
tony Sep 30, 2026
258c360
fix(examples) Clean up partial session startup
tony Sep 30, 2026
a0864f0
test(products) Accept Java Gradle settings
tony Sep 30, 2026
0151f79
docs(products) Publish complete native examples
tony Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ asset normalization:
$ pnpm test:inner
```

Run workspace unit suites, lint, and generated mention/navigation freshness
checks in the medium loop:
Run workspace unit suites, lint, API links, and generated mention/navigation
freshness checks in the medium loop:

```console
$ pnpm test:medium
Expand Down
76 changes: 67 additions & 9 deletions WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,12 @@ than translating another port's spelling by analogy.

Prefer a tested source example to a manually copied snippet. This site's
[`remark-port-code.mjs`](site/src/plugins/remark-port-code.mjs) reads fences
with `file="..."` from its configured port checkout or docs worktree;
with `file="..."` from the revision-bound example cache;
`region="..."` selects text between the source's region markers. Read that
plugin's mapping before editing an example source. Port mutations follow
plugin's mapping before editing an example source. Regenerate the cache with
`node scripts/gen-example-sources.mjs`: it reads committed files at the source
revision recorded by the API model or wrapper guide artifact. Working-tree
edits and a newer HEAD do not change the documented example. Port mutations follow
[Repository boundaries](AGENTS.md#repository-boundaries).

Preserve source metadata, region markers, doctest prompts, and expected
Expand All @@ -92,21 +95,76 @@ paths and a coverage floor, but it does not execute every language example.
It scans `.md` pages, not MDX, and cannot check source existence when the
relevant checkout is absent.

For inline examples, record the verification performed in the change's
review notes. Do not claim a code fence is executed merely because it has a
language tag. Preserve collected examples when changing their formatting.
Every executable example must work when copied with its displayed setup.
Include imports, an entry point, required inputs, and cleanup. Show dependency
and run commands. Do not rely on variables or helper code from another example.
A source file that only declares functions is not a runnable program.

Run the exact displayed program against the documented library revision.
Record its commands, source revision, result, and content hash in the review.
Tests that add a hidden prelude or execute a larger source file do not verify
the copied example. Preserve collected examples when changing formatting.

Put explanatory comments on separate lines above the code they describe.
Wrap example comments at 80 columns, including indentation. Put long source
links and attribution in prose outside the code block.

### Examples across ports

Port pages teach only their selected language. Root pages explain tmux behavior
and may compare languages or show equivalent examples in tabs.

Keep a language's prose, headings, caveats and examples in an ownership region:

```markdown
<!-- port:go -->
Pass a context to each operation and check the returned error.
<!-- /port -->
```

Comma-separated port slugs select several languages. Regions can nest;
`port:root` marks framing that appears only on the shared page. Root builds
keep every region. The same selection applies to HTML, headings, search,
Markdown copies and LLM exports. A page's `supportedPorts` array restricts
shared prose to ports with verified coverage; native source guides supply
the other ports' documentation and task equivalents.

Keep equivalent examples together under one task heading, using the actual
language fence tags. The site groups alternative ports into tabs; a build
for one port filters out the others. Language-specific lead-ins should stay
with their example. Separate sequential examples and distinct tasks with
their own explanation rather than forcing them into an alternatives group.
language fence tags. Separate sequential examples and distinct tasks with
their own explanation. Check the selected page for empty sections and links
that accidentally leave its port or version.

`console`, JSON, and other shared fences survive port filtering. Check that
shared setup still makes sense in every port's rendered page.

Run the Go examples against isolated servers when changing their calls:

```console
$ python3 scripts/check-go-prose.py --checkout /path/to/libtmux-go
```

This checks eight examples covering transport, input, capture, options, hooks,
waiting, and cleanup at the integrated Go revision. It requires Go and tmux
on `PATH`; it is separate from the ordinary docs test loop.

For workspace command examples, build the native CLI from the source revision
linked by the page, then run:

```console
$ python3 scripts/check-workspace-prose.py \
--port go \
--binary /path/to/tmux-workspace
```

The runner executes the command, automation, export and troubleshooting examples,
then loads the configuration and gallery documents. It checks pane counts,
options, focus, directories, launch environment, overwrite refusal, editor errors,
invalid patterns and unsupported fields on a private tmux socket.
Node, tmux and the selected CLI's runtime must be on `PATH`. Set
`TMUX_WORKSPACE_PYTHON` to a compatible interpreter to include optional shell
inspection; its absence is reported as a skip. Use `--report` to save command
results and content hashes, and record the native build revision with that report.

## Content collections and MDX

[`site/src/content.config.ts`](site/src/content.config.ts) owns collection
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"packageManager": "pnpm@12.6.0",
"devDependencies": {
"@biomejs/biome": "catalog:",
"typescript": "catalog:",
"oxlint": "catalog:"
"happy-dom": "catalog:",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-to-markdown": "2.1.2",
"oxlint": "catalog:",
"typescript": "catalog:"
}
}
7 changes: 4 additions & 3 deletions packages/api-model/src/mentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,12 @@ const FENCE_PORT: Record<string, string> = {
}

/** Inline references in prose, including port sections, tables, and existing links. */
export function proseMentions(markdown: string, portByLabel: Record<string, string>): ProseMention[] {
export function proseMentions(markdown: string, portByLabel: Record<string, string>, portAt?: (offset: number) => string | undefined): ProseMention[] {
const out: ProseMention[] = []
const lines = markdown.split('\n')
const context: { port?: string; before: string }[] = []
const bodyLines = lines.map(() => '')
// Keep offsets stable when frontmatter, headings and examples are masked.
const bodyLines = lines.map((line) => ' '.repeat(line.length))
const sections: { depth: number; port?: string }[] = []
let fence: string | undefined
let fencePort: string | undefined
Expand Down Expand Up @@ -157,7 +158,7 @@ export function proseMentions(markdown: string, portByLabel: Record<string, stri
if (!ctx) continue
const before = ctx.before + beforeMatch.slice(beforeMatch.lastIndexOf('\n') + 1)
const linked = body[match.index - 1] === '[' && body.slice(match.index + match[0].length).startsWith('](')
out.push({ text, port: ctx.port, before, line, ...(linked ? { linked: true } : {}) })
out.push({ text, port: portAt?.(match.index) ?? ctx.port, before, line, ...(linked ? { linked: true } : {}) })
}
return out
}
15 changes: 11 additions & 4 deletions packages/api-model/src/prose.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ApiModel, ApiProduct, ApiSymbol } from './model.ts'
import type { Resolver } from './resolver.ts'
import { builtinHref } from './builtins.ts'

/**
* Whether a code span in prose is a reference to the API, and to what.
Expand Down Expand Up @@ -162,6 +163,7 @@ const NOT_API: { why: string; test: RegExp }[] = [
// Environment variables and tmux option names are SCREAMING_CASE and
// belong to tmux or the shell, not to a port.
{ why: 'environment variable', test: /^[A-Z][A-Z0-9_]{2,}$/ },
{ why: 'single-letter flag or type parameter', test: /^[A-Z]$/ },
// An expression, not a name: it carries arguments, a string, or a glob.
{ why: 'expression, not a symbol', test: /["']|\*|=>|\.\.\./ },
{ why: 'expression, not a symbol', test: /^\(|,\s/ },
Expand All @@ -175,7 +177,7 @@ const NOT_API: { why: string; test: RegExp }[] = [
{ why: 'method without a receiver', test: /^[.:]/ },
// Test and example fixtures live in files the extractors exclude, so they
// are real classes that are deliberately not public API.
{ why: 'test or example fixture', test: /(Tests?|TestCase|RunTest)$|^Test[A-Z]/ },
{ why: 'test or example fixture', test: /(Tests?|TestCase|RunTest)$|^Test[A-Z]|^Example(?:\(\))?$/ },
]

/** Why this span is not an API reference, or undefined if it might be. */
Expand Down Expand Up @@ -248,16 +250,21 @@ export function decideMention(

const named = ctx.before ? portFromSentence(ctx.before) : undefined
const tried: string[] = []
let ambiguous = false
for (const port of [named, ctx.pagePort]) {
if (!port || !models[port]) continue
const res = resolver.resolve(port, text, ctx.product)
ambiguous ||= res.how === 'ambiguous'
tried.push(`${port}:${res.how}`)
const hit = link(port, res)
if (hit) return hit
const builtin = builtinHref(port, text)
if (builtin) return { kind: 'link', port, href: builtin, title: `${text}: ${PORT_NAME[port]}`, external: true }
}
// Product pages have an authored port. Shared comparisons retain their
// cross-port fallback when a preceding fence only suggests a language.
if (ctx.product && tried.length) return { kind: 'unresolved', why: 'not defined in the stated port', tried }
// A known language must never resolve a similarly named API in another port.
if (tried.length) return ambiguous
? { kind: 'skip', why: 'ambiguous within the stated port; qualify the receiver to link it' }
: { kind: 'unresolved', why: 'not defined in the stated port', tried }

// Nothing said which language. One claimant is an answer; several are not.
const claims: { port: string; decision: MentionDecision }[] = []
Expand Down
5 changes: 5 additions & 0 deletions packages/api-model/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ export class Resolver {

const member = parts[parts.length - 1]
const candidates = Resolver.preferTypeOverConstructor(this.members(port, member))
if (parts.length > 2) {
const suffix = `.${parts.join('.')}`
const qualified = candidates.filter((row) => `.${toPath(row.qualified).join('.')}`.endsWith(suffix))
if (qualified.length === 1) return { how: 'scoped', symbol: qualified[0].symbol, port }
}
if (candidates.length === 1) return { how: 'unique', symbol: candidates[0].symbol, port }
let local = Resolver.preferProduct(candidates, product)
if (parts.length === 1) {
Expand Down
22 changes: 20 additions & 2 deletions packages/api-model/test/prose-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,26 @@ describe('product context in prose', () => {

it('does not borrow another language when the page already names its port', () => {
const other = { port: 'py', version: '0', symbols: [{ id: 'Other', name: 'Other', kind: 'class', signatures: [] }] } as ApiModel
const result = decideMention('Other', { pagePort: 'go', product: 'workspace' }, new Resolver([model, other]), { go: model, py: other })
expect(result.kind).toBe('unresolved')
for (const product of [undefined, 'workspace'] as const) {
const result = decideMention('Other', { pagePort: 'go', product }, new Resolver([model, other]), { go: model, py: other })
expect(result.kind).toBe('unresolved')
}
})

it('links standard-library types through the existing language catalog', () => {
const rust = { port: 'rs', version: '0', symbols: [] } as unknown as ApiModel
expect(decideMention('BTreeMap', { pagePort: 'rs' }, new Resolver([rust]), { rs: rust }))
.toMatchObject({ kind: 'link', port: 'rs', href: 'https://doc.rust-lang.org/std/collections/struct.BTreeMap.html', external: true })
})

it('uses the enclosing type to distinguish nested builders', () => {
const java = { port: 'java', version: '0', symbols: ['SessionSpec', 'WindowSpec'].map((name) => ({
id: `io.example.${name}.${name}.Builder.environment`, name: 'environment', kind: 'method', signatures: [],
})) } as unknown as ApiModel
const r = new Resolver([java])
const found = r.resolve('java', 'SessionSpec.Builder.environment(Map)')
expect('symbol' in found && found.symbol.id).toBe('io.example.SessionSpec.SessionSpec.Builder.environment')
expect(r.resolve('java', 'Builder.environment(Map)').how).toBe('ambiguous')
})

it('classifies MCP resource URIs and newly authored filenames explicitly', () => {
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

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

19 changes: 6 additions & 13 deletions scripts/build-site.sh
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,10 @@ if [ -n "${LIBTMUX_DOCS_SOURCE_SHA:-}" ] || [ -n "${LIBTMUX_DOCS_SOURCE_REF:-}"
[ "$source_resolved" = "$LIBTMUX_DOCS_SOURCE_SHA" ] || die "source ref resolves to $source_resolved, expected $LIBTMUX_DOCS_SOURCE_SHA"
fi

# Source-owned guides are ephemeral build input. Always clear the staging
# tree first so a guide removed on a branch cannot survive from an earlier
# build. Exact port callers must have their native artifact; a full local
# assembly stages both new ports only when both artifacts are present.
# Ordinary builds use committed, revision-bound guides. A selected source
# replaces only its port's guides after native artifact validation.
rm -rf "$site_dir/src/content/docs/_staged"
node "$script_dir/stage-port-docs.mjs" --cached
if [ -n "${LIBTMUX_DOCS_PORT:-}" ] && { [ "$LIBTMUX_DOCS_PORT" = ruby ] || [ "$LIBTMUX_DOCS_PORT" = lua ]; }; then
node "$script_dir/gen-api-model.mjs" --port "$LIBTMUX_DOCS_PORT"
node "$script_dir/stage-port-docs.mjs" --port "$LIBTMUX_DOCS_PORT"
Expand All @@ -314,15 +313,7 @@ elif [ -n "${LIBTMUX_DOCS_SOURCE_SHA:-}" ]; then
# last refreshed. The generator re-checks the SHA and records it.
node "$script_dir/gen-api-model.mjs" --port "$LIBTMUX_DOCS_PORT"
node "$script_dir/gen-api-model.mjs" --port "$LIBTMUX_DOCS_PORT" --nav
elif [ -f "${LIBTMUX_DOCS_CHECKOUT_RUBY:-$HOME/work/libtmux/libtmux-ruby-docs}/docs/_build/api.json" ] &&
[ -f "${LIBTMUX_DOCS_CHECKOUT_LUA:-$HOME/work/libtmux/libtmux-lua-docs}/docs/_build/api.json" ]; then
node "$script_dir/gen-api-model.mjs" --port ruby
node "$script_dir/gen-api-model.mjs" --port lua
node "$script_dir/stage-port-docs.mjs"
elif [ -n "${LIBTMUX_DOCS_CHECKOUT_RUBY:-}" ] && [ -n "${LIBTMUX_DOCS_CHECKOUT_LUA:-}" ]; then
node "$script_dir/stage-port-docs.mjs" --from-source
fi
node "$script_dir/stage-port-docs.mjs" --wrappers
node "$script_dir/gen-example-sources.mjs"
node "$script_dir/gen-mentions.mjs"

Expand Down Expand Up @@ -1076,7 +1067,9 @@ while IFS='|' read -r slug name versioned renderer generator checkout ecosystem_
if [ "$ref_status" = "built" ]; then
mkdir -p "$port_out/api"
cp -a "$ref_outdir/." "$port_out/api/"
node "$script_dir/normalize-native-shell.mjs" "$port_out/api" "$LIBTMUX_DOCS_PORT_ROOT"
native_shell_args=()
if [ "$renderer" = sphinx ]; then native_shell_args+=("$slug" "$version"); fi
node "$script_dir/normalize-native-shell.mjs" "$port_out/api" "$LIBTMUX_DOCS_PORT_ROOT" "${native_shell_args[@]}"
node "$script_dir/brand-native-pages.mjs" "$port_out/api" "$slug" "$LIBTMUX_DOCS_PORT_ROOT"
elif [ "$ref_status" = "skipped" ]; then
mkdir -p "$port_out/api"
Expand Down
18 changes: 8 additions & 10 deletions scripts/check-api-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { Resolver, decideFilePath, decideMention, isLikelyReference, looksLikeAp
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const { API_MODEL_PORTS: PORT_DEFS, PORT_BY_SLUG } = await import(`file://${resolve(root, 'site/src/lib/ports.ts')}`)
const PORTS = PORT_DEFS.map((p) => p.slug)
const { KNOWN_PORTS, resolvePortBody } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`)
const { KNOWN_PORTS, resolvePortBody, resolvePortContent } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`)
const SHARED = join(root, 'site/src/content/_workspace-shared')
const CONTENT = join(root, 'site/src/content/docs')
const started = Date.now()
Expand Down Expand Up @@ -157,9 +157,10 @@ for (const file of targets) {
if (PORT_BY_SLUG[authoredPort]?.referenceKind === 'guide') continue
const product = /^product:\s*['"]?(core|workspace|mcp)['"]?\s*$/m.exec(frontmatter)?.[1]
?? /^ports\/[^/]+\/(workspace|mcp)\//.exec(file.replace(`${CONTENT}/`, ''))?.[1]
for (const { text, port: ctxPort, before, line, linked } of proseMentions(raw, PORT_BY_LABEL)) {
const selected = resolvePortContent(raw, authoredPort)
for (const { text, port: ctxPort, before, line, linked } of proseMentions(selected.body, PORT_BY_LABEL, selected.portAt)) {
if (linked) { tally.alreadyLinked++; continue }
const pagePort = ctxPort ?? authoredPort
const pagePort = authoredPort ?? ctxPort

if (FILE_RE.test(text) || text.endsWith('/')) {
const d = decideFilePath(text, { before, pagePort }, trees)
Expand All @@ -175,17 +176,14 @@ for (const file of targets) {
if (notASymbol(text) || !looksLikeApiMention(text)) { tally.notASymbol++; continue }


const linkable = (authoredPort ? [pagePort] : [ctxPort, ...PORTS]).some((port) => {
if (!port) return false
const decision = decideMention(text, { pagePort: port, product, before }, resolver, models)
return decision.kind === 'link'
})
const decisions = (pagePort ? [pagePort] : PORTS)
.map((port) => decideMention(text, { pagePort: port, product, before }, resolver, models))
// `notApiReason` gates *reporting*, not linking — exactly as the plugin
// does. A span it names still gets offered to the resolver, because a
// `TMUX_TMPDIR` that happens to resolve is a link worth having; it simply
// is not a dangling reference when it does not.
if (linkable) tally.willLink++
else if (!isLikelyReference(text) || notApiReason(text) || EXCEPTIONS.has(text)) tally.notASymbol++
if (decisions.some((decision) => decision.kind === 'link')) tally.willLink++
else if (decisions.some((decision) => decision.kind === 'skip') || !isLikelyReference(text) || notApiReason(text) || EXCEPTIONS.has(text)) tally.notASymbol++
else { tally.unresolved++; unresolved.push({ file, line, text }) }
}
}
Expand Down
Loading
Loading