Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7497839
feat(api-model[ocaml]) Add native OCaml documentation
tony Nov 7, 2026
7bc15b2
perf(site[api-links]) Bound parser and render setup costs
tony Nov 7, 2026
d482cbc
perf(site[workspace]) Run isolated picker checks concurrently
tony Nov 7, 2026
2d68bbc
ci(docs[ocaml]) Check out the pinned guide source
tony Nov 7, 2026
ba2acc1
feat(scripts[opam]) Read OCaml package registry versions
tony Nov 7, 2026
3765b85
feat(site[ocaml]) Register the qualified docs caller
tony Nov 7, 2026
ef1c8ff
fix(site[ocaml]) Align generated documentation inventories
tony Nov 7, 2026
3259831
perf(site[styles]) Bound Tailwind to rendering sources
tony Nov 8, 2026
5b1736d
test(ci[sources]) Verify emitted documentation revisions
tony Nov 8, 2026
672f8eb
docs(fix[ocaml]) Resolve native prose references precisely
tony Nov 8, 2026
52a10df
docs(feat[ocaml]) Add the retained-operation recovery guide
tony Nov 8, 2026
bb3a159
docs(chore[sync]) Integrate current shared-site behavior
tony Nov 8, 2026
0e9c376
docs(fix[index]) Refresh OCaml guide mentions
tony Nov 8, 2026
4ce7a8d
docs(fix[ocaml]) Refresh manifest provenance with the guides
tony Nov 8, 2026
0836aca
test(fix[ocaml]) Guard missing declaration identities
tony Nov 8, 2026
6955e6f
docs(feat[ocaml]) Include mixed-workload evidence and limits
tony Nov 8, 2026
8f256b4
fix(site) Resolve OCaml documentation routes
tony Nov 8, 2026
d3f937e
test(ocaml) Verify native server connection modules
tony Nov 8, 2026
32e0c7f
fix(ocaml) Align workspace pages with native guides
tony Nov 8, 2026
2f393ee
test(products) Derive page contracts from port metadata
tony Nov 8, 2026
896d7e7
chore(ocaml) Integrate shared product navigation
tony Nov 8, 2026
7cd2b3b
fix(ocaml[links]) Resolve members through public module aliases
tony Nov 8, 2026
6ece483
docs(ocaml) Refresh lifecycle and query interchange documentation
tony Nov 8, 2026
e5bc45b
docs(ocaml) Refresh support policy and retained-outcome examples
tony Nov 8, 2026
7d4f88f
docs(ocaml[guides]) Refresh tested bootstrap and support evidence
tony Nov 8, 2026
1e8e9f8
Merge branch 'main' of https://github.com/libtmux/docs into feat/ocam…
tony Nov 9, 2026
a250f81
test(site[pickers]) Select canonical product destinations
tony Nov 9, 2026
301038c
fix(site[ocaml]) Verify variant constructor destinations
tony Nov 9, 2026
52209f1
test(site[ocaml]) Record rendered reference baselines
tony Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/port-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,22 @@ jobs:
mix deps.get --only prod
mix run --no-start "$EXPORTER" _build/docs/api.json

