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. 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/src/components/CoreShowcase.astro b/site/src/components/CoreShowcase.astro index c5ab3c93f..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. @@ -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 @@ -92,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 && } @@ -189,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..5ca3bf769 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -12,11 +12,15 @@ 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' 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) : [] /** @@ -153,11 +159,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 cf68a767d..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([]) @@ -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') @@ -387,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-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/) }) }) diff --git a/site/test/port-landing-page.test.ts b/site/test/port-landing-page.test.ts index 64525c256..71bcdca10 100644 --- a/site/test/port-landing-page.test.ts +++ b/site/test/port-landing-page.test.ts @@ -398,6 +398,19 @@ 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, 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:") + 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() + 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 () => { const document = await renderRoot('go') expect(document.querySelectorAll('[data-port-landing]')).toHaveLength(1)