diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dc1b8fd..ddea6de0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/WRITING.md b/WRITING.md index e996e0e6..576a1ebb 100644 --- a/WRITING.md +++ b/WRITING.md @@ -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 @@ -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 + +Pass a context to each operation and check the returned error. + +``` + +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 diff --git a/package.json b/package.json index 6a9f41a8..3f4aa00e 100644 --- a/package.json +++ b/package.json @@ -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:" } } diff --git a/packages/api-model/src/mentions.ts b/packages/api-model/src/mentions.ts index 5ba892de..c8393b90 100644 --- a/packages/api-model/src/mentions.ts +++ b/packages/api-model/src/mentions.ts @@ -101,11 +101,12 @@ const FENCE_PORT: Record = { } /** Inline references in prose, including port sections, tables, and existing links. */ -export function proseMentions(markdown: string, portByLabel: Record): ProseMention[] { +export function proseMentions(markdown: string, portByLabel: Record, 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 @@ -157,7 +158,7 @@ export function proseMentions(markdown: string, portByLabel: Record|\.\.\./ }, { why: 'expression, not a symbol', test: /^\(|,\s/ }, @@ -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. */ @@ -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 }[] = [] diff --git a/packages/api-model/src/resolver.ts b/packages/api-model/src/resolver.ts index 95d63849..b6f4d6fc 100644 --- a/packages/api-model/src/resolver.ts +++ b/packages/api-model/src/resolver.ts @@ -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) { diff --git a/packages/api-model/test/prose-audit.test.ts b/packages/api-model/test/prose-audit.test.ts index 710ad642..a8da1a02 100644 --- a/packages/api-model/test/prose-audit.test.ts +++ b/packages/api-model/test/prose-audit.test.ts @@ -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', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b912787d..8a8b696c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,15 @@ importers: '@biomejs/biome': specifier: 'catalog:' version: 2.5.14 + happy-dom: + specifier: 'catalog:' + version: 20.14.5 + mdast-util-from-markdown: + specifier: 2.0.3 + version: 2.0.3 + mdast-util-to-markdown: + specifier: 2.1.2 + version: 2.1.2 oxlint: specifier: 'catalog:' version: 1.85.0(oxlint-tsgolint@7.0.2002) diff --git a/scripts/build-site.sh b/scripts/build-site.sh index b1f08c75..0544beff 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -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" @@ -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" @@ -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" diff --git a/scripts/check-api-links.mjs b/scripts/check-api-links.mjs index 76e02953..f7ab5f3f 100755 --- a/scripts/check-api-links.mjs +++ b/scripts/check-api-links.mjs @@ -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() @@ -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) @@ -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 }) } } } diff --git a/scripts/check-go-prose.py b/scripts/check-go-prose.py new file mode 100644 index 00000000..abbc3af1 --- /dev/null +++ b/scripts/check-go-prose.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Compile and run eight shared Go examples on isolated tmux servers. + +Usage: python3 scripts/check-go-prose.py --checkout PATH [--ref REF] +The default ref is the source revision in the integrated Go API model. +This is an optional native verification gate; it needs Go and tmux on PATH. +""" +from pathlib import Path +import argparse +import json +import os +import re +import subprocess +import tempfile + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--checkout', type=Path, required=True) +parser.add_argument('--ref') +args = parser.parse_args() +repo = Path(__file__).resolve().parent.parent +model = json.loads((repo / 'site/src/data/api/go.json').read_text()) +revision = subprocess.check_output( + ['git', '-C', str(args.checkout), 'rev-parse', (args.ref or model['revision']) + '^{commit}'], text=True +).strip() +root = repo / 'site/src/content/docs/topics' + +def verify(root, out, source, revision): + go_version = re.search(r'^go (.+)$', (source / 'go.mod').read_text(), re.M).group(1) + (out / 'go.mod').write_text( + f'module docs-check\n\ngo {go_version}\n\n' + 'require github.com/libtmux/libtmux-go v0.0.0\n\n' + f'replace github.com/libtmux/libtmux-go => {source.resolve()}\n' + ) + parts=['''package docscheck + import ( + "context" + "errors" + "fmt" + "testing" + "os" + "time" + "github.com/libtmux/libtmux-go/tmux" + "github.com/libtmux/libtmux-go/tmux/tmuxtest" + ) + '''] + givens={'pane-interaction':['pane tmux.Pane','pane tmux.Pane'], 'options-and-hooks':['window tmux.Window','session tmux.Session'], 'waiting-and-retry':['session tmux.Session','server tmux.Server']} + for name in [*givens, 'context-managers']: + matches = list(re.finditer(r'^```go[^\n]*\n(.*?)^```', (root/(name+'.md')).read_text(), re.M|re.S)) + assert len(matches) == (1 if name == 'context-managers' else 2), f'Update verification for changed {name} examples' + for index, match in enumerate(matches): + code=match[1] + if name=='context-managers':parts.append(code);continue + fn=name.replace('-','')+str(index) + parts.append('func '+fn+'(ctx context.Context, '+givens[name][index]+') error {\n'+code+'\nreturn nil\n}\n') + transports = list(re.finditer(r'^```go[^\n]*\n(.*?)^```', + (root.parent/'concepts/transports.md').read_text(), re.M|re.S)) + assert len(transports) == 1, 'Update verification for changed transport examples' + parts.append('func transportExample() error {\n'+transports[0][1]+'\n}\n') + parts.append(''' + func TestMain(m *testing.M) { os.Exit(tmuxtest.Main(m)) } + + func TestTransportExample(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + server := tmuxtest.NewServerWithOptions(ctx, t, tmuxtest.ServerOptions{FixedShell: true}) + t.Setenv("TMUX", server.SocketPath()+",0,0") + if err := transportExample(); err != nil { t.Fatal(err) } + filter := tmux.TmuxFilter("#{==:#{session_name},work}") + sessions, err := server.SearchSessions(ctx, &filter) + if err != nil || len(sessions) != 1 { t.Fatalf("created session: %v, %v", sessions, err) } + panes, err := sessions[0].SearchPanes(ctx, nil) + if err != nil || len(panes) != 1 { t.Fatalf("created pane: %v, %v", panes, err) } + tmuxtest.WaitForLine(ctx, t, panes[0], "hello") + } + + func TestPublishedExamples(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + server := tmuxtest.NewServerWithOptions(ctx, t, tmuxtest.ServerOptions{Config: []byte("set-window-option -g automatic-rename off\\n"), FixedShell: true}) + session := tmuxtest.NewSession(ctx, t, server, tmux.NewSessionRequest{}) + name := "build" + window := tmuxtest.NewWindow(ctx, t, session, tmux.NewWindowRequest{Name: &name}) + panes, err := window.SearchPanes(ctx, nil) + if err != nil || len(panes) != 1 { t.Fatalf("setup panes: %v, %v", panes, err) } + pane := panes[0] + calls := []struct { name string; run func() error }{ + {"send", func() error { return paneinteraction0(ctx, pane) }}, + {"capture", func() error { return paneinteraction1(ctx, pane) }}, + {"options", func() error { return optionsandhooks0(ctx, window) }}, + {"hooks", func() error { return optionsandhooks1(ctx, session) }}, + {"poll", func() error { return waitingandretry0(ctx, session) }}, + {"channel", func() error { return waitingandretry1(ctx, server) }}, + {"cleanup", func() error { return temporarySession(ctx, server) }}, + } + for _, call := range calls {t.Run(call.name, func(t *testing.T) { + if err := call.run(); err != nil { t.Fatal(err) } + })} + if err := tmuxtest.WaitFor(ctx, 10*time.Millisecond, func(ctx context.Context) (bool, error) { + lines, err := pane.Capture(ctx, tmux.CapturePaneRequest{}) + for _, line := range lines { if line == "hello" { return true, err } } + return false, err + }); err != nil { t.Fatalf("literal input did not execute: %v", err) } + if err := paneinteraction0(ctx, tmux.Pane{}); err == nil { t.Fatal("invalid pane error was swallowed") } + expired, stop := context.WithCancel(ctx); stop() + if err := waitingandretry0(expired, session); !errors.Is(err, context.Canceled) {t.Fatalf("cancellation lost: %v", err)} + sessions, err := server.Sessions(ctx) + if err != nil || len(sessions) != 1 || sessions[0].ID() != session.ID() {t.Fatalf("owned cleanup changed sessions: %v, %v", sessions, err)} + } + ''') + (out/'examples_test.go').write_text('\n'.join(parts)) + + env = {key: value for key, value in os.environ.items() if key not in ['TMUX', 'TMUX_PANE', 'GOWORK']} + env['GOWORK'] = 'off' + env['TMUX_TMPDIR'] = str(out) + print(f'Go prose: {revision}; eight examples from five pages', flush=True) + subprocess.run(['go', 'test', '-count=1', '-v', '.'], cwd=out, env=env, check=True) + +with tempfile.TemporaryDirectory(prefix='libtmux-go-prose-') as temporary: + out = Path(temporary) + source = out / 'source' + source.mkdir() + archive = subprocess.check_output(['git', '-C', str(args.checkout), 'archive', revision]) + subprocess.run(['tar', '-xf', '-', '-C', str(source)], input=archive, check=True) + verify(root, out, source, revision) diff --git a/scripts/check-workspace-prose.py b/scripts/check-workspace-prose.py new file mode 100644 index 00000000..3276e762 --- /dev/null +++ b/scripts/check-workspace-prose.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Run native workspace CLI examples and failure checks on a private tmux socket. + +Build the CLI from the source revision cited by the pages first. This runner +checks the supplied executable; it does not establish its build provenance. +Usage: python3 scripts/check-workspace-prose.py --port go --binary /path/to/tmux-workspace +Node, tmux and the CLI runtime must be on PATH. --report saves the run results. +""" + +import argparse +import hashlib +import json +import os +import pathlib +import re +import shutil +import subprocess +import tempfile +import time + +ROOT = pathlib.Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument( + "--port", + choices=["ts", "rs", "go", "java", "dotnet", "cxx", "swift"], + required=True, +) +parser.add_argument("--binary", required=True, type=pathlib.Path) +parser.add_argument("--report", type=pathlib.Path) +args = parser.parse_args() +BINARY = args.binary.resolve() +NODE = shutil.which("node") or parser.error("node is required") +TMUX = shutil.which("tmux") or parser.error("tmux is required") +PAGES = [ + "cli/" + name + for name in [ + "index", + "convert", + "edit", + "freeze", + "ls", + "debug-info", + "search", + "load", + "import", + "import-teamocil", + "import-tmuxinator", + "completion", + ] +] +if os.environ.get("TMUX_WORKSPACE_PYTHON"): + PAGES.append("cli/shell") +CONFIGURATIONS = [ + "index", + "session", + "windows", + "panes", + "commands", + "directories", + "environment", + "layouts", + "hooks", +] +PAGES.extend("configuration/" + name for name in CONFIGURATIONS) +PAGES.extend( + [ + "guides/discovery", + "guides/automation", + "guides/export-session", + "guides/troubleshooting", + "reference/output", + "examples/gallery", + ] +) +extract = """import {readFileSync} from 'node:fs'; +import {resolvePortBody} from './site/src/lib/workspace-shared-slots.ts'; +const pages=JSON.parse(process.argv[1]); +console.log(JSON.stringify(Object.fromEntries(pages.map(name=>[name,resolvePortBody(readFileSync('site/src/content/_workspace-shared/workspace/'+name+'.md','utf8'),process.argv[2])])))); +""" +bodies = json.loads( + subprocess.check_output( + [NODE, "--input-type=module", "-e", extract, json.dumps(PAGES), args.port], + cwd=ROOT, + text=True, + ) +) +evidence = { + "port": args.port, + "binary": str(BINARY), + "entrypoint_sha256": hashlib.sha256(BINARY.read_bytes()).hexdigest(), + "pages": { + name: hashlib.sha256(body.encode()).hexdigest() for name, body in bodies.items() + }, + "positive": [], + "negative": [], + "skipped": [] + if "cli/shell" in PAGES + else ["shell: set TMUX_WORKSPACE_PYTHON to a compatible runtime"], +} +# Unix sockets require the Linux filesystem when running under WSL. +fixture_root = ( + pathlib.Path(tempfile.gettempdir()) + if args.port == "ts" + else pathlib.Path(f"/tmp/libtmux-{args.port}-test") +) +fixture_root.mkdir(exist_ok=True) +with tempfile.TemporaryDirectory( + prefix="ltx-doc-" if args.port == "ts" else "docs-", dir=fixture_root +) as directory: + here = pathlib.Path(directory) + socket = here / "tmux.sock" + bindir = here / "bin" + bindir.mkdir() + (bindir / "tmux-workspace").symlink_to(BINARY) + editor = bindir / "vi" + editor.write_text( + '#!/bin/sh\n[ -f "$1" ] || exit 42\nprintf "%s" "$1" > "$WORKSPACE_TMP/editor-argument"\n' + ) + editor.chmod(0o755) + configs = here / "config" + configs.mkdir() + doc = "session_name: workspace-guide\nwindows:\n - window_name: editor\n layout: even-horizontal\n panes:\n - printf ready\n - printf second\n" + (here / "workspace.yaml").write_text(doc) + (configs / "workspace.yaml").write_text(doc) + for body in bodies.values(): + for match in re.finditer( + r'```(?:yaml|json) title="([\w.-]+)"\n(.*?)\n```', body, re.S + ): + (here / match[1]).write_text(match[2] + "\n") + env = dict( + os.environ, + PATH=f"{bindir}{os.pathsep}{os.environ.get('PATH', '')}", + WORKSPACE_TMP=str(here), + TMUXP_CONFIGDIR=str(configs), + XDG_CONFIG_HOME=str(configs), + TMUX_TMPDIR=str(here), + SHELL="/bin/sh", + ENV="/dev/null", + BASH_ENV="/dev/null", + ZDOTDIR=str(here), + ) + env.pop("TMUX", None) + env.pop("TMUX_PANE", None) + env.pop("VISUAL", None) + for name in ["DOC_SESSION", "DOC_WINDOW", "DOC_PANE"]: + env.pop(name, None) + + def run(command, success=True): + result = subprocess.run( + command, + cwd=here, + env=env, + text=True, + capture_output=True, + timeout=30, + shell=isinstance(command, str), + ) + if success and result.returncode: + raise AssertionError((command, result.returncode, result.stderr[-1500:])) + if not success and not result.returncode: + raise AssertionError(("expected failure", command)) + return result + + try: + run( + [ + str(BINARY), + "load", + "-S", + str(socket), + "-f", + "/dev/null", + "-d", + "--json", + "workspace.yaml", + ] + ) + for name, body in bodies.items(): + for match in re.finditer(r"```console\n(.*?)\n```", body, re.S): + command = match[1].removeprefix("$ ") + result = run(command) + if "--json" in command: + json.loads(result.stdout) + elif "--ndjson" in command: + for line in result.stdout.splitlines(): + json.loads(line) + evidence["positive"].append( + {"page": name, "command": command, "exit": result.returncode} + ) + for name in CONFIGURATIONS: + body = bodies["configuration/" + name] + document = re.search(r'```yaml title="([\w.-]+)"\n(.*?)\n```', body, re.S) + assert document, name + result = run( + [str(BINARY), "load", "-S", str(socket), "-d", "--json", document[1]] + ) + json.loads(result.stdout) + evidence["positive"].append( + { + "page": "configuration/" + name, + "document": document[1], + "exit": result.returncode, + } + ) + + for document in ["gallery-blank.yaml", "gallery-commands.yaml"]: + result = run( + [str(BINARY), "load", "-S", str(socket), "-d", "--json", document] + ) + json.loads(result.stdout) + evidence["positive"].append( + { + "page": "examples/gallery", + "document": document, + "exit": result.returncode, + } + ) + + def tmux(*command): + return run([TMUX, "-S", str(socket), *command]).stdout.strip() + + expected_panes = { + "configuration-example": 2, + "session-example": 1, + "windows-example": 2, + "panes-example": 2, + "commands-example": 1, + "directories-example": 1, + "environment-example": 2, + "layouts-example": 3, + "hooks-example": 1, + } + for session, count in expected_panes.items(): + panes = tmux( + "list-panes", "-s", "-t", "=" + session, "-F", "#{pane_id}" + ).splitlines() + assert len(panes) == count, (session, panes) + session_ids = dict( + line.split("\t") + for line in tmux( + "list-sessions", "-F", "#{session_name}\t#{session_id}" + ).splitlines() + ) + assert ( + tmux("show-options", "-v", "-t", session_ids["session-example"], "status") + == "off" + ) + assert ( + tmux( + "show-window-options", + "-v", + "-t", + tmux( + "list-windows", + "-t", + session_ids["windows-example"], + "-F", + "#{window_id}", + ), + "synchronize-panes", + ) + == "on" + ) + assert ( + tmux("list-windows", "-t", "=windows-example", "-F", "#{window_index}") + == "2" + ) + assert tmux( + "list-panes", "-t", "=panes-example:work", "-F", "#{pane_active}" + ).splitlines() == ["0", "1"] + assert tmux( + "display-message", + "-p", + "-t", + "=directories-example:shell", + "#{pane_current_path}", + ) == str(here) + environment_panes = tmux( + "list-panes", "-t", "=environment-example:shell", "-F", "#{pane_id}" + ).splitlines() + + def await_text(pane, expected): + deadline = time.monotonic() + 3 + while True: + output = tmux("capture-pane", "-p", "-t", pane) + if expected in output: + return output + if time.monotonic() >= deadline: + raise AssertionError((args.port, pane, expected, output)) + time.sleep(0.02) + + for pane, expected in zip( + environment_panes, ["ENV=session||pane", "ENV=session|window|"], strict=True + ): + await_text(pane, expected) + synchronized = tmux( + "list-panes", "-t", "=windows-example:tools", "-F", "#{pane_id}" + ).splitlines() + assert "right" not in await_text(synchronized[0], "left") + assert "left" not in await_text(synchronized[1], "right") + evidence["configuration_checks"] = [ + "pane counts", + "session options", + "option timing", + "window index", + "pane focus", + "working directory", + "launch environment", + ] + assert (here / "workspace.json").is_file() + assert (here / "captured-workspace.yaml").is_file() + assert (here / "editor-argument").read_text().endswith("workspace.yaml") + before = (here / "workspace.json").read_bytes() + failed = run( + [ + str(BINARY), + "convert", + "--json", + "--workspace-format", + "json", + "--save-to", + "workspace.json", + "workspace.yaml", + ], + False, + ) + assert before == (here / "workspace.json").read_bytes() + evidence["negative"].append( + {"case": "existing destination preserved", "exit": failed.returncode} + ) + editor.write_text("#!/bin/sh\nexit 17\n") + failed = run("EDITOR=vi tmux-workspace edit workspace.yaml", False) + assert failed.returncode == 17 + evidence["negative"].append( + {"case": "editor status propagated", "exit": failed.returncode} + ) + failed = run([str(BINARY), "search", "--json", "["], False) + evidence["negative"].append( + {"case": "invalid regex fails", "exit": failed.returncode} + ) + (here / "invalid.yaml").write_text( + "session_name: invalid\nbogus: true\nwindows: [{window_name: shell, panes: [null]}]\n" + ) + failed = run( + [str(BINARY), "load", "-S", str(socket), "-d", "--json", "invalid.yaml"], + False, + ) + evidence["negative"].append( + {"case": "invalid workspace fails", "exit": failed.returncode} + ) + finally: + if socket.exists(): + subprocess.run( + [TMUX, "-S", str(socket), "kill-server"], + capture_output=True, + check=True, + ) +if args.report: + args.report.write_text(json.dumps(evidence, indent=2) + "\n") +print( + f"PASS: {len(evidence['positive'])} documented commands, {len(evidence['negative'])} failure checks for {args.port}" +) +for skipped in evidence["skipped"]: + print(f"SKIPPED: {skipped}") diff --git a/scripts/gen-example-sources.mjs b/scripts/gen-example-sources.mjs index 26e7940e..0482b9a7 100755 --- a/scripts/gen-example-sources.mjs +++ b/scripts/gen-example-sources.mjs @@ -1,35 +1,9 @@ #!/usr/bin/env node -/** - * Cache the example sources that prose inlines, so a build needs no port - * checkouts. - * - * `remark-port-code.mjs` turns a fence like - * - * ```typescript file="examples/quickstart/quickstart.ts" - * - * into the real contents of that file, read out of the port's own checkout. - * That is the point: the code on the page is the code the port tests, and a - * missing source fails the build rather than shipping an empty fence. - * - * It also means the build cannot run anywhere the eight checkouts are absent, - * which is every CI runner. This writes what those fences resolve to into a - * committed file, exactly as `gen-api-model.mjs` does for the extracted - * models and for the same reason — CI builds from committed data, and - * `--check` is what stops that data rotting, since nothing else compares it - * against the source it came from. - * - * Whole files are cached, not the sliced regions: `sliceRegion` stays the one - * implementation, so a cached read and a live read cannot disagree about what - * a region means. - * - * Usage: - * node scripts/gen-example-sources.mjs # rewrite the cache - * node scripts/gen-example-sources.mjs --check # fail if it is stale - * node scripts/gen-example-sources.mjs --out PATH # a fixture, for the - * # negative test - */ +/** Cache example files at the exact revision recorded by each port's documentation. */ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -76,45 +50,43 @@ for (const md of markdownFiles(CONTENT)) { } } -const cache = {} -const missing = [] -for (const [key, { owner, file }] of [...wanted].sort((a, b) => a[0].localeCompare(b[0]))) { - const checkout = checkoutFor(owner) - const abs = join(checkout, file) - if (!existsSync(abs)) { - missing.push(`${key} (looked in ${checkout})`) - continue - } - cache[key] = readFileSync(abs, 'utf8') -} +const digest = (content) => createHash('sha256').update(content).digest('hex') -const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '' - -/* - * A checkout that is not here cannot be read, and its cached entry cannot be - * confirmed either way. Reported rather than treated as agreement: silence - * would read as "verified" on a machine that verified nothing. - */ -if (missing.length) { - console.error(`gen-example-sources: ${missing.length} source(s) unreadable — checkout absent:`) - for (const m of missing) console.error(` ${m}`) - const kept = missing.filter((m) => Object.hasOwn(JSON.parse(current || '{}'), m.split(' ')[0])) - if (kept.length) { - console.error(` ${kept.length} of these are in the cache already; keeping the cached copy.`) - for (const m of kept) cache[m.split(' ')[0]] = JSON.parse(current)[m.split(' ')[0]] +/** Read committed bytes; a working tree can be dirty or on a different branch. */ +export function cachedExample({ repository, revision, file, checkout, current }) { + if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error(`Invalid example revision: ${revision}`) + if (existsSync(join(checkout, '.git'))) { + const content = execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], + { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }) + return { repository, revision, sha256: digest(content), content } + } + if (current?.repository !== repository || current?.revision !== revision || + typeof current.content !== 'string' || current.sha256 !== digest(current.content)) { + throw new Error(`${repository}:${file}: no verified cache for ${revision}; provide its checkout and regenerate example sources`) } + return current } -const merged = `${JSON.stringify(Object.fromEntries(Object.entries(cache).sort()), null, 2)}\n` - -if (check) { - if (merged !== current) { - console.error(`\ngen-example-sources: ${OUT.replace(`${root}/`, '')} is stale. Rerun:`) - console.error(' node scripts/gen-example-sources.mjs') - process.exit(1) +export function run() { + const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '' + const previous = JSON.parse(current || '{}') + const cache = {} + const offline = new Set() + for (const [key, { owner, file }] of [...wanted].sort((a, b) => a[0].localeCompare(b[0]))) { + const modelPath = join(root, `site/src/data/api/${owner}.json`) + const provenance = existsSync(modelPath) ? JSON.parse(readFileSync(modelPath, 'utf8')) + : JSON.parse(readFileSync(join(root, `site/src/data/port-guides/${owner}.json`), 'utf8')).source + const repository = provenance.repository ?? provenance.repo + const revision = provenance.revision + const checkout = checkoutFor(owner) + if (!existsSync(join(checkout, '.git'))) offline.add(owner) + cache[key] = cachedExample({ repository, revision, file, checkout, current: previous[key] }) } - console.log(`gen-example-sources: cache matches ${Object.keys(cache).length} example source(s)`) -} else { - writeFileSync(OUT, merged) - console.log(`gen-example-sources: wrote ${Object.keys(cache).length} example source(s) to ${OUT.replace(`${root}/`, '')}`) + const merged = `${JSON.stringify(cache, null, 2)}\n` + if (check && merged !== current) throw new Error(`${OUT}: stale example sources; run node scripts/gen-example-sources.mjs`) + if (!check) writeFileSync(OUT, merged) + console.log(`gen-example-sources: ${check ? 'cache matches' : 'wrote'} ${Object.keys(cache).length} revision-bound example sources`) + if (offline.size) console.log(`gen-example-sources: cached sources for ${[...offline].join(', ')}; checksums and revisions checked, source checkouts unavailable`) } + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) run() diff --git a/scripts/gen-example-sources.negative.mjs b/scripts/gen-example-sources.negative.mjs index ac9f66b3..971155a2 100755 --- a/scripts/gen-example-sources.negative.mjs +++ b/scripts/gen-example-sources.negative.mjs @@ -1,98 +1,38 @@ #!/usr/bin/env node -/* - * Proof that `gen-example-sources.mjs --check` fails on a stale cache. - * - * The control is a freshly generated fixture, because a check that always - * failed would satisfy the drift case on its own. - * - * Two ways to be stale are tested, because they are different failures. An - * edited source is the everyday one: a port changes the example it tests and - * the committed copy still shows the old code, which is the drift the cache - * exists to make visible rather than to hide. A dropped entry is the one that - * would break a build rather than mislead a reader — `readFence` throws when - * a source is in neither the checkout nor the cache. - */ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +/** Exercise revision binding and deliberately damaged example caches. */ +import assert from 'node:assert/strict' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' +import { join } from 'node:path' import { execFileSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' -import { CHECKOUTS } from '../site/src/plugins/remark-port-code.mjs' -import sources from '../site/src/data/example-sources.json' with { type: 'json' } - -const script = join(dirname(fileURLToPath(import.meta.url)), 'gen-example-sources.mjs') - -function run(args) { - try { - return { code: 0, out: execFileSync('node', [script, ...args], { encoding: 'utf8', stdio: 'pipe', env }) } - } catch (err) { - return { code: err.status, out: `${err.stdout ?? ''}${err.stderr ?? ''}` } - } -} - -const dir = mkdtempSync(join(tmpdir(), 'gen-example-sources-')) -const fixture = join(dir, 'cache.json') -const env = { ...process.env } -for (const port of Object.keys(CHECKOUTS)) env[`LIBTMUX_DOCS_CHECKOUT_${port.toUpperCase()}`] = join(dir, port) -const sample = Object.keys(sources).sort()[0] -const sampleText = '// isolated example source\n' -for (const [key, text] of Object.entries(sources)) { - const [port, file] = key.split(':') - const path = join(dir, port, file) - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, key === sample ? sampleText : text) -} -process.on('exit', () => rmSync(dir, { recursive: true, force: true })) - -let failures = 0 -const check = (name, ok, detail) => { - if (ok) console.log(`ok ${name}`) - else { - console.error(`FAIL ${name} — ${detail}`) - failures += 1 - } -} - -const generated = run(['--out', fixture]) -if (generated.code !== 0) { - console.error(`FAIL could not generate a control fixture — ${generated.out}`) - process.exit(1) -} -const pristine = readFileSync(fixture, 'utf8') -if (JSON.parse(pristine)[sample] !== sampleText) throw new Error('Generator ignored the fixture checkout') - -{ - const res = run(['--check', '--out', fixture]) - check('a freshly generated cache passes', res.code === 0 && /matches/.test(res.out), `exit ${res.code}: ${res.out.trim()}`) -} - -const mutations = [ - [ - 'an edited source is caught', - (d) => { - const k = Object.keys(d).sort()[0] - return { ...d, [k]: `${d[k]}\n// drifted\n` } - }, - ], - [ - 'a dropped entry is caught', - (d) => { - const { [Object.keys(d).sort()[0]]: _gone, ...rest } = d - return rest - }, - ], -] - -for (const [name, mutate] of mutations) { - const before = JSON.parse(pristine) - const after = mutate(before) - writeFileSync(fixture, `${JSON.stringify(after, null, 2)}\n`) - const res = run(['--check', '--out', fixture]) - check(name, res.code === 1 && /stale/.test(res.out), `exit ${res.code}: ${res.out.trim()}`) -} - -if (failures) { - console.error(`\ngen-example-sources.negative: ${failures} case(s) did not behave as required.`) - process.exit(1) +import { cachedExample } from './gen-example-sources.mjs' + +const scratch = mkdtempSync(join(tmpdir(), 'libtmux-example-cache-')) +const git = (...args) => execFileSync('git', ['-C', scratch, ...args], { encoding: 'utf8', stdio: 'pipe' }).trim() +try { + git('init', '--quiet') + writeFileSync(join(scratch, 'example.go'), '// committed example\n') + git('add', 'example.go') + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'Example') + const revision = git('rev-parse', 'HEAD') + const request = { repository: 'fixture/example', revision, file: 'example.go', checkout: scratch } + const initial = cachedExample(request) + assert.equal(initial.content, '// committed example\n') + writeFileSync(join(scratch, 'example.go'), '// uncommitted replacement\n') + assert.deepEqual(cachedExample(request), initial) + git('add', 'example.go') + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'Later revision') + assert.deepEqual(cachedExample(request), initial) + console.log('ok examples ignore a dirty working tree and a later HEAD') + + const offline = { ...request, checkout: join(scratch, 'absent'), current: initial } + assert.deepEqual(cachedExample(offline), initial) + assert.throws(() => cachedExample({ ...offline, current: { ...initial, content: 'damaged' } }), /no verified cache/) + assert.throws(() => cachedExample({ ...offline, revision: git('rev-parse', 'HEAD') }), /no verified cache/) + assert.throws(() => cachedExample({ ...offline, repository: 'another/repo' }), /no verified cache/) + assert.throws(() => cachedExample({ ...offline, current: undefined }), /no verified cache/) + assert.throws(() => cachedExample({ ...request, file: 'missing.go' })) + console.log('ok missing files, wrong revisions, wrong repositories and damaged bytes fail') +} finally { + rmSync(scratch, { recursive: true, force: true }) } -console.log('gen-example-sources.negative: the staleness check can fail, and passes when current') diff --git a/scripts/gen-mentions.mjs b/scripts/gen-mentions.mjs index e7043f09..185af8e9 100755 --- a/scripts/gen-mentions.mjs +++ b/scripts/gen-mentions.mjs @@ -14,7 +14,7 @@ const check = process.argv.includes('--check') const { PORTS: PORT_DEFS } = 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')}`) /** The first column's label, as the prose writes it. */ const PORT_BY_LABEL = { @@ -159,8 +159,9 @@ for (const { file, source } of [...realEntries, ...sharedWorkspaceEntries(realFi if (PORT_DEFS.find((port) => port.slug === authoredPort)?.referenceKind === 'guide') continue const product = frontmatterValue(source, 'product') ?? /^ports\/[^/]+\/(workspace|mcp)\//.exec(file)?.[1] - for (const { port: contextPort, text, line, before, linked } of proseMentions(source, PORT_BY_LABEL)) { - const pagePort = contextPort ?? authoredPort + const selected = resolvePortContent(source, authoredPort) + for (const { port: contextPort, text, line, before, linked } of proseMentions(selected.body, PORT_BY_LABEL, selected.portAt)) { + const pagePort = authoredPort ?? contextPort if (notASymbol(text)) continue const decision = decideMention(text, { pagePort, product, before }, resolver, models) if (decision.kind !== 'link') { diff --git a/scripts/normalize-native-shell.mjs b/scripts/normalize-native-shell.mjs index 2ec6e2c8..1b28c729 100644 --- a/scripts/normalize-native-shell.mjs +++ b/scripts/normalize-native-shell.mjs @@ -1,30 +1,96 @@ #!/usr/bin/env node -import { readFileSync, readdirSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' +import { Window } from 'happy-dom' +import { PORT_BY_SLUG } from '../site/src/lib/ports.ts' -/** Keep generated native shell assets inside the assembly's locale and preview. */ -export function normalizeNativeShell(directory, prefix) { +const currentPage = '__LIBTMUX_NATIVE_CURRENT_PAGE__' +const escapeAttribute = (text) => text.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') + +/** Render the runtime's own chrome once per port/version, without network access. */ +async function renderChrome(root, port, version) { + const window = new Window({ url: `https://libtmux.org${root}/${port.slug}/${version}/api/` }) + window.fetch = async () => ({ ok: false }) + try { + window.eval(readFileSync(new URL('../site/public/_shell/shell.js', import.meta.url), 'utf8')) + window.document.dispatchEvent(new window.Event('DOMContentLoaded')) + const header = window.document.querySelector('[data-lt-shell="header"]') + const footer = window.document.querySelector('[data-lt-shell="footer"]') + const style = window.document.getElementById('lt-shell-style') + if (!header || !footer || !style) throw new Error('Native shell did not render its header, footer and styles') + for (const link of header.querySelectorAll('[data-page-port-switcher] a[aria-current], a[lang="en"]')) { + link.setAttribute('href', currentPage) + } + return { header: header.outerHTML, footer: footer.outerHTML, style: style.outerHTML } + } finally { + await window.happyDOM.close() + } +} + +/** + * Keep generated native shell assets inside the assembly's locale and preview. + * @param {string} directory + * @param {string} prefix + * @param {{ sphinxPort?: string, version?: string }} [options] + */ +export async function normalizeNativeShell(directory, prefix, { sphinxPort, version = 'latest' } = {}) { + const port = sphinxPort ? PORT_BY_SLUG[sphinxPort] : undefined + if (sphinxPort && port?.renderer !== 'sphinx') throw new Error(`Not a Sphinx port: ${sphinxPort}`) const root = prefix.replace(/\/+$/, '') + const chrome = port ? await renderChrome(root, port, version) : undefined + const normalize = (content) => content.replace(/(['"(])(?:https?:\/\/libtmux\.org)?\/_shell\//g, `$1${root}/_shell/`) + const adapterPath = join(directory, '_static/libtmux-org.css') + if (port) { + mkdirSync(dirname(adapterPath), { recursive: true }) + writeFileSync(adapterPath, normalize(readFileSync(new URL('../site/public/_shell/sphinx.css', import.meta.url), 'utf8'))) + } let changed = 0 - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isDirectory()) { - changed += normalizeNativeShell(path, root) - } else if (/\.(html|css)$/.test(entry.name)) { - const before = readFileSync(path, 'utf8') - const after = before.replace(/(['"(])(?:https?:\/\/libtmux\.org)?\/_shell\//g, `$1${root}/_shell/`) - if (after !== before) { - writeFileSync(path, after) - changed++ + function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== '_sources') walk(path) + } else if (/\.(html|css)$/.test(entry.name)) { + const before = readFileSync(path, 'utf8') + let after = normalize(before) + if (port && entry.name.endsWith('.html') && !/]*http-equiv=["']refresh["']/i.test(after)) { + if (!/<\/head>/i.test(after)) throw new Error(`Native page has no closing head: ${path}`) + // Old source refs may predate the shell; replace existing integration once. + after = after + .replace(/]*\bsrc=["'][^"']*\/_shell\/shell\.js(?:\?[^"']*)?["'][^>]*>[\s\S]*?<\/script>\s*/gi, '') + .replace(/]*\bhref=["'][^"']*\blibtmux-org\.css(?:\?[^"']*)?["'][^>]*>\s*/gi, '') + .replace(/]*\bid=["']lt-shell-style["'][^>]*>[\s\S]*?<\/style>\s*/gi, '') + .replace(/[\s\S]*?\s*/g, '') + const cssUrl = relative(dirname(path), adapterPath).split(sep).join('/') + after = after.replace(/<\/head>/i, `\n${chrome.style}\n\n`) + if (!/]*>/i.test(after) || !/<\/body>/i.test(after)) throw new Error(`Native page has no body: ${path}`) + const pageUrl = `${root}/${port.slug}/${version}/api/${relative(directory, path).split(sep).join('/').replace(/index\.html$/, '')}` + const header = chrome.header.replaceAll(currentPage, escapeAttribute(pageUrl)) + after = after + .replace(/(]*>)\s*/i, `$1\n${header}\n`) + .replace(/\s*<\/body>/i, `\n${chrome.footer}\n`) + if (!/]*)>/i, (_tag, attributes) => { + const clean = attributes.replace(/\sdata-pagefind-(?:body|filter)(?:=["'][^"']*["'])?/gi, '') + return `` + }) + } + if (after !== before) { + writeFileSync(path, after) + changed++ + } } } } + walk(directory) return changed } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const [directory, prefix] = process.argv.slice(2) - if (!directory || !prefix?.startsWith('/')) throw new Error('Usage: normalize-native-shell.mjs DIRECTORY /LOCALE_PREFIX') - console.log(`Native shell URLs: normalized ${normalizeNativeShell(directory, prefix)} HTML/CSS files`) + const [directory, prefix, sphinxPort, version] = process.argv.slice(2) + if (!directory || !prefix?.startsWith('/')) { + throw new Error('Usage: normalize-native-shell.mjs DIRECTORY /LOCALE_PREFIX [SPHINX_PORT VERSION]') + } + console.log(`Native shell: prepared ${await normalizeNativeShell(directory, prefix, { sphinxPort, version })} HTML/CSS files`) } diff --git a/scripts/stage-port-docs.mjs b/scripts/stage-port-docs.mjs index fbaefbae..76d29aec 100644 --- a/scripts/stage-port-docs.mjs +++ b/scripts/stage-port-docs.mjs @@ -5,6 +5,8 @@ import { execFileSync } from 'node:child_process' import { homedir } from 'node:os' import { dirname, join, posix, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { toMarkdown } from 'mdast-util-to-markdown' import { PORTS } from '../site/src/lib/ports.ts' import { sourceGuidesFor, SOURCE_GUIDE_PORTS } from '../site/src/lib/port-documentation.ts' @@ -13,6 +15,7 @@ const output = join(root, 'site/src/content/docs/_staged') const selectedPort = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : undefined const check = process.argv.includes('--check') const fromSource = process.argv.includes('--from-source') +const cached = process.argv.includes('--cached') const wrappersOnly = process.argv.includes('--wrappers') const refreshCache = process.argv.includes('--refresh-cache') const cacheRoot = join(root, 'site/src/data/port-guides') @@ -47,6 +50,14 @@ function external(value) { export function rewriteLinks(content, sourcePath, route, routes, repo, revision) { const targetUrl = (destination, image = false) => { + const ownSource = `https://github.com/${repo}/blob/` + if (!image && destination.startsWith(ownSource)) { + const [ref, ...path] = destination.slice(ownSource.length).split('/') + const [target, fragment = ''] = path.join('/').split('#', 2) + if (['master', 'main', revision].includes(ref)) { + destination = `${posix.relative(posix.dirname(sourcePath), target)}${fragment ? `#${fragment}` : ''}` + } + } if (external(destination)) return destination const [target, fragment = ''] = destination.split('#', 2) const normalized = posix.normalize(posix.join(posix.dirname(sourcePath), target)) @@ -58,11 +69,21 @@ export function rewriteLinks(content, sourcePath, route, routes, repo, revision) } return `https://github.com/${repo}/${image ? 'raw' : 'blob'}/${revision}/${normalized}${fragment ? `#${fragment}` : ''}` } + // Scala calls such as resource[IO](config) look like Markdown links. + // Parse first so code remains byte-for-byte source-owned, then replace + // only real links; the rest of the guide keeps its authored formatting. + const editsFor = (node) => { + const children = (node.children ?? []).flatMap(editsFor) + if (!['link', 'image', 'definition'].includes(node.type)) return children + const url = targetUrl(node.url, node.type === 'image') + if (url === node.url) return children + node.url = url + return [{ start: node.position.start.offset, end: node.position.end.offset, + text: toMarkdown(node).trimEnd() }] + } + const edits = editsFor(fromMarkdown(content)).sort((a, b) => b.start - a.start) + for (const { start, end, text } of edits) content = content.slice(0, start) + text + content.slice(end) return content - .replace(/(!?)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, - (_all, image, label, destination) => `${image}[${label}](${targetUrl(destination, Boolean(image))})`) - .replace(/^(\[[^\]]+\]:)\s*\n?\s*(\S+)/gm, - (_all, label, destination) => `${label} ${targetUrl(destination)}`) } export function stagedPortGuides(port, artifact) { @@ -92,20 +113,23 @@ export function stagedPortGuides(port, artifact) { files.set(`${port}/${route}/index.md`, `---\n${frontmatter}\n---\n\n${rewritten.trim()}\n`) } const identity = PORTS.find((entry) => entry.slug === port) - if (identity.parentLibrary) { + { const source = { repo: artifact.source.repository, path: Object.keys(routes)[0], ref: artifact.source.revision } const writeIndex = (route, title, body, cards = []) => { const data = { title, description: `${title} for ${identity.packageName}.`, port, route, source, cards, - sidebar: { group: route === 'reference' ? 'API reference' : 'Guides', order: 0 } } + sidebar: { group: route === 'reference' ? 'API reference' : route[0].toUpperCase() + route.slice(1), order: 0 } } const frontmatter = Object.entries(data).map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join('\n') files.set(`${port}/${route}/index.md`, `---\n${frontmatter}\n---\n\n${body}\n`) } - const cards = Object.entries(routes).filter(([, entry]) => entry.route.startsWith('guides/')) - .map(([path, entry]) => ({ label: titleAndBody(guides.get(path), path).title, - href: `./${entry.route.slice('guides/'.length)}/`, body: `Read the ${identity.name} package guide.` })) - writeIndex('guides', `${identity.name} guides`, - `These guides come from the ${identity.packageName} sources at this documentation revision.`, cards) - if (identity.ecosystemHost) writeIndex('reference', `${identity.name} API reference`, + for (const section of identity.parentLibrary ? ['guides'] : ['guides', 'examples', 'topics']) { + const ownSection = Object.entries(routes).filter(([, entry]) => entry.route.startsWith(`${section}/`)) + const selected = ownSection.length ? ownSection : Object.entries(routes).filter(([, entry]) => entry.domain === 'core' && entry.route.startsWith('guides/')) + const cards = selected.map(([path, entry]) => ({ label: titleAndBody(guides.get(path), path).title, + href: `../${entry.route}/`, body: `Read the ${identity.name} guide and its examples.` })) + writeIndex(section, `${identity.name} ${section}`, + `Use these ${identity.packageName} guides for the APIs and examples in this version.`, cards) + } + if (identity.parentLibrary && identity.ecosystemHost) writeIndex('reference', `${identity.name} API reference`, `Use the [${identity.ecosystemHost.name} reference](${identity.ecosystemHost.url}) for published package versions.\n\nThe [source at this documentation revision](https://github.com/${source.repo}/tree/${source.ref}/${posix.dirname(source.path)}/src/main) contains the wrapper declarations and their documentation.\n\n${identity.name} and its ${identity.parentLibrary.runtime} core share a release version.`) } return files @@ -114,43 +138,49 @@ export function stagedPortGuides(port, artifact) { function artifactFromSource(port, checkout) { const modelPath = join(root, `site/src/data/api/${port.slug}.json`) const model = port.parentLibrary ? undefined : JSON.parse(readFileSync(modelPath, 'utf8')) - const head = execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() - if (model && head !== model.revision) { - throw new Error(`${port.slug}: checkout ${head} differs from integrated model ${model.revision}`) - } + const revision = model?.revision ?? execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() return { - source: { repository: port.repo, revision: head }, + source: { repository: port.repo, revision }, guides: Object.keys(ROUTES[port.slug]).map((path) => ({ path, - content: readFileSync(join(checkout, path), 'utf8'), + content: execFileSync('git', ['-C', checkout, 'show', `${revision}:${path}`], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }), })), } } export function run() { if (selectedPort && !(selectedPort in ROUTES)) throw new Error(`unsupported staged port: ${selectedPort}`) + const publicationPort = process.env.LIBTMUX_DOCS_SOURCE_SHA && process.env.LIBTMUX_DOCS_PORT + if (cached && selectedPort && selectedPort === publicationPort) { + throw new Error(`--cached cannot stage selected publication port ${selectedPort}; regenerate its source guides first`) + } const generated = new Map() - const selected = PORTS.filter((entry) => entry.slug in ROUTES && (!selectedPort || entry.slug === selectedPort) && (!wrappersOnly || entry.parentLibrary)) - if (refreshCache && (!selectedPort || !selected[0]?.parentLibrary)) throw new Error('--refresh-cache requires one wrapper --port') + const selected = PORTS.filter((entry) => entry.slug in ROUTES && (!selectedPort || entry.slug === selectedPort) + && (!wrappersOnly || entry.parentLibrary) && !(cached && entry.slug === publicationPort)) + if (refreshCache && !selectedPort) throw new Error('--refresh-cache requires one --port') for (const port of selected) { const checkout = expand(process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || port.worktree) const artifactPath = join(checkout, 'docs/_build/api.json') const selectedSource = process.env.LIBTMUX_DOCS_PORT === port.slug && process.env.LIBTMUX_DOCS_SOURCE_SHA const cachePath = join(cacheRoot, `${port.slug}.json`) const liveWrapper = port.parentLibrary && (refreshCache || selectedSource || (selectedPort && fromSource)) - if (!port.parentLibrary && !fromSource && !existsSync(artifactPath)) throw new Error(`${port.slug}: native artifact missing at ${artifactPath}`) - const artifact = port.parentLibrary && !liveWrapper + if (!cached && !port.parentLibrary && !fromSource && !existsSync(artifactPath)) throw new Error(`${port.slug}: native artifact missing at ${artifactPath}`) + const artifact = cached || (port.parentLibrary && !liveWrapper) ? JSON.parse(readFileSync(cachePath, 'utf8')) : fromSource || liveWrapper ? artifactFromSource(port, checkout) : JSON.parse(readFileSync(artifactPath, 'utf8')) if (artifact.source.repository !== port.repo || !/^[a-f0-9]{40}$/.test(artifact.source.revision)) { throw new Error(`${port.slug}: invalid source guide provenance`) } + if (!port.parentLibrary) { + const model = JSON.parse(readFileSync(join(root, `site/src/data/api/${port.slug}.json`), 'utf8')) + if (artifact.source.revision !== model.revision) throw new Error(`${port.slug}: guide source ${artifact.source.revision} differs from integrated model ${model.revision}`) + } const expected = selectedSource if (expected && artifact.source?.revision !== expected) { throw new Error(`${port.slug}: expected source ${expected}, artifact records ${artifact.source?.revision}`) } - if (port.parentLibrary && !check && (refreshCache || selectedSource)) { + if (!check && (refreshCache || selectedSource)) { mkdirSync(cacheRoot, { recursive: true }) writeFileSync(cachePath, `${JSON.stringify(artifact, null, 2)}\n`) } diff --git a/scripts/test-loop.mjs b/scripts/test-loop.mjs index 9c47a6b1..389d9581 100644 --- a/scripts/test-loop.mjs +++ b/scripts/test-loop.mjs @@ -69,11 +69,13 @@ try { if (loop !== 'inner') checks.push( pnpm('run', '--recursive', 'lint'), pnpm('exec', 'oxlint', 'scripts'), + node('scripts/check-api-links.mjs'), node('scripts/gen-mentions.mjs', '--check'), node('scripts/gen-shell-ports.mjs', '--check'), node('scripts/gen-brand-css.mjs', '--check'), ) if (loop === 'outer') checks.push( + node('scripts/gen-example-sources.mjs', '--check'), pnpm('run', '--recursive', 'type-check'), node('site/scripts/check-dev.mjs'), ) diff --git a/site/public/_shell/README.md b/site/public/_shell/README.md index dd9d87ba..9705be82 100644 --- a/site/public/_shell/README.md +++ b/site/public/_shell/README.md @@ -1,219 +1,66 @@ -# `/_shell/` — the design-token bridge +# Native reference navigation and theme -This file lives under `site/public/`, so Astro publishes it verbatim to -`libtmux.org/_shell/README.md` on every build — the same passthrough that -publishes `tokens.css` and `shell.js` from this directory. It is not -gated behind anything private; the `~/work/...` paths and the -`build-site.sh` bug narrative below are written the way they are (no -absolute machine paths, no assumptions about who is reading) because this -is effectively a public document already. +The shared assets give native reference pages the site's navigation, version +switcher, API equivalents, and theme. Assets live under the locale's `_shell/` +directory, including the preview prefix when present. -Closes the largest open item in `notes/status.md`: Python and C++ rendered -as stock Furo — their own title, their own header, no port switcher, no -version switcher, none of the site's palette. A reader moving from `/ts/` -to `/py/` landed on what looked like a different website. Full design in -`notes/research/03-design-token-bridge.md`. +| File | Purpose | +| --- | --- | +| `shell.js` | Header, footer, port and version switching, and theme synchronization. | +| `tokens.css` | Shared light and dark theme values. | +| `sphinx.css` | Maps shared tokens to Furo's CSS variables. | +| `brand.css` | Port colors and artwork used by native pages. | -## The three artifacts +## Sphinx integration -| File | What it is | Who loads it | -|---|---|---| -| `tokens.css` | ~25 semantic `--lt-*` custom properties (light default, `[data-theme="dark"]` override, `prefers-color-scheme` fallback) | Never linked directly by a foreign generator — pulled in transitively via each generator's own adapter stylesheet's `@import` | -| `shell.js` | Header/footer injection, the version switcher, and a dark-mode shim | Loaded directly via each generator's script-injection flag (Sphinx: `html_js_files`) | -| `README.md` | This file | Nobody; documentation only | +After Sphinx builds the selected source revision, `scripts/build-site.sh` +runs `scripts/normalize-native-shell.mjs`. It copies `sphinx.css` to the +reference's `_static/libtmux-org.css`, then adds its stylesheet and the shared +script to each page. It renders the script's own header, footer and styles once +per port/version, using Happy DOM without network access. The header is present +at first paint, so loading the script does not push the article down. +Existing integration is replaced once; redirects retain +their original behavior. This step does not edit source checkouts or their +Sphinx configuration. -Both are served from a **stable, unversioned URL** -(`https://libtmux.org/_shell/tokens.css`, `.../shell.js`) — not copied into -each build. That is deliberate: a chrome-color fix or a header bug fix -reaches every already-published, immutable version prefix (`/py/v0.46.2/`, -`/cxx/v1.2.0/`, …) without rebuilding that version. See -`notes/research/00-DECISIONS.md` §6 ("load the shared header, footer, -version switcher and tokens at runtime from a stable URL"). +The stylesheet loads after the native theme and imports the locale's +`_shell/tokens.css`. Its fallbacks retain Furo colors if shared tokens cannot +load. Other native asset URLs are normalized to the same locale and preview. -Two research documents (`03-design-token-bridge.md`, -`14-lang-rust.md`, `07-ci-topology.md`) describe this prefix as -**versioned**, `/_shell/v1/`, for exactly this reason — a breaking change -bumps to `/_shell/v2/` instead of breaking every generator that already -baked in the v1 URL. A third (`16-lang-java-kotlin.md`) and this -assignment's own file list use the **unversioned** `/_shell/` path. This -implementation follows the assignment's literal paths -(`site/public/_shell/{tokens,shell}.{css,js}`). Flagged as an open -contradiction between research documents; adding a `v1/` segment later is a -rename of these two files plus every conf.py that references them, not a -redesign. +This allows older library revisions to receive the current site navigation +without requiring shell integration in their own source tree. A standalone +Sphinx build remains controlled by its repository's configuration. -## Why the Astro shell itself does not consume these +## Runtime contracts -The Astro shell (`site/src/styles/global.css`) has its own, richer, -multi-theme token system (`emerald`/`amber`/`sky`/`purple` palettes via -`html[data-theme]`, light/dark via `html[data-theme-mode]`). `tokens.css` -is deliberately the narrow, lowest-common-denominator subset a *foreign* -generator's own CSS can plausibly repaint with — Furo has no slot for four -selectable brand hues, only one accent. The two systems name their -attributes the same way for unrelated things (`html[data-theme]` means -"which brand palette" in the shell, "resolved light/dark" for this -bridge) — this only matters if a future shell page ever loads `tokens.css` -directly, which none does today. Flagged as an open naming collision -rather than resolved, since resolving it means picking a side without a -second consumer to test against. +`shell.js` reads `versions.json` and `page-links.json` from the locale root. +The generated port table comes from `site/src/lib/ports.ts`; regenerate it +with `node scripts/gen-shell-ports.mjs` after changing port identities. -## The adapter problem +Native pages read Astro's `color-scheme` preference first, translating +`system` to Furo's `auto`. Native toggles update that key, Furo's `theme`, +and the legacy `libtmux-theme` key. Test navigation in both directions when +changing this contract. -`tokens.css` restyles nothing by itself. Each foreign generator paints -from its own variable system, so each needs a small **adapter -stylesheet** — a translation table from the ~25 `--lt-*` names onto that -generator's real variables, loaded through that generator's own override -mechanism. Today that is Furo, for the two Sphinx-rendered ports: +The script and tokens use stable URLs so navigation and theme fixes can reach +previously published references. Changes must preserve existing page contracts. +The script enhances existing navigation without replacing it, and still inserts +navigation on older pages that have no initial header. -- `~/work/python/libtmux-python-docs/docs/_static/libtmux-org.css` (Python - — `sphinx-gp-theme`, a Furo child theme) -- `~/work/libtmux/libtmux-cxx-docs/docs/_static/libtmux-org.css` (C++ — - vanilla Furo via Doxygen → Breathe) +## Verification -Both map the same ~25 names onto the same Furo `--color-*` contract -(harvested from upstream Furo's SCSS at -`~/work/python/gp-sphinx/packages/gp-furo-tokens/src/{light,dark}.ts` — -verified against the actual built output at -`_site/py/*/api/_static/styles/furo-tw.css` and -`_site/cxx/*/api/_static/styles/furo.css` before writing the mapping, not -assumed). The two files are near-identical and kept in sync by hand — -there is no shared npm package either Sphinx build could import from -(`gp-furo-tokens` is `"private": true`); this is the one duplication cost -the design accepts, and it is the *mapping* that is duplicated (~60 lines), -not the token *values* (which stay centralized in `tokens.css` via -`@import` — see "Rejected: vendoring" in -`notes/research/03-design-token-bridge.md` §1). - -Every mapping in the adapter carries Furo's own stock color as a `var()` -fallback (`--color-brand-primary: var(--lt-color-accent, #0a4bff)`). This -is load-bearing: the site has never been deployed (`notes/status.md`), so -every page loads `tokens.css` cross-origin from wherever it is actually -served today — a fetch that 404s until DNS resolves. Per the CSS custom -properties spec, `var()` on an undefined property with no fallback -resolves to the guaranteed-invalid value, which would make every rule -reading `--color-background-primary` compute to nothing — transparent -backgrounds, UA-default text — strictly worse than the stock-Furo glitch -this closes. The fallback is Furo's own value, not a libtmux one, so -degrading means "looks like unmodified Furo," not "looks half-skinned." - -Each conf.py wires the adapter in with two config values: - -```python -html_static_path = ["_static"] # cxx only — Python's already has this -html_css_files = ["libtmux-org.css"] # appended after any project CSS -html_js_files = [("https://libtmux.org/_shell/shell.js", {"defer": "defer"})] -``` - -`html_css_files`/`html_js_files` accept a full external URL verbatim -(Sphinx's own `add_css_file`/`add_js_file`: a filename containing `://` is -never rewritten to `_static/…`) — confirmed against the installed Sphinx -source, not assumed from documentation. `libtmux-org.css` must be **last** -in the list: Sphinx emits `html_css_files` after every extension's own -bundled CSS (including Furo's), so appending guarantees the adapter's -`body { --color-x: var(--lt-y) }` overrides land after Furo's own -`--color-x` declaration at equal specificity and wins. - -## The version switcher contract - -`shell.js`'s `` custom element is a hand-kept -port of `site/src/components/VersionSwitcher.astro`'s inline script — -same element name, same `data-port`/`data-current` dataset keys, same -`/versions.json` fetch and schema check, same supported-only filtering, -same eol-suffix labelling, same path-preserving navigation on change, and -the same ecosystem-port suppression (a port whose `referenceMode` is -`'ecosystem'` never gets a switcher — its versions live on docs.rs / -pkg.go.dev / javadoc.io, which have their own). If -`VersionSwitcher.astro`'s contract changes, `shell.js` must change with -it; nothing enforces that automatically. - -`shell.js` also carries a small, hand-kept-in-sync copy of the fields it -needs from `site/src/lib/ports.ts` (slug, display name, `referenceMode`, -and — for ecosystem ports — the exact `ecosystemHost.url`, copied -verbatim). A runtime script has no bundler and cannot import that module; -`ThemeScript.astro` already accepts the identical trade-off for -`theme-config.ts`, with the same comment shape, for the same reason. - -## The dark-mode shim - -"Shim, don't replace" (`03-design-token-bridge.md` §4): Furo keeps its own -toggle button, its own `theme` `localStorage` key, and its own -`body[data-theme]` attribute. `shell.js` never touches Furo's click -handler — it observes the resulting `body[data-theme]` mutation (via -`MutationObserver`, since Furo's toggle fires no event of its own) and -mirrors the *resolved* light/dark value onto `html[data-theme]`, which is -the attribute `tokens.css` actually reads. A page can legitimately show -two theme controls (Furo's native one, plus whatever the injected header -grows later) — both read the same synced state, so they cannot disagree, -which is the seam `03-design-token-bridge.md` §4 explicitly accepts rather -than papering over with a fork of Furo's toggle. - -`notes/research/00-DECISIONS.md` §7.13 leaves the canonical cross-site -`localStorage` key an open decision ("pick one before `shell.js` ships"). -This implementation picks `libtmux-theme` and records that choice in -`shell.js`'s own comments rather than in a separate document. It has not -been reconciled with `social-embed`'s `starlight-theme` key — that -remains open, and only matters if `libtmux.org` and `social-embed.org` -(or another `git-pull.com` property) are ever meant to share a dark-mode -preference across visits. - -One accepted flash: Furo's inline pre-paint script sets -`body[data-theme]` synchronously, before `shell.js` (deferred) runs. If -the reader's explicit preference disagrees with the OS (e.g. chose "dark" -while the OS is light), `tokens.css`'s `prefers-color-scheme` fallback -briefly disagrees with Furo's own paint until `shell.js` sets -`html[data-theme]` explicitly. The "auto" case has no flash at all, since -`tokens.css`'s media-query block matches with no attribute present. This -is the same one-frame trade-off `03-design-token-bridge.md` §4 names -explicitly rather than rewriting Furo's boot script to defer to ours. - -A sibling case is not just a flash: if `shell.js` itself fails to load -(same-origin as `tokens.css`, so this fails together with it, but keep the -two failure modes distinct) while the reader's explicit preference is -"dark" and the OS is light, `html[data-theme]` is simply never written — -Furo still paints dark correctly from its own `body[data-theme]`, but -`tokens.css`'s fallback block (`prefers-color-scheme: dark`) never -matches, so **every `--lt-*` token stays at its light value for the -session**, not just for one frame. The adapter's own `var(--lt-x, -)` fallback then supplies the *light* stock color inside -a page Furo has painted dark — a legible but visibly wrong combination, -not a broken one. This is the direct consequence of `shell.js` being the -only thing that writes `html[data-theme]`; there is no second mechanism to -fall back to. - -## Verifying it actually happened - -`scripts/inject-shell.mjs` is the post-build smoke test: it reads -`site/src/lib/ports.ts` for the self-hosted Sphinx ports, walks every -built `//api/` page, and asserts the adapter link and -`shell.js` script tag are present, the adapter still `@import`s -`tokens.css` from the real URL, every `--color-*` name the adapter writes -still exists in that build's own generated CSS (catches a Furo upgrade -silently renaming or dropping one), and every `--lt-*` name the adapter -reads exists in `tokens.css` (catches a typo on our side). Run it after -`scripts/build-site.sh`: +After assembling the site, run: ```console -$ node scripts/inject-shell.mjs +$ node scripts/inject-shell.mjs --site _site ``` -A port with nothing built yet is reported as skipped, not failed. Any -other failure exits non-zero. - -This script proves the wiring is present and internally consistent — the -right tags exist, the right variable names exist on both sides of the -mapping. It does not prove the injected header/footer actually look right -next to Furo's own flex/sticky layout in a real browser; that visual QA -has not been done and is unverified. +The check inspects every built Sphinx page for the script and stylesheet, +checks locale URLs, and compares the adapter's variables with the generated +theme and shared tokens. Missing references are reported as skips. Broken +integration fails the command. -**Known failure as of this writing**: running the check above reports -`py/stable` as failing every check, while `cxx/stable` passes. This is not -a bug in the adapter or in `shell.js` — direct `sphinx-build` runs against -`~/work/python/libtmux-python-docs/docs` (this file's own conf.py) produce -the correct `` tags every time. The cause is in -`scripts/build-site.sh`'s `build_reference()`: it redirects `cxx` and -`dotnet` to their `docs-site` worktree when that worktree carries the -generator's entrypoint, but the equivalent `py)` case is missing from that -same `case` statement, so the Python build actually runs against -`~/work/python/libtmux/docs/conf.py` (the main checkout) instead of the -worktree this fix lives in. `build-site.sh` is not owned by this piece of -work; see the top-level summary for this contradiction reported upstream. +The output tests cover native asset paths; `normalize-native-shell.test.ts` +covers older sources, existing integration, previews, redirects, repeated +assembly, and malformed HTML. Use a browser to verify switching, themes, and +layout on the assembled native pages. diff --git a/site/public/_shell/shell.js b/site/public/_shell/shell.js index 3023ba13..58115b54 100644 --- a/site/public/_shell/shell.js +++ b/site/public/_shell/shell.js @@ -206,7 +206,9 @@ ;[['Reference', '/reference/'], ['MCP', '/mcp/'], ['Search', '/search/']].forEach(function (entry) { var link = document.createElement('a') link.className = 'lt-shell-search-link' - link.href = siteRoot + entry[1] + link.href = entry[0] === 'Search' && currentPort && currentVersion + ? siteRoot + '/' + currentPort + '/' + currentVersion + entry[1] + : siteRoot + entry[1] link.textContent = entry[0] controls.appendChild(link) }) @@ -244,11 +246,13 @@ '.lt-shell-header,.lt-shell-footer{font-family:var(--lt-font-sans,sans-serif);' + 'font-size:0.875rem;background:var(--lt-color-bg,#fff);color:var(--lt-color-fg,#000);box-sizing:border-box}' + '.lt-shell-header *,.lt-shell-footer *{box-sizing:border-box}' + - '.lt-shell-header{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;' + + // Furo's fixed table of contents uses layer 50. + '.lt-shell-header{position:relative;z-index:51;display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;' + 'padding:0.6rem 1rem;border-bottom:1px solid var(--lt-color-border,#eeebee)}' + '.lt-shell-brand{font-family:var(--lt-font-mono,monospace);font-weight:600;' + 'font-size:1.05rem;color:var(--lt-color-fg,#000);text-decoration:none;letter-spacing:-0.01em}' + - '.lt-shell-nav{display:flex;flex-wrap:wrap;gap:0.15rem;flex:1}' + + // Wrap the controls before the language list can collapse into a column. + '.lt-shell-nav{display:flex;flex-wrap:wrap;gap:0.15rem;flex:1 1 15rem}' + '.lt-shell-nav-link{padding:0.25rem 0.5rem;border-radius:var(--lt-radius,0.375rem);' + 'color:var(--lt-color-fg-secondary,#5a5c63);text-decoration:none}' + '.lt-shell-nav-link:hover{background:var(--lt-color-bg-hover,#efeff4)}' + @@ -277,14 +281,19 @@ '.lt-shell-footer-list a:hover{text-decoration:underline}' function injectChrome() { - if (document.getElementById('lt-shell-style')) return - var style = document.createElement('style') - style.id = 'lt-shell-style' - style.textContent = CHROME_STYLE - document.head.appendChild(style) + if (!document.getElementById('lt-shell-style')) { + var style = document.createElement('style') + style.id = 'lt-shell-style' + style.textContent = CHROME_STYLE + document.head.appendChild(style) + } defineVersionSwitcher() - document.body.insertBefore(buildHeader(), document.body.firstChild) - document.body.appendChild(buildFooter()) + // Current native builds include chrome in the first paint. Keep the + // insertion fallback for references published before that integration. + if (!document.querySelector('[data-lt-shell="header"]')) { + document.body.insertBefore(buildHeader(), document.body.firstChild) + } + if (!document.querySelector('[data-lt-shell="footer"]')) document.body.appendChild(buildFooter()) refreshPagePorts() manifest.then(function (data) { if (!data || data.schema !== 1) return @@ -306,76 +315,68 @@ window.addEventListener('hashchange', refreshPagePorts) } - // --------------------------------------------------------------------- - // Dark-mode shim (design-token-bridge.md §4: shim, don't replace). - // - // Canonical key: `libtmux-theme`. notes/research/00-DECISIONS.md §7.13 - // leaves this an open decision ("pick one before shell.js ships"); this - // file makes the call and records it here rather than in a separate - // document. Furo's own key (`theme`) stays authoritative for Furo's own - // toggle and paint logic — this shim only mirrors the *resolved* value - // onto html[data-theme], which is the attribute tokens.css reads, and - // writes the canonical key through so a future non-Furo generator (or - // the Astro shell, if it ever adopts this key — see the open question in - // the same section) can agree on "what did the reader last choose" - // without re-deriving it from Furo's storage format. - // --------------------------------------------------------------------- - var CANONICAL_KEY = 'libtmux-theme' - var FURO_KEY = 'theme' // gp-furo-theme's furo.ts: localStorage.setItem('theme', mode) + // Astro stores `system`; Furo stores the same preference as `auto`. + var SITE_THEME_KEY = 'color-scheme' + var LEGACY_THEME_KEY = 'libtmux-theme' + var FURO_KEY = 'theme' + var themePreference function systemPrefersDark() { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches } + function nativePreference(value) { + if (value === 'system') return 'auto' + return value === 'light' || value === 'dark' || value === 'auto' ? value : null + } + function readPreference() { - // Furo's own key is authoritative for what actually painted this page - // (its inline pre-paint script already ran); fall back to the - // canonical key, then "auto". try { - var native = localStorage.getItem(FURO_KEY) - if (native === 'light' || native === 'dark' || native === 'auto') return native + var stored = nativePreference(localStorage.getItem(SITE_THEME_KEY)) || + nativePreference(localStorage.getItem(FURO_KEY)) || + nativePreference(localStorage.getItem(LEGACY_THEME_KEY)) + if (stored) return stored } catch (e) { /* storage disabled */ } + return themePreference || nativePreference(document.body && document.body.getAttribute('data-theme')) || 'auto' + } + + function storePreference(key, value) { try { - var canonical = localStorage.getItem(CANONICAL_KEY) - if (canonical === 'light' || canonical === 'dark' || canonical === 'auto') return canonical + // Avoid storage-event loops between pages mirroring the same choice. + if (localStorage.getItem(key) !== value) localStorage.setItem(key, value) } catch (e) { - /* storage disabled */ + /* The current page still follows the native toggle. */ } - return 'auto' } - function applyResolvedTheme() { - var pref = readPreference() - var resolved = pref === 'auto' ? (systemPrefersDark() ? 'dark' : 'light') : pref + function applyResolvedTheme(pref) { + themePreference = pref || readPreference() + var resolved = themePreference === 'auto' ? (systemPrefersDark() ? 'dark' : 'light') : themePreference + var sitePreference = themePreference === 'auto' ? 'system' : themePreference + var root = document.documentElement + root.setAttribute('data-color-scheme', sitePreference) + root.setAttribute('data-theme-mode', resolved) + root.style.colorScheme = resolved + if (themePreference === 'auto') root.removeAttribute('data-theme') + else root.setAttribute('data-theme', resolved) - // html[data-theme] is what tokens.css keys off. "auto" is never - // written here — the fallback in tokens.css already tracks the media - // query on its own, so an explicit attribute would only fight it on - // the next OS-level change. - if (pref === 'auto') { - document.documentElement.removeAttribute('data-theme') - } else { - document.documentElement.setAttribute('data-theme', resolved) - } - - // Write through the canonical key so it never drifts from what Furo's - // own toggle just decided. - try { - localStorage.setItem(CANONICAL_KEY, pref) - } catch (e) { - /* storage disabled */ + // Furo paints and cycles from its own body attribute and storage key. + if (document.body && document.body.getAttribute('data-theme') !== themePreference) { + document.body.setAttribute('data-theme', themePreference) } + storePreference(SITE_THEME_KEY, sitePreference) + storePreference(FURO_KEY, themePreference) + storePreference(LEGACY_THEME_KEY, themePreference) } function watchNativeToggle() { - // Furo's toggle button mutates document.body's data-theme attribute - // directly with no event of its own — observe that instead of - // reimplementing its click handler, which is the fork this bridge is - // built to avoid (design-token-bridge.md §4). if (!window.MutationObserver || !document.body) return - new MutationObserver(applyResolvedTheme).observe(document.body, { + new MutationObserver(function () { + var pref = nativePreference(document.body.getAttribute('data-theme')) + if (pref && pref !== themePreference) applyResolvedTheme(pref) + }).observe(document.body, { attributes: true, attributeFilter: ['data-theme'], }) @@ -384,13 +385,15 @@ function watchSystemPreference() { if (!window.matchMedia) return window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { - if (readPreference() === 'auto') applyResolvedTheme() + if (themePreference === 'auto') applyResolvedTheme('auto') }) } function watchCrossTab() { window.addEventListener('storage', function (e) { - if (e.key === FURO_KEY || e.key === CANONICAL_KEY) applyResolvedTheme() + if (e.key === SITE_THEME_KEY || e.key === FURO_KEY || e.key === LEGACY_THEME_KEY || e.key === null) { + applyResolvedTheme() + } }) } diff --git a/site/public/_shell/sphinx.css b/site/public/_shell/sphinx.css new file mode 100644 index 00000000..f98c86cc --- /dev/null +++ b/site/public/_shell/sphinx.css @@ -0,0 +1,96 @@ +/* Map shared site tokens to Furo, after the generated theme stylesheet. */ +@import url('/_shell/tokens.css'); + +body { + --color-background-primary: var(--lt-color-bg, white); + --color-background-secondary: var(--lt-color-bg-secondary, #f8f9fb); + --color-background-hover: var(--lt-color-bg-hover, #efeff4); + + --color-foreground-primary: var(--lt-color-fg, black); + --color-foreground-secondary: var(--lt-color-fg-secondary, #5a5c63); + --color-foreground-muted: var(--lt-color-fg-muted, #6b6f76); + + --color-background-border: var(--lt-color-border, #eeebee); + + --color-brand-primary: var(--lt-color-accent, #0a4bff); + --color-brand-content: var(--lt-color-link, #2757dd); + --color-brand-visited: var(--lt-color-link-visited, #872ee0); + + --color-inline-code-background: var(--lt-color-code-bg, #f8f9fb); + --color-highlighted-background: var(--lt-color-highlighted-bg, #ddeeff); + + --color-api-added: var(--lt-color-added, #21632c); + --color-api-removed: var(--lt-color-removed, #b30000); + --color-api-changed: var(--lt-color-changed, #046172); + --color-api-deprecated: var(--lt-color-deprecated, #605706); + + /* Keep Furo fallbacks when shared tokens are unavailable. */ + --color-admonition-title--danger: var(--lt-color-danger, #ff5252); + --color-admonition-title--error: var(--lt-color-danger, #ff5252); + --color-admonition-title--attention: var(--lt-color-danger, #ff5252); + --color-admonition-title--warning: var(--lt-color-warning, #ff9100); + --color-admonition-title--caution: var(--lt-color-warning, #ff9100); + --color-admonition-title--note: var(--lt-color-info, #00b0ff); + --color-admonition-title--seealso: var(--lt-color-info, #448aff); + --color-admonition-title--hint: var(--lt-color-success, #00c852); + --color-admonition-title--tip: var(--lt-color-success, #00c852); + + --font-stack: + var(--lt-font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'); + --font-stack--monospace: + var(--lt-font-mono, 'SFMono-Regular', Menlo, Consolas, Monaco, 'Liberation Mono', + 'Lucida Console', monospace); +} + +@media not print { + body[data-theme='dark'] { + --color-background-primary: var(--lt-color-bg, #131416); + --color-background-secondary: var(--lt-color-bg-secondary, #1a1c1e); + --color-background-hover: var(--lt-color-bg-hover, #1e2124); + + --color-foreground-primary: var(--lt-color-fg, #cfd0d0); + --color-foreground-secondary: var(--lt-color-fg-secondary, #9ca0a5); + --color-foreground-muted: var(--lt-color-fg-muted, #81868d); + + --color-background-border: var(--lt-color-border, #303335); + + --color-brand-primary: var(--lt-color-accent, #3d94ff); + --color-brand-content: var(--lt-color-link, #5ca5ff); + --color-brand-visited: var(--lt-color-link-visited, #b27aeb); + + --color-inline-code-background: var(--lt-color-code-bg, #1a1c1e); + --color-highlighted-background: var(--lt-color-highlighted-bg, #083563); + + --color-api-added: var(--lt-color-added, #3db854); + --color-api-removed: var(--lt-color-removed, #ff7575); + --color-api-changed: var(--lt-color-changed, #09b0ce); + --color-api-deprecated: var(--lt-color-deprecated, #b1a10b); + } + + @media (prefers-color-scheme: dark) { + body:not([data-theme='light']) { + --color-background-primary: var(--lt-color-bg, #131416); + --color-background-secondary: var(--lt-color-bg-secondary, #1a1c1e); + --color-background-hover: var(--lt-color-bg-hover, #1e2124); + + --color-foreground-primary: var(--lt-color-fg, #cfd0d0); + --color-foreground-secondary: var(--lt-color-fg-secondary, #9ca0a5); + --color-foreground-muted: var(--lt-color-fg-muted, #81868d); + + --color-background-border: var(--lt-color-border, #303335); + + --color-brand-primary: var(--lt-color-accent, #3d94ff); + --color-brand-content: var(--lt-color-link, #5ca5ff); + --color-brand-visited: var(--lt-color-link-visited, #b27aeb); + + --color-inline-code-background: var(--lt-color-code-bg, #1a1c1e); + --color-highlighted-background: var(--lt-color-highlighted-bg, #083563); + + --color-api-added: var(--lt-color-added, #3db854); + --color-api-removed: var(--lt-color-removed, #ff7575); + --color-api-changed: var(--lt-color-changed, #09b0ce); + --color-api-deprecated: var(--lt-color-deprecated, #b1a10b); + } + } +} diff --git a/site/scripts/check-dev.mjs b/site/scripts/check-dev.mjs index 207eef36..1c3066aa 100644 --- a/site/scripts/check-dev.mjs +++ b/site/scripts/check-dev.mjs @@ -7,7 +7,8 @@ import { dev } from 'astro' import { chromium, firefox, webkit } from 'playwright' import { PORTS, productAvailable } from '../src/lib/ports.ts' import { checkClipboard } from './check-clipboard.mjs' -import { checkNavigation } from './check-navigation.mjs' +import { checkApiNavigation, checkNavigation } from './check-navigation.mjs' +import { checkNativeLayout } from './check-native-layout.mjs' const workspacePortCount = PORTS.filter((port) => productAvailable(port, 'workspace')).length // `workspaceCli` alone also covers a port's local, unreleased dev CLI @@ -49,9 +50,10 @@ const terminate = async () => { } process.on('SIGTERM', terminate) process.on('SIGINT', terminate) -server = await dev({ root, cacheDir: join(mirror, 'cache'), +const startServer = () => dev({ root, cacheDir: join(mirror, 'cache'), vite: { cacheDir: join(mirror, 'vite') }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 } }) +server = await startServer() const base = `http://127.0.0.1:${server.address.port}/en` // Vite can reload once after its initial dependency optimization. @@ -69,6 +71,7 @@ try { const driver = { chromium, firefox, webkit }[engine] if (!driver) throw new Error(`Unknown browser: ${engine}`) browser = await driver.launch(engine === 'chromium' ? { channel: process.env.LIBTMUX_DOCS_BROWSER_CHANNEL } : {}) + const nativeLayout = checkNativeLayout(browser).then(() => null, (error) => error) const page = await browser.newPage({ reducedMotion: 'reduce' }) page.setDefaultTimeout(10000) const manifest = await page.request.get(`${base}/page-links.json`) @@ -214,6 +217,8 @@ try { console.log('Empty sidebars: article and reference index use their available width') const clipboardError = await clipboard if (clipboardError) throw clipboardError + const nativeLayoutError = await nativeLayout + if (nativeLayoutError) throw nativeLayoutError await page.goto(`${base}/`, { waitUntil: 'load' }) await page.locator('.scheme-switch input[value="dark"]').check({ force: true }) const chipPixel = await page.evaluate(() => { @@ -320,6 +325,14 @@ try { } } console.log('Heroes: 88px marks share the title row and stack at phone widths') + // Core API routes exist only in a port shell. Reuse the isolated fixture + // with its real Lua routes so the routine gate covers retained-document swaps. + await server.stop() + Object.assign(process.env, { + LIBTMUX_DOCS_PORT: 'lua', LIBTMUX_DOCS_BASE: '/en/lua/latest/', + }) + server = await startServer() + await checkApiNavigation(page, `http://127.0.0.1:${server.address.port}/en`) } finally { await browser?.close() await server.stop() diff --git a/site/scripts/check-mobile-nav.mjs b/site/scripts/check-mobile-nav.mjs index 700ce729..be241358 100755 --- a/site/scripts/check-mobile-nav.mjs +++ b/site/scripts/check-mobile-nav.mjs @@ -22,12 +22,21 @@ * Usage: node scripts/check-mobile-nav.mjs [base-url] */ import { chromium } from 'playwright' +import { checkApiNavigation } from './check-navigation.mjs' const BASE = (process.argv[2] ?? 'http://localhost:8080').replace(/\/$/, '') const b = await chromium.launch() const fails = [], ok = [] const note = (pass, msg) => (pass ? ok : fails).push(msg) const WITH_TOC = '/topics/traversal/', NO_TOC = '/concepts/' const ctx = await b.newContext() +const apiPage = await ctx.newPage() +try { + await checkApiNavigation(apiPage, BASE) +} catch (error) { + fails.push(`API navigation: ${error.message}`) +} finally { + await apiPage.close() +} async function page(path, w = 390) { const p = await ctx.newPage() await p.setViewportSize({ width: w, height: 800 }) diff --git a/site/scripts/check-native-layout.mjs b/site/scripts/check-native-layout.mjs new file mode 100644 index 00000000..c585b2c5 --- /dev/null +++ b/site/scripts/check-native-layout.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { normalizeNativeShell } from '../../scripts/normalize-native-shell.mjs' +import { PORTS } from '../src/lib/ports.ts' + +/** Keep every port visible in a short language list at intermediate widths. */ +export async function checkNativeHeader(page) { + const navigation = await page.locator('.lt-shell-nav').evaluate((nav) => { + const links = [...nav.querySelectorAll('a')] + const bounds = links.map((link) => link.getBoundingClientRect()) + return { + count: links.length, + rows: new Set(bounds.map((rect) => rect.top)).size, + visible: bounds.every((rect) => rect.width > 0 && rect.height > 0 && rect.left >= 0 && rect.right <= innerWidth), + covered: links.filter((link, i) => { + const rect = bounds[i] + return !link.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) + }).map((link) => link.getAttribute('aria-label')), + } + }) + assert.equal(navigation.count, PORTS.length, 'Native header keeps every port') + assert(navigation.rows <= 3, `Native ports use at most three rows at ${page.viewportSize().width}px: ${navigation.rows}`) + assert(navigation.visible, 'Native port links fit the viewport') + assert.deepEqual(navigation.covered, [], 'Native header controls do not cover port links') +} + +/** Delay enhancement until the initial article and navigation can be inspected. */ +export async function checkNativeFirstPaint(page, url) { + let release + const delayedScript = new Promise((resolve) => { release = resolve }) + const pendingRoutes = [] + const routeScript = (route) => { + const pending = delayedScript.then(() => route.continue()) + pendingRoutes.push(pending) + return pending + } + await page.route('**/_shell/shell.js', routeScript) + try { + const response = await page.goto(url, { waitUntil: 'commit' }) + assert(response?.ok(), `Native page: HTTP ${response?.status()}`) + await page.locator('article h1').waitFor({ state: 'visible' }) + assert(await page.locator('[data-lt-shell="header"]').isVisible(), 'Native header is visible before shell.js arrives') + assert.equal(await page.evaluate(() => customElements.get('libtmux-version-switcher') !== undefined), false, + 'Native content and navigation are visible while shell.js is still unavailable') + await checkNativeHeader(page) + const initial = await page.locator('article h1').boundingBox() + await page.evaluate(() => { window.__initialNativeHeader = document.querySelector('[data-lt-shell="header"]') }) + release() + await page.waitForLoadState('networkidle') + assert.deepEqual(await page.locator('article h1').boundingBox(), initial, `Native first paint stays still at ${page.viewportSize().width}px`) + assert(await page.evaluate(() => window.__initialNativeHeader === document.querySelector('[data-lt-shell="header"]')), + 'Enhancement preserves the initial header') + assert.equal(await page.locator('[data-lt-shell="header"]').count(), 1) + } finally { + release() + await Promise.all(pendingRoutes) + await page.unroute('**/_shell/shell.js', routeScript) + } +} + +/** Exercise the real native adapter before, during and after script loading. */ +export async function checkNativeLayout(browser) { + const directory = mkdtempSync(join(tmpdir(), 'native-first-paint-')) + const root = '/pr-42/en', version = 'v0.62.0', pagePath = `${root}/py/${version}/api/session/` + const pageFile = join(directory, 'session/index.html') + mkdirSync(join(directory, 'session')) + writeFileSync(pageFile, '

Sessions

Read the native reference while its controls load.

Session
') + let server + try { + await normalizeNativeShell(directory, root, { sphinxPort: 'py', version }) + const assets = new Map([ + [pagePath, ['text/html', readFileSync(pageFile)]], + [`${root}/py/${version}/api/_static/libtmux-org.css`, ['text/css', readFileSync(join(directory, '_static/libtmux-org.css'))]], + ...['shell.js', 'tokens.css'].map((file) => [`${root}/_shell/${file}`, [file.endsWith('.js') ? 'text/javascript' : 'text/css', readFileSync(new URL(`../public/_shell/${file}`, import.meta.url))]]), + [`${root}/versions.json`, ['application/json', JSON.stringify({ schema: 1, defaultVersion: { ts: 'stable' }, ports: { + py: [{ slug: version, label: version, supported: true }, { slug: 'v0.63.0rc1', label: 'v0.63.0rc1', supported: true }], + } })]], + [`${root}/page-links.json`, ['application/json', JSON.stringify({ schema: 1, symbols: { py: {} }, indexes: {} })]], + ]) + server = createServer((request, response) => { + const asset = assets.get(request.url) + response.writeHead(asset ? 200 : 404, { 'Content-Type': asset?.[0] ?? 'text/plain', 'Cache-Control': 'no-store' }) + response.end(asset?.[1] ?? 'Not found') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${server.address().port}${pagePath}` + for (const width of [1440, 768, 688, 390]) { + const context = await browser.newContext({ viewport: { width, height: 900 } }) + try { + const page = await context.newPage() + await checkNativeFirstPaint(page, url) + assert.equal(await page.locator('libtmux-version-switcher option').count(), 2) + assert.equal(await page.locator('[data-port-home="ts"]').first().getAttribute('href'), `${root}/ts/stable/`) + } finally { + await context.close() + } + const noScript = await browser.newContext({ javaScriptEnabled: false, viewport: { width, height: 900 } }) + try { + const page = await noScript.newPage() + await page.goto(url) + assert(await page.locator('article h1').isVisible()) + await checkNativeHeader(page) + await page.locator('[data-page-port-switcher] summary').click() + assert(await page.locator('[data-page-port-switcher] a[aria-current]').isVisible(), 'Native disclosure works without JavaScript') + } finally { + await noScript.close() + } + } + console.log('Native first paint: compact header, visible content, stable geometry, enhanced controls and no-JS navigation at 1440/768/688/390px') + } finally { + if (server) await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/site/scripts/check-native-shell.mjs b/site/scripts/check-native-shell.mjs index 5ea3badb..8baa15a0 100644 --- a/site/scripts/check-native-shell.mjs +++ b/site/scripts/check-native-shell.mjs @@ -1,36 +1,54 @@ #!/usr/bin/env node import assert from 'node:assert/strict' import { chromium } from 'playwright' +import { checkNativeFirstPaint } from './check-native-layout.mjs' const base = (process.argv.find((arg) => arg.startsWith('http')) ?? 'http://localhost:8080/en').replace(/\/$/, '') +const version = process.argv.find((arg) => arg.startsWith('--version='))?.slice('--version='.length) ?? 'stable' const browser = await chromium.launch({ channel: process.env.LIBTMUX_DOCS_BROWSER_CHANNEL }) try { - const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + const page = await browser.newPage() page.setDefaultTimeout(10000) const loaded = new Set() page.on('response', (response) => { if (response.ok()) loaded.add(response.url()) }) - const response = await page.goto(`${base}/py/stable/api/api/libtmux.session/`) - 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$="/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() - const bounds = await menu.evaluate((details) => { - const current = details.querySelector('summary').getBoundingClientRect() - const locale = details.nextElementSibling.querySelector('summary').getBoundingClientRect() - const panel = details.querySelector('ul').getBoundingClientRect() - return { sameRow: Math.abs(current.y - locale.y) <= 1, left: panel.left, right: panel.right, viewport: innerWidth } - }) - assert(bounds.sameRow && bounds.left >= 0 && bounds.right <= bounds.viewport, 'Native dropdowns split rows or leave the viewport') + for (const width of [1440, 768, 688, 390]) { + for (const colorScheme of ['light', 'dark']) { + await page.setViewportSize({ width, height: 900 }) + await page.emulateMedia({ colorScheme }) + await checkNativeFirstPaint(page, `${base}/py/${version}/api/api/libtmux.session/`) + const menu = page.locator('[data-page-port-switcher]') + await menu.waitFor() + 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').focus() + await page.keyboard.press('Enter') + const bounds = await menu.evaluate((details) => { + const current = details.querySelector('summary').getBoundingClientRect() + const locale = details.nextElementSibling.querySelector('summary').getBoundingClientRect() + const panel = details.querySelector('ul') + const rect = panel.getBoundingClientRect() + const covered = [...panel.querySelectorAll('li')].filter((item) => { + const row = item.getBoundingClientRect() + return [rect.left + 12, rect.right - 12].some((x) => !item.contains(document.elementFromPoint(x, row.top + row.height / 2))) + }).map((item) => item.textContent.trim()) + return { open: details.open, sameRow: Math.abs(current.y - locale.y) <= 1, left: rect.left, right: rect.right, viewport: innerWidth, covered } + }) + const context = `${width}px ${colorScheme}` + assert(bounds.open, `${context}: keyboard did not open the port dropdown`) + assert(bounds.sameRow && bounds.left >= 0 && bounds.right <= bounds.viewport, `${context}: native dropdowns split rows or leave the viewport`) + assert.deepEqual(bounds.covered, [], `${context}: native content covers port menu entries`) + await page.keyboard.press('Enter') + assert.equal(await menu.evaluate((details) => details.open), false, `${context}: keyboard did not close the port dropdown`) + } + } await page.evaluate(() => { location.hash = 'sessions' }) 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$="/ts/latest/reference/session-session-windows/"]')) - console.log('Native shell: script, tokens, phone dropdowns and class/member equivalents passed') + console.log('Native shell: compact header, stable first paint, assets, keyboard, unobscured dropdowns at 1440/768/688/390px in light/dark, and class/member equivalents passed') } finally { await browser.close() } diff --git a/site/scripts/check-navigation.mjs b/site/scripts/check-navigation.mjs index 105b93fb..653f797c 100644 --- a/site/scripts/check-navigation.mjs +++ b/site/scripts/check-navigation.mjs @@ -1,5 +1,70 @@ import assert from 'node:assert/strict' +/** Keep the API drawer usable after the router replaces the document. */ +export async function checkApiNavigation(page, base) { + const server = `${base}/lua/latest/reference/libtmux-server/` + const snapshot = `${base}/lua/latest/reference/libtmux-server-snapshot/` + for (const width of [688, 390]) { + await page.setViewportSize({ width, height: 759 }) + await page.goto(server, { waitUntil: 'load' }) + await page.waitForLoadState('networkidle') + await page.evaluate(() => { window.__apiNavigationProbe = true }) + await page.locator('.api-member-link[href$="libtmux-server-snapshot/"]').click() + await page.waitForURL(snapshot) + assert(await page.evaluate(() => window.__apiNavigationProbe), 'API navigation retains the document') + assert(await page.locator('html').evaluate((el) => el.hasAttribute('data-api-nav')), + `API navigation restores the drawer styles at ${width}px`) + const nav = page.locator('#api-nav') + const toggle = page.locator('[data-api-nav-toggle]') + await nav.waitFor({ state: 'hidden' }) + const open = async () => { + await toggle.click() + await page.waitForFunction(() => document.querySelector('#api-nav').getBoundingClientRect().left >= 0) + assert.equal(await toggle.getAttribute('aria-expanded'), 'true') + } + for (const close of ['button', 'Escape', 'overlay']) { + await open() + await nav.locator('[role="tree"]').evaluate((el) => { el.scrollTop = el.scrollHeight }) + if (close === 'button') await nav.locator('[data-api-nav-close]').click() + else if (close === 'Escape') await page.keyboard.press('Escape') + else await page.locator('[data-api-nav-overlay]').click({ position: { x: width - 2, y: 400 } }) + await nav.waitFor({ state: 'hidden' }) + assert.equal(await toggle.getAttribute('aria-expanded'), 'false') + assert.equal(await page.evaluate(() => document.body.style.overflow), '') + } + await page.goBack() + await page.waitForURL(server) + await open() + const menu = nav.locator('.api-nav__menu') + const summary = menu.locator(':scope > summary') + const row = await summary.boundingBox(), section = await menu.boundingBox() + assert(Math.abs(row.y + row.height / 2 - section.y - section.height / 2) <= 1, + `Documentation disclosure is vertically centered at ${width}px`) + await summary.click() + assert.equal(await menu.evaluate((el) => el.open), true) + await summary.click() + assert.equal(await menu.evaluate((el) => el.open), false) + await nav.locator('[data-api-nav-close]').click() + } + for (const path of ['', 'guides/overview/']) { + await page.goto(`${base}/lua/latest/${path}`, { waitUntil: 'load' }) + await page.locator('#mobile-sidebar-toggle').click() + await page.locator('#mobile-sidebar a[href$="/reference/"]').click() + await page.waitForURL(`${base}/lua/latest/reference/`) + await page.locator('.api-index-card__link[href$="/reference/libtmux-server/"]').click() + await page.waitForURL(server) + assert(await page.locator('[data-api-nav-toggle]').isVisible(), `${path || 'Port home'} to API keeps the drawer toggle`) + await page.locator('[data-api-nav-toggle]').click() + await page.locator('[data-api-nav-close]').click() + await page.locator('#api-nav').waitFor({ state: 'hidden' }) + } + await page.setViewportSize({ width: 1440, height: 900 }) + await page.locator('#api-nav').waitFor({ state: 'visible' }) + assert.equal(await page.locator('#api-nav').evaluate((el) => el.inert), false) + assert.equal(await page.locator('[data-api-nav-toggle]').isVisible(), false) + console.log('API navigation: client swaps, Back, drawer close controls, disclosure alignment and desktop pass') +} + /** Check the controls attached to a document after its content is replaced. */ export async function checkNavigation(page, base) { await page.goto(`${base}/examples/attach-and-send-keys/`, { waitUntil: 'load' }) diff --git a/site/src/components/PortHero.astro b/site/src/components/PortHero.astro index 854cd711..108d3bf7 100644 --- a/site/src/components/PortHero.astro +++ b/site/src/components/PortHero.astro @@ -1,7 +1,7 @@ --- import { branding } from '../lib/branding' import { withRoot } from '../lib/site-root' -import { defaultVersionFor } from '../lib/versions' +import { buildTarget, defaultVersionFor } from '../lib/versions' import PortLinks from './PortLinks.astro' import { hasReference, PORT_BY_SLUG, portPageUrl, type Port } from '../lib/ports' @@ -29,6 +29,8 @@ interface Props { const { port } = Astro.props as Props const brand = branding(port.slug) +const { version } = buildTarget(process.env) +const revision = process.env.LIBTMUX_DOCS_PORT === port.slug ? process.env.LIBTMUX_DOCS_SOURCE_SHA : undefined ---
@@ -51,7 +53,7 @@ const brand = branding(port.slug) footer, pointing at the organisation rather than at this language, and the registry was nowhere. */} - + { hasReference(port) ? null : ( diff --git a/site/src/components/PortLinks.astro b/site/src/components/PortLinks.astro index 62eeed07..ab537797 100644 --- a/site/src/components/PortLinks.astro +++ b/site/src/components/PortLinks.astro @@ -1,6 +1,6 @@ --- import RegistryIcon from './icons/RegistryIcon.astro' -import type { DocProduct, Port } from '../lib/ports' +import { portSourceUrl, type DocProduct, type Port } from '../lib/ports' interface Props { port: Port @@ -18,7 +18,9 @@ const repo = source?.repo ?? port.repo const taggedVersion = /^v?\d/.test(version) && repo === port.repo ? `${port.tagPrefix ?? ''}${version}` : undefined const ref = version === 'latest' ? source?.ref : taggedVersion ?? revision ?? source?.ref -const sourceUrl = source ? `https://github.com/${repo}/tree/${ref}/${source.path}` : `https://github.com/${repo}` +const sourceUrl = product + ? source ? `https://github.com/${repo}/tree/${ref}/${source.path}` : `https://github.com/${repo}` + : portSourceUrl(port, version, revision) const registry = product ? pkg?.registry && port.registry ? { ...port.registry, url: pkg.registry } : undefined : port.registry diff --git a/site/src/components/SearchModal.astro b/site/src/components/SearchModal.astro index eae0381f..4420b42d 100644 --- a/site/src/components/SearchModal.astro +++ b/site/src/components/SearchModal.astro @@ -1,6 +1,13 @@ --- import SearchPanel from './SearchPanel.astro' -import { withRoot } from '../lib/site-root' +import { withPortRoot, withRoot } from '../lib/site-root' +import { PORT_BY_SLUG } from '../lib/ports' + +interface Props { + port?: string + version: string +} +const { port, version } = Astro.props /** * Search without leaving the page. @@ -10,11 +17,11 @@ import { withRoot } from '../lib/site-root' * /search/, and this intercepts the click — with no JavaScript the link still * works, which is why it is a link and not a button. */ -const searchHref = withRoot('/search/') +const searchHref = port ? withPortRoot(`/${port}/${version}/search/`) : withRoot('/search/') --- - +