- if: matrix.port == 'ocaml'
uses: ocaml/setup-ocaml@ff66428f72b64472e749b22e0ad4dded4b2ddca6 # v3.9.1
with:
ocaml-compiler: ocaml-base-compiler.5.5.2
opam-local-packages: port/*.opam
- name: Export OCaml API from the selected source
if: matrix.port == 'ocaml'
working-directory: port
env:
LIBTMUX_API_EXPORT: '1'
run: |
set -euo pipefail
opam install . --deps-only --with-doc --yes
opam exec -- dune build @install tools/api_export.exe
opam exec -- python3 tools/export_api.py --output _build/docs/ocaml.json

- if: matrix.port == 'ruby'
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/reusable-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ jobs:
echo "::error::path-prefix '$p' must be an unrooted path using letters, digits, '.', '_', '-' and '/', without '..'" >&2
exit 1
fi
reserved=(py ruby lua powershell julia elixir ts rs go java kotlin scala csharp fsharp cxx swift manifest _shell)
reserved=(py ruby lua powershell julia elixir ts rs go java kotlin scala csharp fsharp cxx swift ocaml manifest _shell)
for r in "${reserved[@]}"; do
if [[ "$p" == "$r" ]]; then
echo "::error::path-prefix '$p' is a bare reserved top-level name — qualify it, e.g. '$r/latest'" >&2
Expand All @@ -153,7 +153,7 @@ jobs:
done
if [[ -n "${PORT_IN:-}" ]]; then
case "$PORT_IN" in
py|ruby|lua|powershell|julia|elixir|ts|rs|go|java|kotlin|scala|csharp|fsharp|cxx|swift) ;;
py|ruby|lua|powershell|julia|elixir|ts|rs|go|java|kotlin|scala|csharp|fsharp|cxx|swift|ocaml) ;;
*) echo "::error::unknown port '$PORT_IN'" >&2; exit 1 ;;
esac
if [[ ! "$VERSION_IN" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ || "$VERSION_IN" == *..* ]]; then
Expand Down
25 changes: 19 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@ jobs:
- name: Resolve integrated guide sources
id: integrated-sources
run: |
echo "ruby=$(jq -r .revision site/src/data/api/ruby.json)" >> "$GITHUB_OUTPUT"
echo "lua=$(jq -r .revision site/src/data/api/lua.json)" >> "$GITHUB_OUTPUT"
echo "powershell=$(jq -r .revision site/src/data/api/powershell.json)" >> "$GITHUB_OUTPUT"
echo "julia=$(jq -r .revision site/src/data/api/julia.json)" >> "$GITHUB_OUTPUT"
echo "elixir=$(jq -r .revision site/src/data/api/elixir.json)" >> "$GITHUB_OUTPUT"
{
echo "ruby=$(jq -r .revision site/src/data/api/ruby.json)"
echo "lua=$(jq -r .revision site/src/data/api/lua.json)"
echo "powershell=$(jq -r .revision site/src/data/api/powershell.json)"
echo "julia=$(jq -r .revision site/src/data/api/julia.json)"
echo "elixir=$(jq -r .revision site/src/data/api/elixir.json)"
echo "ocaml=$(jq -r .revision site/src/data/api/ocaml.json)"
} >> "$GITHUB_OUTPUT"

- uses: actions/checkout@v7
with:
Expand Down Expand Up @@ -89,6 +92,12 @@ jobs:
ref: ${{ steps.integrated-sources.outputs.elixir }}
path: .port-sources/elixir
persist-credentials: false
- uses: actions/checkout@v7
with:
repository: libtmux/libtmux-ocaml
ref: ${{ steps.integrated-sources.outputs.ocaml }}
path: .port-sources/ocaml
persist-credentials: false

# The exporter tests compile a fixture with the package's own toolchain:
# the exact pair the integrated Elixir source pins, read as port-docs.yml
Expand All @@ -112,6 +121,7 @@ jobs:
LIBTMUX_DOCS_CHECKOUT_POWERSHELL: ${{ github.workspace }}/.port-sources/powershell
LIBTMUX_DOCS_CHECKOUT_JULIA: ${{ github.workspace }}/.port-sources/julia
LIBTMUX_DOCS_CHECKOUT_ELIXIR: ${{ github.workspace }}/.port-sources/elixir
LIBTMUX_DOCS_CHECKOUT_OCAML: ${{ github.workspace }}/.port-sources/ocaml
run: |
node scripts/stage-port-docs.mjs --from-source
node scripts/stage-port-docs.mjs --cached --check
Expand All @@ -131,12 +141,15 @@ jobs:
PREVIEW_PREFIX: pr-${{ github.event.pull_request.number }}
LIBTMUX_DOCS_CHECKOUT_RUBY: ${{ github.workspace }}/.port-sources/ruby
LIBTMUX_DOCS_CHECKOUT_LUA: ${{ github.workspace }}/.port-sources/lua
LIBTMUX_DOCS_CHECKOUT_OCAML: ${{ github.workspace }}/.port-sources/ocaml
# PowerShell, Julia and Elixir paths stay scoped to guide staging
# above, which checks the cache used by ordinary builds. Native API
# and MCP export freshness remain owning-port checks, not claims of
# this root audit. Elixir's exporter tests run in the unit step; its
# committed model is derived again by port-docs.yml at publication.
LIBTMUX_DOCS_SKIP_NATIVE_MODEL_PORTS: ruby,lua,powershell,julia,elixir
# OCaml's checkout also feeds source-derived usage checks. Its
# compiled API is qualified by the owning port's exporter job.
LIBTMUX_DOCS_SKIP_NATIVE_MODEL_PORTS: ruby,lua,powershell,julia,elixir,ocaml

- name: Upload audited preview
if: github.event_name == 'pull_request'
Expand Down
9 changes: 9 additions & 0 deletions packages/api-model/src/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ const ELIXIR_BUILTIN_TYPES = [
]

export const BUILTINS: Record<string, Record<string, string>> = {
ocaml: {
Iarray: 'https://ocaml.org/manual/5.5/api/Iarray.html',
'Iarray.t': 'https://ocaml.org/manual/5.5/api/Iarray.html#TYPEt',
List: 'https://ocaml.org/manual/5.5/api/List.html',
Seq: 'https://ocaml.org/manual/5.5/api/Seq.html',
'Seq.t': 'https://ocaml.org/manual/5.5/api/Seq.html#TYPEt',
'Seq.filter': 'https://ocaml.org/manual/5.5/api/Seq.html#VALfilter',
Unix: 'https://ocaml.org/manual/5.5/api/Unix.html',
},
elixir: {
...Object.fromEntries(ELIXIR_BASIC_TYPES.map((name) => [name, elixirDocs('typespecs.html#basic-types')])),
...Object.fromEntries(ELIXIR_BUILTIN_TYPES.map((name) => [name, elixirDocs('typespecs.html#built-in-types')])),
Expand Down
69 changes: 69 additions & 0 deletions packages/api-model/src/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3824,6 +3824,74 @@ for (const [id, binding] of Object.entries(ELIXIR_CONCEPTS)) {
if (binding.note) (concept.notes ??= {}).elixir = binding.note
}
CONCEPTS['list-session-panes']!.absent!.julia = 'Traverse `windows(session)` and `panes(window)` in the captured snapshot. `panes(snapshot)` has server-wide scope.'

const OCAML_CONCEPTS: Record<string, { symbol: string; note?: string; variants?: string[] }> = {
server: { symbol: 'Libtmux_unix.Client.t', variants: ['Libtmux_eio.Client.t'], note: 'A client is a live, generation-bound server connection. Endpoint describes a socket without connecting; closing a client releases its resources.' },
session: { symbol: 'Libtmux.Session.t' },
window: { symbol: 'Libtmux.Window.t', note: 'A global window can have several session placements. Window_link holds the session, index and current flag.' },
pane: { symbol: 'Libtmux.Pane.t' },
'workspace-description': { symbol: 'Libtmux_workspace.Config.t' },
'build-workspace': { symbol: 'Libtmux_unix.Workspace.await', variants: ['Libtmux_eio.Workspace.await'], note: 'Apply a validated Plan through submit, then await its retained execution receipt. Inspect preserves partial and late outcomes.' },
snapshot: { symbol: 'Libtmux_unix.Client.capture', variants: ['Libtmux_eio.Client.capture'], note: 'Acquires a complete immutable graph over an interval. Earlier observations remain readable without refreshing.' },
'list-sessions': { symbol: 'Libtmux.Snapshot.sessions', note: 'Reads an immutable array of already captured sessions without I/O.' },
'list-windows': { symbol: 'Libtmux.Session.windows', note: 'Reads distinct captured global windows in this session. window_links preserves individual indexed placements.' },
'list-panes': { symbol: 'Libtmux.Window.panes', note: 'Reads captured panes in one global window without I/O.' },
'list-server-windows': { symbol: 'Libtmux.Snapshot.windows', note: 'Reads captured global windows; use window_links for indexed session placements.' },
'list-server-panes': { symbol: 'Libtmux.Snapshot.panes' },
'list-clients': { symbol: 'Libtmux.Snapshot.clients', note: 'Reads captured attached-client observations, separate from transport Client.t handles.' },
'pane-window': { symbol: 'Libtmux.Pane.window' },
'window-session': { symbol: 'Libtmux.Window_link.session', note: 'The session belongs to a placement. A global Window has no single containing session.' },
'window-link-window': { symbol: 'Libtmux.Window_link.window' },
'client-session': { symbol: 'Libtmux.Client_observation.session', note: 'Returns the captured session when the attached client had one.' },
'get-session': { symbol: 'Libtmux.Snapshot.find_session', note: 'Exact opaque-ID lookup in an existing snapshot, without I/O or tmux target expansion.' },
'get-window': { symbol: 'Libtmux.Snapshot.find_window', note: 'Exact opaque-ID lookup in an existing snapshot, without I/O.' },
'get-pane': { symbol: 'Libtmux.Snapshot.find_pane', note: 'Exact opaque-ID lookup in an existing snapshot, without I/O.' },
'window-index': { symbol: 'Libtmux.Window_link.index' },
'window-active': { symbol: 'Libtmux.Window_link.is_current' },
}
for (const [concept, operation] of Object.entries({
'capture-pane': 'capture_text', 'send-keys': 'send_keys', 'split-pane': 'split_pane',
'respawn-pane': 'respawn_pane', 'clear-history': 'clear_history', 'set-pane-title': 'set_pane_title',
'select-layout': 'select_layout', 'rotate-panes': 'rotate_panes', 'unlink-window': 'unlink_window_at',
'new-session': 'create_session', 'new-window': 'create_window', 'kill-session': 'kill_session',
'kill-window': 'kill_window', 'kill-pane': 'kill_pane', 'rename-session': 'rename_session',
'rename-window': 'rename_window', 'select-window': 'select_window_at', 'select-pane': 'select_pane',
'resize-pane': 'resize_pane', 'resize-window': 'resize_window', 'link-window': 'link_window',
'move-window': 'move_window_at', 'break-pane': 'break_pane', 'move-pane': 'move_pane',
'join-pane': 'move_pane', 'swap-pane': 'swap_panes', 'detach-client': 'request_detach_client_at',
'refresh-client': 'refresh_client_at', 'switch-client': 'switch_client_at',
'wait-for': 'wait_for', 'server-version': 'version',
})) OCAML_CONCEPTS[concept] = { symbol: `Libtmux_unix.Client.${operation}`, variants: [`Libtmux_eio.Client.${operation}`] }
for (const [prefix, owner, members] of [
['session', 'Session', ['id', 'name', 'attached']],
['window', 'Window', ['id', 'name', 'height', 'width', 'layout']],
['pane', 'Pane', ['id', 'index', 'height', 'width', 'title', 'active', 'current_command', 'current_path', 'pid', 'dead']],
] as const) for (const member of members) OCAML_CONCEPTS[`${prefix}-${member.replaceAll('_', '-')}`] = { symbol: `Libtmux.${owner}.${member}` }
for (const scope of ['server', 'session', 'window', 'pane']) {
for (const [suffix, operation] of Object.entries({ options: 'capture_options', option: 'read_option', hooks: 'capture_hooks' })) {
OCAML_CONCEPTS[`${scope}-${suffix}`] = { symbol: `Libtmux_unix.Client.${operation}`, variants: [`Libtmux_eio.Client.${operation}`], note: 'Pass the corresponding explicit Options scope; captured tables preserve their source and inheritance policy.' }
}
for (const [prefix, operation] of Object.entries({ 'set-': 'set_option', 'unset-': 'unset_option' })) OCAML_CONCEPTS[`${prefix}${scope}-option`] = { symbol: `Libtmux_unix.Client.${operation}`, variants: [`Libtmux_eio.Client.${operation}`] }
for (const [prefix, operation] of Object.entries({ 'set-': 'set_hook', 'unset-': 'remove_hook' })) OCAML_CONCEPTS[`${prefix}${scope}-hook`] = { symbol: `Libtmux_unix.Client.${operation}`, variants: [`Libtmux_eio.Client.${operation}`] }
}
for (const scope of ['server', 'session']) {
OCAML_CONCEPTS[`${scope}-environment`] = { symbol: 'Libtmux_unix.Client.capture_environment', variants: ['Libtmux_eio.Client.capture_environment'], note: 'Pass Environment.Global for server values or Environment.Session with a bound session target. Acquisition is explicit.' }
for (const [prefix, operation] of Object.entries({ 'set-': 'set_environment', 'unset-': 'unset_environment', 'remove-': 'remove_environment' })) OCAML_CONCEPTS[`${prefix}${scope}-environment`] = { symbol: `Libtmux_unix.Client.${operation}`, variants: [`Libtmux_eio.Client.${operation}`] }
}
for (const [id, binding] of Object.entries(OCAML_CONCEPTS)) {
const concept = CONCEPTS[id]
if (!concept) throw new Error(`concepts: OCaml maps unknown concept ${id}`)
concept.symbols.ocaml = binding.symbol
if (binding.note) (concept.notes ??= {}).ocaml = binding.note
if (binding.variants) (concept.variants ??= {}).ocaml = binding.variants
}
for (const [id, reason] of Object.entries({
'freeze-workspace': 'The workspace packages validate and apply descriptions; they do not export a live session as configuration.',
'list-session-panes': 'Traverse Session.windows and Window.panes in the captured graph. Snapshot.panes has server-wide scope.',
'pane-session': 'A pane belongs to a global window that can have several session placements. Traverse Window.links and Window_link.session.',
'kill-server': 'Client.close releases transport resources. The public client API does not terminate a borrowed tmux daemon.',
'server-from-env': 'Endpoint selection is explicit. Construct a named socket or socket path; the library does not infer an ambient server.',
})) (CONCEPTS[id]!.absent ??= {}).ocaml = reason
for (const concept of Object.values(CONCEPTS)) {
concept.absent ??= {}
if (!concept.symbols.powershell) concept.absent.powershell ??= 'No source-verified PowerShell equivalent is recorded for this operation.'
Expand All @@ -3834,6 +3902,7 @@ for (const concept of Object.values(CONCEPTS)) {
if (!concept.symbols.kotlin) concept.absent.kotlin = 'No source-verified Kotlin equivalent is recorded for this operation.'
if (!concept.symbols.scala) concept.absent.scala = 'No source-verified Scala equivalent is recorded for this operation.'
if (!concept.symbols.fsharp) concept.absent.fsharp = 'No source-verified F# equivalent is recorded for this operation.'
if (!concept.symbols.ocaml) concept.absent.ocaml ??= 'No direct public OCaml equivalent is recorded for this operation.'
}

/** Every concept naming this symbol, including concepts sharing an overload group. */
Expand Down
20 changes: 19 additions & 1 deletion packages/api-model/src/doc/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type DocSpan =
target: string
/** What to show. `~` means "last segment only"; explicit titles win. */
label: string
/** Custom odoc labels are prose rather than code. */
code?: boolean
}

