From d1d44a83bd00b0c1c8c95d15742f27e7c939f107 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Nov 2026 17:35:41 -0600 Subject: [PATCH 1/6] badges(fix[services]) Render LuaRocks, Hex and Maven Central version badges why: The lua, elixir, kotlin and scala docs builds failed with "unsupported badge" because their READMEs carry luarocks, hexpm and maven-central shields, which badges.mjs had no mapping for. The Java README's Maven Central badges would have failed the same way. what: - Replace the version-service label map with entries holding shields' default label and the number of qualifier segments before the package (LuaRocks user, Maven group). - Add luarocks (luarocks), hexpm (hex) and maven-central (maven-central), resolved through registryVersion, which already falls back to the port entry's own version when it lists no packages. - Test each service against a port entry without packages, with and without a label query, and that an unmapped path on these services still throws. --- scripts/lib/badges.mjs | 29 +++++++++++++++++++---------- site/test/port-guide-badges.test.ts | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/scripts/lib/badges.mjs b/scripts/lib/badges.mjs index 969abafa6..f5a03cf26 100644 --- a/scripts/lib/badges.mjs +++ b/scripts/lib/badges.mjs @@ -18,14 +18,22 @@ import { toMarkdown } from 'mdast-util-to-markdown' const root = join(dirname(fileURLToPath(import.meta.url)), '../..') export const BADGE_DIRECTORY = 'badges' -/** Registries whose `//v/` badge reports a published version. */ +/** + * Registries whose `//v/.../` badge reports a + * published version. `label` is what shields shows without a `label` query; + * `qualifiers` counts the path segments before the package (a LuaRocks user, + * a Maven group). + */ const VERSION_SERVICES = { - gem: 'gem', - crates: 'crates.io', - npm: 'npm', - nuget: 'nuget', - powershellgallery: 'powershell gallery', - pypi: 'pypi', + gem: { label: 'gem', qualifiers: 0 }, + crates: { label: 'crates.io', qualifiers: 0 }, + hexpm: { label: 'hex', qualifiers: 0 }, + luarocks: { label: 'luarocks', qualifiers: 1 }, + 'maven-central': { label: 'maven-central', qualifiers: 1 }, + npm: { label: 'npm', qualifiers: 0 }, + nuget: { label: 'nuget', qualifiers: 0 }, + powershellgallery: { label: 'powershell gallery', qualifiers: 0 }, + pypi: { label: 'pypi', qualifiers: 0 }, } const OFF_ORIGIN = /^https?:\/\/(?:img\.shields\.io|badgen\.net|badge\.fury\.io|(?:www\.)?github\.com\/[^/]+\/[^/]+\/(?:actions\/workflows\/[^/]+|workflows\/[^/]+)\/badge\.svg|github\.com\/[^/]+\/[^/]+\/workflows\/.*\/badge\.svg)/i @@ -84,9 +92,10 @@ export function badgeSpec(url, { port, registry }) { const [, label, message, color] = match return { label: query.label ?? unescapeStatic(label), message: unescapeStatic(message), color: query.color ?? color } } - if (VERSION_SERVICES[segments[0]] && segments[1] === 'v' && segments.length === 3) { - const version = registryVersion(registry, port, decodeURIComponent(segments[2])) - return { label: query.label ?? VERSION_SERVICES[segments[0]], message: addV(version), color: query.color ?? versionColor(version) } + const service = Object.hasOwn(VERSION_SERVICES, segments[0]) ? VERSION_SERVICES[segments[0]] : undefined + if (service && segments[1] === 'v' && segments.length === 3 + service.qualifiers) { + const version = registryVersion(registry, port, decodeURIComponent(segments.at(-1))) + return { label: query.label ?? service.label, message: addV(version), color: query.color ?? versionColor(version) } } throw new Error(`${port}: unsupported badge ${url}; add a mapping to scripts/lib/badges.mjs`) } diff --git a/site/test/port-guide-badges.test.ts b/site/test/port-guide-badges.test.ts index c8e9e5ea9..f72a1429b 100644 --- a/site/test/port-guide-badges.test.ts +++ b/site/test/port-guide-badges.test.ts @@ -68,6 +68,20 @@ describe('localizeBadges', () => { expect(svg).toContain(`aria-label="gem: ${version.startsWith('v') ? version : `v${version}`}"`) }) + it.each([ + ['lua', 'https://img.shields.io/luarocks/v/tony/libtmux', '0.1.0-1', 'luarocks: v0.1.0-1'], + ['elixir', 'https://img.shields.io/hexpm/v/libtmux', '0.1.0', 'hex: v0.1.0'], + ['kotlin', 'https://img.shields.io/maven-central/v/io.github.libtmux/libtmux-kotlin', '0.1.0', 'maven-central: v0.1.0'], + ['scala', 'https://img.shields.io/maven-central/v/io.github.libtmux/libtmux-scala_3', '0.1.0', 'maven-central: v0.1.0'], + ])('renders the %s registry badge from a port entry without packages', (port, url, version, aria) => { + const data = { ports: { [port]: { version } } } + const [svg] = localizeBadges(`![x](${url})`, port, data).files.values() + expect(svg).toContain(`aria-label="${aria}"`) + expect(svg).toContain('fill="#ea7233"') + const [labelled] = localizeBadges(`![x](${url}?label=libtmux)`, port, data).files.values() + expect(labelled).toContain(`aria-label="libtmux: v${version}"`) + }) + it('does not double the v of a registry version that has one', () => { const [svg] = localizeBadges('![a](https://img.shields.io/gem/v/x)', 'go', { ports: { go: { packages: { x: { version: 'v0.1.0' } } } } }).files.values() expect(svg).toContain('aria-label="gem: v0.1.0"') @@ -88,6 +102,8 @@ describe('localizeBadges', () => { it('refuses a badge it cannot render rather than hotlinking it', () => { expect(() => localizeBadges('![x](https://img.shields.io/github/stars/o/r)', 'ruby', registry)).toThrow(/unsupported badge/) + expect(() => localizeBadges('![x](https://img.shields.io/hexpm/dt/libtmux)', 'elixir', { ports: { elixir: { version: '0.1.0' } } })).toThrow(/unsupported badge/) + expect(() => localizeBadges('![x](https://img.shields.io/maven-central/v/libtmux)', 'kotlin', { ports: { kotlin: { version: '0.1.0' } } })).toThrow(/unsupported badge/) expect(() => localizeBadges('![x](https://img.shields.io/gem/v/missing)', 'ruby', registry)).toThrow(/registry data does not carry/) }) }) From 16b3c3f2741b7f337f2e9100da4a694f328b1736 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Nov 2026 17:35:41 -0600 Subject: [PATCH 2/6] build-site(fix[clojure]) Point the Java core checkout at the clojure checkout why: The clojure leg of the Java docs build failed in gen-tmux-usage with "java: no checkout at .../libtmux-java". port-docs.yml exports only LIBTMUX_DOCS_CHECKOUT_CLOJURE, but gen-tmux-usage also derives the Java core the wrapper stands on and looks for Java at its default path, which a clean runner lacks. Clojure lives in the Java repository, so the two checkouts are the same directory. what: - Export LIBTMUX_DOCS_CHECKOUT_JAVA from LIBTMUX_DOCS_CHECKOUT_CLOJURE before gen-tmux-usage runs for the clojure port, keeping an explicit Java override if one is set. --- scripts/build-site.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/build-site.sh b/scripts/build-site.sh index fa3453403..43789f641 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -329,6 +329,12 @@ fi node "$script_dir/stage-port-docs.mjs" --wrappers if [ -n "${LIBTMUX_DOCS_SOURCE_SHA:-}" ]; then if [ "$LIBTMUX_DOCS_PORT" = clojure ]; then + # gen-tmux-usage also reads the Java core the wrapper stands on. Clojure + # lives in the Java repository, so its checkout is the Java checkout; a + # clean CI runner has no other copy at Java's default path. + if [ -n "${LIBTMUX_DOCS_CHECKOUT_CLOJURE:-}" ]; then + export LIBTMUX_DOCS_CHECKOUT_JAVA="${LIBTMUX_DOCS_CHECKOUT_JAVA:-$LIBTMUX_DOCS_CHECKOUT_CLOJURE}" + fi node "$script_dir/gen-tmux-usage.mjs" --ports clojure fi # The selected API and wire contracts must come from the same product source. From 696e8e1c7e9311eb0b38243a04ae9f0353198a2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Nov 2026 19:03:44 -0600 Subject: [PATCH 3/6] CoreShowcase(fix[panels]) Draw each capability once why: OCaml's capability table has a typed-graph entry, and the quickstart panel carries the same capability. Both panels got data-capability-panel="typed-graph". The script selects panels by id, so both showed in one grid cell and the "APIs used" list drew twice. what: - Leave the quickstart's capability out of the table programs when a quickstart exists; the menu and the APIs map are built from the one list - Add a test that every port's panel ids are unique and match the menu --- site/src/components/CoreShowcase.astro | 3 ++- site/test/core-capabilities.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/site/src/components/CoreShowcase.astro b/site/src/components/CoreShowcase.astro index c5ab3c93f..eee4cecbe 100644 --- a/site/src/components/CoreShowcase.astro +++ b/site/src/components/CoreShowcase.astro @@ -32,10 +32,11 @@ interface Props { links?: { href: string; label: string }[] } const { port, links = [] } = Astro.props as Props -const programs = portCapabilities(port, TABLE as CapabilityTable, SOURCES as Record) const quickstart = QUICKSTARTS[port]?.complete const quickstartCapability = CAPABILITIES.find((item) => item.id === (quickstart?.capability ?? 'typed-graph')) if (!quickstartCapability) throw new Error(`CoreShowcase: ${port} names an unknown quickstart capability`) +// The quickstart is the panel for its capability, so a port table entry for the same capability is not a second panel: the script selects panels by id. +const programs = portCapabilities(port, TABLE as CapabilityTable, SOURCES as Record).filter((item) => !quickstart || item.id !== quickstartCapability.id) const quickstartCode = quickstart ? homeProgram(quickstart, 'full') : '' // A recording of this program, run for real, makes the first capability a player; without one the program stands alone. const player = quickstart ? await playableCapability(port, quickstartCapability.id, quickstartCode) : undefined diff --git a/site/test/core-capabilities.test.ts b/site/test/core-capabilities.test.ts index cf68a767d..3871a3e42 100644 --- a/site/test/core-capabilities.test.ts +++ b/site/test/core-capabilities.test.ts @@ -364,6 +364,28 @@ describe('the showcase component', () => { expect(ts.querySelector('[data-apis-for="typed-graph"]'), 'no empty API list when use detection is not qualified').toBeNull() }) + it('draws each capability once on every port, because the script selects panels by id', async () => { + const { Window } = await import('happy-dom') + const { createComponent, renderTemplate } = await import('astro/runtime/server/index.js') + const { loadAstroTemplate, renderAstroTemplate } = await import('./astro-templates') + const Code = createComponent((_result: unknown, props: { code: string }) => renderTemplate`
${props.code}
`) + const template = await loadAstroTemplate('components/CoreShowcase.astro', { + stubs: { + 'components/HomeExample.astro': '---\n---\n
', + 'components/core/CorePlayer.astro': '---\n---\n
', + }, + modules: { 'astro-expressive-code/components': { Code } }, + }) + for (const port of Object.keys(PORT_BY_SLUG)) { + const window = new Window() + window.document.body.innerHTML = await renderAstroTemplate(template, { url: `https://libtmux.org/${port}/latest/`, props: { port } }) + const doc = window.document as unknown as Document + const panels = [...doc.querySelectorAll('[data-capability-panel]')].map((panel) => panel.getAttribute('data-capability-panel')) + expect(new Set(panels).size, `${port} panels ${panels.join(', ')}`).toBe(panels.length) + expect([...doc.querySelectorAll('[data-capability-option]')].map((a) => a.getAttribute('data-capability-option')), port).toEqual(panels.length > 1 ? panels : []) + } + }) + it('plays a capability whose recording is of its cached program, and keeps a plain block for one without', async () => { const { Window } = await import('happy-dom') const { createComponent, renderTemplate } = await import('astro/runtime/server/index.js') From de459bf2ce4a7adf4846de647306ccf8dca8740c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Nov 2026 19:05:55 -0600 Subject: [PATCH 4/6] landing(refactor[ci-footer]) Move the example CI note to the page footer why: The sentence under every example named the workflow, directory and command that run it. Readers choosing an example do not need that; the capability menu already shows each tmux version. The maintainer asked for it at the bottom of the page. what: - Drop the per-example source paragraph, its tmux chip and its styles from CoreShowcase - Add a muted footer at the end of the port landing page with one line per example: the linked program file and the workflow that runs it - Leave the command out, as it does not fit on one line - Assert the footer and the removed paragraph in the showcase and landing page tests --- site/src/components/CoreShowcase.astro | 11 ++--------- site/src/pages/index.astro | 12 ++++++++++++ site/test/core-capabilities.test.ts | 5 ++--- site/test/port-landing-page.test.ts | 11 +++++++++++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/site/src/components/CoreShowcase.astro b/site/src/components/CoreShowcase.astro index eee4cecbe..c4d9d8c50 100644 --- a/site/src/components/CoreShowcase.astro +++ b/site/src/components/CoreShowcase.astro @@ -20,8 +20,8 @@ import { homeCodeColumns } from '../lib/quickstarts' * and the APIs it uses under that. Narrow pages fold the menu into a dropdown. * The menu is plain links, so Tab and Enter reach every capability. * The selection is the `capability` query parameter and leaves `product` and - * the example toggles alone. Every example is an excerpt of a program or test - * the port's CI runs; see `lib/core-capabilities.ts`. + * the example toggles alone. Every example is a program the port's CI runs, listed + * in the page footer; see `lib/core-capabilities.ts`. * * A port whose only example is its quickstart gets that one panel, with no * list. Returns nothing when the port has neither. @@ -93,10 +93,6 @@ const apis = new Map(shown.map((item) => [item.id, apisUsed(port, CORE_APIS[port {apis.get(item.id)!.more > 0 &&
  • +{apis.get(item.id)!.more} more
  • }
    } -

    - {item.tmux && tmux {item.tmux}} - The whole program, {item.file}. The port's CI runs it in {item.ci.workflow}{(item.ci.where ?? item.ci.directory) && <> from {item.ci.where ?? item.ci.directory}/}: {item.ci.command}. -

    )}
    {links.length > 0 && } @@ -190,9 +186,6 @@ const apis = new Map(shown.map((item) => [item.id, apisUsed(port, CORE_APIS[port margin-inline-start: .5rem; padding: .05rem .4rem; border: 1px solid var(--color-edge); border-radius: .75rem; font-size: .6875rem; font-weight: 500; color: var(--color-muted); } - .core-showcase__source { margin-top: .6rem; font-size: .8125rem; color: var(--color-muted); } - .core-showcase__source a { color: var(--color-link); } - .core-showcase__source code { white-space: normal; overflow-wrap: anywhere; } .core-showcase :global(.expressive-code pre) { overflow: auto; tab-size: 8; } @media (min-width: 56rem) { .core-showcase__picker > summary { display: none !important; } diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index e4cc63fea..8531c7df8 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -17,6 +17,10 @@ import { localesFor } from '../i18n/resolve' import { getCollection, render } from 'astro:content' import { DEFAULT_LOCALE } from '../i18n/locales' import { CONTRIBUTE_BRANCH, CONTRIBUTE_REPO, repoPath } from '../i18n/contribute' +import { portCapabilities, type CapabilityTable } from '../lib/core-capabilities' +import type { CachedSource } from '../lib/port-landing' +import TABLE from '../data/core-capabilities.json' +import SOURCES from '../data/example-sources.json' import { documentationCards, documentationSectionsFor } from '../lib/port-documentation' /** @@ -43,6 +47,8 @@ const versionSections = versionRootPort { route: 'concepts', href: portPageUrl(versionRootPort, buildVersion, 'concepts'), label: 'Concepts', body: 'The domain model every port shares.' }, ].filter((section) => documentationSectionsFor(versionRootPort.slug).includes(section.route)) : [] +// The programs the showcase shows, each with the workflow that runs it. +const ciExamples = versionRootPort ? portCapabilities(versionRootPort.slug, TABLE as CapabilityTable, SOURCES as Record) : [] const versionDomains = versionRootPort ? documentationCards(versionRootPort.slug, buildVersion) : [] /** @@ -158,6 +164,12 @@ const homeEntries = PORTS.map((port) => ({ All {versionRootPort.name} versions and install instructions

    + {ciExamples.length > 0 &&
    +

    Example programs, each run by the port's CI:

    +
      + {ciExamples.map((item) =>
    • {item.file} in {item.ci.workflow}
    • )} +
    +
    } ) : ( diff --git a/site/test/core-capabilities.test.ts b/site/test/core-capabilities.test.ts index 3871a3e42..d19ad2a4d 100644 --- a/site/test/core-capabilities.test.ts +++ b/site/test/core-capabilities.test.ts @@ -353,8 +353,8 @@ describe('the showcase component', () => { expect(lists[0]!.textContent).toContain('Server::new_session') expect(lists[0]!.textContent).toMatch(/\+17 more/) expect(doc.querySelector('.core-showcase__apis-title')!.textContent).toBe('APIs used') - expect(doc.body.textContent).toContain('just check') - expect(doc.body.textContent).toContain('The whole program') + expect(doc.body.textContent, 'the CI sentence moved to the page footer').not.toContain('The whole program') + expect(doc.querySelector('.core-showcase__source')).toBeNull() expect([...doc.querySelectorAll('.core-showcase__links a')].map((a) => a.textContent)).toEqual(['Core Library documentation →', 'More examples →']) const ts = await render('ts') expect(portCapabilities('ts', table, cache), 'ts has only its quickstart').toEqual([]) @@ -409,7 +409,6 @@ describe('the showcase component', () => { expect(panel.querySelector('[data-core-player]')!.getAttribute('data-core-player'), id).toBe(id) expect(panel.querySelector('[data-core-player]')!.getAttribute('data-options'), 'no toggles for a program with one view').toBe('false') expect(panel.querySelector('[data-code]'), `${id} is not also drawn as a plain block`).toBeNull() - expect(panel.textContent).toContain('The whole program') } // rs records its send-keys program, but not create-windows, whose values the recorder cannot reach. const rs = await render('rs') diff --git a/site/test/port-landing-page.test.ts b/site/test/port-landing-page.test.ts index 64525c256..883ce73fb 100644 --- a/site/test/port-landing-page.test.ts +++ b/site/test/port-landing-page.test.ts @@ -398,6 +398,17 @@ describe('the version root of a port', () => { expect(links.map((link) => link.href)).toEqual(['/haskell/latest/core/', '/haskell/latest/guides/']) }) + it('lists each example program and its workflow once, in a footer after the cards', async () => { + const document = await renderRoot('ocaml') + const footer = document.querySelector('[data-ci-examples]')! + expect(footer.querySelector('p')!.textContent).toBe("Example programs, each run by the port's CI:") + const items = [...footer.querySelectorAll('li')] + expect(items.length).toBeGreaterThan(1) + expect(items[0]!.textContent).toBe('examples/showcase_graph.ml in .github/workflows/ocaml.yml') + expect(items[0]!.querySelector('a')!.getAttribute('href')).toMatch(/^https:\/\/github\.com\/.+\/blob\/[0-9a-f]{40}\/examples\/showcase_graph\.ml$/) + expect(footer.nextElementSibling, 'nothing follows the footer').toBeNull() + }) + it('offers the selector instead of one long install list', async () => { const document = await renderRoot('go') expect(document.querySelectorAll('[data-port-landing]')).toHaveLength(1) From e07bff9322ed1568a27abfd137f0fba104113884 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Nov 2026 19:06:25 -0600 Subject: [PATCH 5/6] landing(fix[versions-link]) Remove the self-referencing versions link why: /en// has redirected to /en//latest/ since September, so "All versions and install instructions" sent readers back to the page they were on. It dates from when install lived at the port root. The version switcher is the way to change versions. what: - Remove the link and its withPortRoot import from the port landing page - Assert that no link to the port root remains --- site/src/pages/index.astro | 7 +------ site/test/port-landing-page.test.ts | 4 +++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 8531c7df8..864039aa8 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -12,7 +12,7 @@ import AgentPrompt from '../components/widgets/AgentPrompt.astro' import HomeIntro from '../components/HomeIntro.astro' import HomeLauncher from '../components/HomeLauncher.astro' import { getDocumentationSurfaces } from '../lib/documentation-navigation' -import { withPortRoot, withRoot } from '../lib/site-root' +import { withRoot } from '../lib/site-root' import { localesFor } from '../i18n/resolve' import { getCollection, render } from 'astro:content' import { DEFAULT_LOCALE } from '../i18n/locales' @@ -159,11 +159,6 @@ const homeEntries = PORTS.map((port) => ({ } -

    - - All {versionRootPort.name} versions and install instructions - -

    {ciExamples.length > 0 &&

    Example programs, each run by the port's CI:

      diff --git a/site/test/port-landing-page.test.ts b/site/test/port-landing-page.test.ts index 883ce73fb..71bcdca10 100644 --- a/site/test/port-landing-page.test.ts +++ b/site/test/port-landing-page.test.ts @@ -398,7 +398,7 @@ describe('the version root of a port', () => { expect(links.map((link) => link.href)).toEqual(['/haskell/latest/core/', '/haskell/latest/guides/']) }) - it('lists each example program and its workflow once, in a footer after the cards', async () => { + it('lists each example program and its workflow once, in a footer after the cards, and no link back to the version root', async () => { const document = await renderRoot('ocaml') const footer = document.querySelector('[data-ci-examples]')! expect(footer.querySelector('p')!.textContent).toBe("Example programs, each run by the port's CI:") @@ -407,6 +407,8 @@ describe('the version root of a port', () => { expect(items[0]!.textContent).toBe('examples/showcase_graph.ml in .github/workflows/ocaml.yml') expect(items[0]!.querySelector('a')!.getAttribute('href')).toMatch(/^https:\/\/github\.com\/.+\/blob\/[0-9a-f]{40}\/examples\/showcase_graph\.ml$/) expect(footer.nextElementSibling, 'nothing follows the footer').toBeNull() + expect(document.body.textContent).not.toContain('versions and install instructions') + expect(document.querySelector('a[href$="/ocaml/"]')).toBeNull() }) it('offers the selector instead of one long install list', async () => { From 0149aad137f24742a23d4eca8cea6f093a23e596 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Nov 2026 19:51:02 -0600 Subject: [PATCH 6/6] landing(fix[ci-footer]) Wrap long paths in the example footer why: The footer's file and workflow paths do not break, so on a 280px screen the rs landing page scrolls sideways (the header check measures scrollWidth 283 against a 280px viewport). The paragraph it replaced wrapped its code with overflow-wrap: anywhere. what: - Give the footer wrap-anywhere, which its code elements inherit --- site/src/pages/index.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 864039aa8..5ca3bf769 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -159,7 +159,7 @@ const homeEntries = PORTS.map((port) => ({
    } - {ciExamples.length > 0 &&
    + {ciExamples.length > 0 &&

    Example programs, each run by the port's CI:

      {ciExamples.map((item) =>
    • {item.file} in {item.ci.workflow}
    • )}