/**
Expand Down Expand Up @@ -296,6 +298,7 @@ const SYNTAX: Record<string, ReadonlySet<string>> = {
// ExDoc links a Markdown destination written as code: [the server](`LibTmux.Server`).
elixir: new Set(['markdown-link', 'exdoc']),
powershell: new Set(['markdown-link']),
ocaml: new Set(['odoc', 'markdown-link']),
}

/**
Expand Down Expand Up @@ -352,6 +355,21 @@ function tokenizeRun(text: string, lang?: string): DocSpan[] {
type Hit = { start: number; end: number; span: DocSpan }
const hits: Hit[] = []

if (syntax.has('odoc')) {
// The native exporter preserves references and converts odoc code and URL links to Markdown.
for (const match of text.matchAll(/\{\{!([^{}\s]+)\}([^{}]*)\}|\{!([^{}\s]+)\}/g)) {
let target = match[1] ?? match[3]!
const legacy = /^(type|val|module|module-type|class|class-type|exception|constructor|field):(.+)$/.exec(target)
const typed = /(?:^|\.)(module-type|class-type|type|val|value|module|class|exception|exn|constructor|const|field|recfield)-([^.]+)$/.exec(target)
const kind = legacy?.[1] ?? typed?.[1]
if (legacy) target = legacy[2]!
else if (typed) target = target.slice(0, typed.index) + (typed[0].startsWith('.') ? '.' : '') + typed[2]!
const label = match[1] ? match[2]! : target.replace(/(?:^|(?<=\.))(?:module-type|class-type|type|val|value|module|class|exception|exn|constructor|const|field|recfield)-/g, '')
hits.push({ start: match.index, end: match.index + match[0].length,
span: { kind: 'ref', role: `odoc:${kind ?? 'any'}`, target, label, ...(match[1] ? { code: false } : {}) } })
}
}

if (syntax.has('documenter')) {
// Documenter's named @ref destination contains spaces that Markdown URLs cannot.
for (const match of text.matchAll(/\[(?:(`+)([^\n]*?)\1|([^\]\n]+))\]\(@ref\s+([^\n)]+)\)/g)) {
Expand Down Expand Up @@ -599,7 +617,7 @@ function tokenizeRun(text: string, lang?: string): DocSpan[] {
}
} else {
for (const code of markdownCode(text, masked.join(''))) {
const span: DocSpan = code.ticks === 1
const span: DocSpan = syntax.has('odoc') ? { kind: 'code', text: code.text } : code.ticks === 1
? { kind: 'ref', role: 'any', target: syntax.has('intra-doc') && RUST_CODE_PATH_RE.test(code.text) ? rustTarget(code.text) : code.text, label: code.text }
: syntax.has('docc') && code.ticks === 2
? { kind: 'ref', role: 'any', target: doccTarget(code.text), label: code.text }
Expand Down
1 change: 1 addition & 0 deletions packages/api-model/src/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const DOMAIN: Record<PortSlug, string> = {
kotlin: 'std',
scala: 'std',
fsharp: 'std',
ocaml: 'std',
csharp: 'std',
swift: 'std',
}
Expand Down
Loading
Loading