mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
cff085edf9
On every shell code block the copy button sat 40px below the top of the
code: on the second line of multi-line blocks, and hanging below
one-line blocks such as `wt merge --no-ff` on /merge/. Shell blocks were
Expressive Code terminal frames, and the terminal plugin deleted their
title bar, but Expressive Code still offsets the copy button by that
bar's height.
## Changes
- `docs/astro.config.mjs` sets `defaultProps: { frame: 'code' }`, so no
block is a terminal frame and there is no title bar to remove. The
plugin's header-removal hook and its `props.frame = 'terminal'` go, and
so does the `wt-commands-only` class: mobile wrapping in `custom.css`
now keys on whether a block contains captured output
(`:has(.wt-output)`).
- Blocks with several commands had a whole-block copy button and
per-line buttons in the same corner, handed over on hover by opacity. A
hidden button still takes clicks, so clicking a line's button could copy
the whole block, and on touch screens one button covered another. Most
of these blocks are lists of alternatives, so they now get only per-line
buttons, and the hover rule is deleted.
- Those blocks wrap (Expressive Code's `wrap` prop) unless they carry
captured output, since a per-line button sits at the end of its line and
scrolled out of view on the long `jq` examples on /list/. A per-line
button is no taller than its line, so buttons on adjacent lines no
longer overlap.
- The two multi-command blocks that only work run in order, the FAQ's
stash recipe and the `Equivalent to:` block under `wt step diff`'s **How
it works** (in `src/cli/step.rs`), are now `bash` fences, the form
`docs/CLAUDE.md` prescribes for a copyable recipe, so they keep one copy
button for the whole recipe. Terminal `--help` renders `console` and
`bash` fences alike (it strips `$ `), and the help snapshots are
unchanged.
## Side effect
Expressive Code strips comment lines only from what terminal frames
copy. The eight `bash` and `powershell` blocks with comments on
/shell-integration/ now copy them, as console blocks already did. Pasted
into zsh without `interactivecomments`, each comment line prints
`command not found: #`; the commands still run.
## Tests
A browser test checks every copy button on every page, at 393px with
touch and at 1376px, for three things: it sits on the line it copies, it
is inside the visible code, and it doesn't overlap another button. These
checks fail against worktrunk.dev and against a build without the wrap
and the height cap. The built-site and plugin tests now expect per-line
payloads and no block payload on multi-command blocks, and a plugin test
pins that blocks carrying output never wrap.
> _This was written by Claude Code on behalf of max-sixty_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_013U96NY8qKtZhavBSwnfCYq
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
636 lines
26 KiB
JavaScript
636 lines
26 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
keepCurrentTocLinkVisible,
|
|
syncTocCurrentToHash,
|
|
} from '../src/components/toc-scroll.mjs';
|
|
|
|
const docsRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
const dist = path.join(docsRoot, 'dist');
|
|
const publicRoutes = [
|
|
'/',
|
|
'/claude-code/',
|
|
'/code-signing/',
|
|
'/config/',
|
|
'/extending/',
|
|
'/faq/',
|
|
'/hook/',
|
|
'/list/',
|
|
'/llm-commits/',
|
|
'/merge/',
|
|
'/remove/',
|
|
'/step/',
|
|
'/switch/',
|
|
'/tips-patterns/',
|
|
];
|
|
|
|
async function htmlFiles(directory = dist) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const nested = await Promise.all(entries.map((entry) => {
|
|
const file = path.join(directory, entry.name);
|
|
return entry.isDirectory() ? htmlFiles(file) : entry.name.endsWith('.html') ? [file] : [];
|
|
}));
|
|
return nested.flat();
|
|
}
|
|
|
|
async function allFiles(directory = dist) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const nested = await Promise.all(entries.map((entry) => {
|
|
const file = path.join(directory, entry.name);
|
|
return entry.isDirectory() ? allFiles(file) : [file];
|
|
}));
|
|
return nested.flat();
|
|
}
|
|
|
|
function routeFile(pathname) {
|
|
if (pathname === '/') return path.join(dist, 'index.html');
|
|
if (pathname === '/404/') return path.join(dist, '404.html');
|
|
if (pathname.endsWith('/')) return path.join(dist, pathname, 'index.html');
|
|
if (pathname.endsWith('.html')) return path.join(dist, pathname);
|
|
}
|
|
|
|
const renderedPages = [
|
|
'/404/',
|
|
...publicRoutes,
|
|
'/worktrunk/',
|
|
].map(routeFile);
|
|
|
|
function fileRoute(file) {
|
|
const relative = path.relative(dist, file);
|
|
if (relative === 'index.html') return '/';
|
|
if (relative === '404.html') return '/404/';
|
|
return `/${path.dirname(relative)}/`;
|
|
}
|
|
|
|
function tableSummaries(html) {
|
|
const text = (value) => value
|
|
.replace(/<[^>]*>/g, '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('&', '&')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return [...html.matchAll(/<table\b([^>]*)>([\s\S]*?)<\/table>/g)].map((match) => {
|
|
const body = match[2].match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? '';
|
|
const rows = [...body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/g)];
|
|
return {
|
|
attributes: match[1],
|
|
headers: [...match[2].matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/g)]
|
|
.map((header) => text(header[1])),
|
|
rowLabels: rows.map((row) => (
|
|
[...row[1].matchAll(/<span\b([^>]*)>([\s\S]*?)<\/span>/g)]
|
|
.filter((label) => (
|
|
/\baria-hidden="true"/.test(label[1])
|
|
&& /\bclass="[^"]*\bwt-responsive-record-label\b[^"]*"/.test(label[1])
|
|
))
|
|
.map((label) => text(label[2]))
|
|
)),
|
|
};
|
|
});
|
|
}
|
|
|
|
function renderedText(html) {
|
|
return html
|
|
.replace(/<[^>]*>/g, '')
|
|
.replace(/&#x([\da-f]+);/giu, (_, value) => String.fromCodePoint(Number.parseInt(value, 16)))
|
|
.replace(/&#(\d+);/gu, (_, value) => String.fromCodePoint(Number(value)))
|
|
.replaceAll('"', '"')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('&', '&');
|
|
}
|
|
|
|
test('build preserves the public route contract', async () => {
|
|
const files = new Set(await htmlFiles());
|
|
const buildFiles = new Set(await allFiles());
|
|
const expected = [
|
|
path.join(dist, '404.html'),
|
|
...publicRoutes.map(routeFile),
|
|
routeFile('/worktrunk/'),
|
|
];
|
|
for (const file of expected) assert.ok(files.has(file), `missing ${file}`);
|
|
|
|
const sitemap = await readFile(path.join(dist, 'sitemap-0.xml'), 'utf8');
|
|
for (const route of publicRoutes) {
|
|
assert.match(sitemap, new RegExp(`<loc>https://worktrunk\\.dev${route}</loc>`));
|
|
}
|
|
assert.doesNotMatch(sitemap, /worktrunk\.dev\/(?:404|worktrunk)\//);
|
|
|
|
const robots = await readFile(path.join(dist, 'robots.txt'), 'utf8');
|
|
for (const match of robots.matchAll(/^Sitemap:\s+(\S+)$/gm)) {
|
|
const url = new URL(match[1]);
|
|
assert.equal(url.origin, 'https://worktrunk.dev');
|
|
assert.ok(buildFiles.has(path.join(dist, url.pathname)), `robots.txt references missing ${url.pathname}`);
|
|
}
|
|
|
|
const compatibilityPage = await readFile(routeFile('/worktrunk/'), 'utf8');
|
|
assert.match(compatibilityPage, /<link rel="canonical" href="https:\/\/worktrunk\.dev\/"/);
|
|
assert.match(compatibilityPage, /<meta name="robots" content="noindex"/);
|
|
assert.match(compatibilityPage, /<meta property="og:url" content="https:\/\/worktrunk\.dev\/"/);
|
|
assert.doesNotMatch(compatibilityPage, /<meta property="og:url" content="https:\/\/worktrunk\.dev\/worktrunk\/"/);
|
|
|
|
const homepage = await readFile(routeFile('/'), 'utf8');
|
|
assert.match(homepage, /<title>Worktrunk — Git worktree management for parallel AI agent workflows<\/title>/);
|
|
assert.doesNotMatch(homepage, /data-contract="home-hero"/);
|
|
assert.doesNotMatch(homepage, /data-has-hero/);
|
|
assert.doesNotMatch(homepage, />\s*Learn More\s*</);
|
|
assert.match(
|
|
homepage,
|
|
/<a\b[^>]*data-contract="github-stars"[^>]*href="https:\/\/github\.com\/max-sixty\/worktrunk"[^>]*>[\s\S]*?<img\b[^>]*src="https:\/\/img\.shields\.io\/github\/stars\/max-sixty\/worktrunk\?style=social"[^>]*>[\s\S]*?<\/a>/,
|
|
);
|
|
assert.doesNotMatch(homepage, />\s*GitHub stars\s*</);
|
|
assert.match(
|
|
homepage,
|
|
/<div\b[^>]*class="wt-home"[^>]*>[\s\S]*?<p>Worktrunk is a CLI for Git worktree management, designed for <strong>parallel AI agent\s+workflows<\/strong>\.<\/p>/,
|
|
);
|
|
assert.match(homepage, /<button\b[^>]*aria-label="Menu"[^>]*aria-controls="starlight__sidebar"/);
|
|
assert.match(homepage, /<div id="starlight__sidebar" class="sidebar-pane\b/);
|
|
assert.match(homepage, /<a\b[^>]*href="\/switch\/"[^>]*><span\b[^>]*>wt switch<\/span><\/a>/);
|
|
assert.match(homepage, /<a\b[^>]*href="\/faq\/"[^>]*><span\b[^>]*>FAQ<\/span><\/a>/);
|
|
assert.doesNotMatch(homepage, /data-has-toc/);
|
|
assert.doesNotMatch(homepage, /<nav aria-labelledby="starlight__on-this-page">/);
|
|
assert.doesNotMatch(homepage, /<starlight-theme-select>/);
|
|
assert.match(homepage, /<div\b[^>]*class="wt-home"[^>]*data-contract="home-content-rail"/);
|
|
assert.match(homepage, /<h1 id="_top"[^>]*><span id="worktrunk"[^>]*>Worktrunk<\/span><\/h1>/);
|
|
assert.match(
|
|
homepage,
|
|
/<source srcset="\/assets\/docs\/dark\/wt-core-mobile\.gif"[^>]*data-demo-theme="dark"[^>]*data-demo-media="\(max-width: 42rem\)"/,
|
|
);
|
|
assert.match(
|
|
homepage,
|
|
/<source srcset="\/assets\/docs\/light\/wt-core-mobile\.gif"[^>]*data-demo-theme="light"[^>]*data-demo-media="\(max-width: 42rem\)"/,
|
|
);
|
|
assert.match(homepage, /<img src="\/assets\/docs\/light\/wt-core\.gif"/);
|
|
assert.equal(homepage.match(/<h1\b/g)?.length, 1);
|
|
assert.match(
|
|
homepage,
|
|
/<pre[^>]*><code><span class="wt-shell-command">git<\/span> <span class="wt-shell-argument">worktree<\/span> <span class="wt-shell-argument">add<\/span> <span class="wt-shell-option">-b<\/span>/,
|
|
);
|
|
const comparison = homepage.match(/<table class="cmd-compare">([\s\S]*?)<\/table>/)?.[1];
|
|
assert.ok(comparison, 'homepage is missing the command comparison table');
|
|
assert.equal(comparison.match(/<td data-label="Worktrunk">/g)?.length, 4);
|
|
assert.equal(comparison.match(/<td data-label="Plain git">/g)?.length, 4);
|
|
assert.equal(comparison.match(/class="wt-shell-command"/g)?.length, 12);
|
|
assert.equal(comparison.match(/class="wt-shell-option"/g)?.length, 4);
|
|
assert.equal(comparison.match(/class="wt-shell-argument"/g)?.length, 21);
|
|
assert.match(homepage, /class="wt-terminal-green"/);
|
|
assert.match(homepage, /class="wt-terminal-red"/);
|
|
const switchPage = await readFile(routeFile('/switch/'), 'utf8');
|
|
assert.match(switchPage, /<nav aria-labelledby="starlight__on-this-page">/);
|
|
assert.match(
|
|
switchPage,
|
|
/<a\b[^>]*data-contract="github-stars"[^>]*href="https:\/\/github\.com\/max-sixty\/worktrunk"[^>]*>[\s\S]*?<img\b[^>]*src="https:\/\/img\.shields\.io\/github\/stars\/max-sixty\/worktrunk\?style=social"[^>]*>[\s\S]*?<\/a>/,
|
|
);
|
|
assert.doesNotMatch(switchPage, /<starlight-theme-select>/);
|
|
|
|
const listPage = await readFile(routeFile('/list/'), 'utf8');
|
|
assert.match(listPage, /href="https:\/\/github\.com\/max-sixty\/worktrunk\/edit\/main\/docs\/src\/content\/docs\/list\.md"/);
|
|
assert.doesNotMatch(listPage, /docs\/src\/content\/docs\/src\/content\/docs/);
|
|
assert.match(listPage, /<span class="wt-terminal-green">\+54<\/span>/);
|
|
assert.match(listPage, /<span class="wt-terminal-red">-5<\/span>/);
|
|
assert.match(listPage, /<span class="wt-terminal-blue wt-terminal-dim">#412<\/span>/);
|
|
assert.match(
|
|
listPage,
|
|
/<div class="ec-line wt-command"><div class="code"><span[^>]*>wt<\/span><span[^>]*> <\/span><span[^>]*>list<\/span>/,
|
|
);
|
|
assert.match(listPage, /<figure class="frame wt-command-reference not-content">/);
|
|
assert.match(listPage, /<span class="wt-help-heading">Usage:<\/span>/);
|
|
assert.match(listPage, /<span class="wt-help-option">--format<\/span>/);
|
|
assert.match(listPage, /<span class="wt-help-value">\[OPTIONS\]<\/span>/);
|
|
assert.match(
|
|
listPage,
|
|
/<span class="wt-help-command">wt list<\/span> <span class="wt-help-value"><COMMAND><\/span>/,
|
|
);
|
|
|
|
const llmCommitsPage = await readFile(routeFile('/llm-commits/'), 'utf8');
|
|
assert.match(llmCommitsPage, /<span class="wt-terminal-cyan">◎<\/span>/);
|
|
assert.match(
|
|
llmCommitsPage,
|
|
/<span class="wt-terminal-bold">feat\(validation\): add input validation utilities<\/span>/,
|
|
);
|
|
|
|
const configPage = await readFile(routeFile('/config/'), 'utf8');
|
|
assert.match(
|
|
configPage,
|
|
/<figure class="frame has-title not-content"><figcaption class="header"><span class="title">~\/.config\/worktrunk\/config\.toml<\/span><\/figcaption>/,
|
|
);
|
|
const configToc = configPage.match(
|
|
/<nav aria-labelledby="starlight__on-this-page">([\s\S]*?)<\/nav>/,
|
|
)?.[1];
|
|
assert.ok(configToc, 'config page is missing its desktop table of contents');
|
|
assert.equal(
|
|
[...configToc.matchAll(/<a href="#[^"]+"/g)].length,
|
|
33,
|
|
'config table of contents should expose overview and structural section headings only',
|
|
);
|
|
for (const [id, title] of [
|
|
['user-configuration', 'User Configuration'],
|
|
['project-configuration', 'Project Configuration'],
|
|
['shell-integration', 'Shell Integration'],
|
|
['other', 'Other'],
|
|
['subcommands', 'Subcommands'],
|
|
]) {
|
|
assert.match(
|
|
configToc,
|
|
new RegExp(`<a href="#${id}"[^>]*>[\\s\\S]*?<span[^>]*>${title}</span></a>`),
|
|
);
|
|
}
|
|
assert.doesNotMatch(configToc, /href="#claude-code"/);
|
|
assert.doesNotMatch(configToc, /href="#wt-config-[^"]*--command-reference"/);
|
|
|
|
const structuralSections = new Map([
|
|
['/hook/', ['hook-types', 'security', 'configuration', 'running-hooks-manually', 'recipes']],
|
|
['/list/', ['subcommands']],
|
|
['/step/', ['subcommands']],
|
|
]);
|
|
for (const [route, ids] of structuralSections) {
|
|
const page = await readFile(routeFile(route), 'utf8');
|
|
const toc = page.match(/<nav aria-labelledby="starlight__on-this-page">([\s\S]*?)<\/nav>/)?.[1];
|
|
assert.ok(toc, `${route} is missing its desktop table of contents`);
|
|
for (const id of ids) assert.match(toc, new RegExp(`<a href="#${id}"`));
|
|
}
|
|
});
|
|
|
|
test('built pages omit low-value footer navigation', async () => {
|
|
for (const page of renderedPages) {
|
|
const html = await readFile(page, 'utf8');
|
|
assert.doesNotMatch(
|
|
html,
|
|
/class="[^"]*\bpagination-links\b/,
|
|
`${page} renders previous/next pagination`,
|
|
);
|
|
assert.doesNotMatch(
|
|
html,
|
|
/data-contract="project-links"/,
|
|
`${page} renders duplicate project links`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('nested command references expose unique search-only fragment titles', async () => {
|
|
const stepPage = await readFile(routeFile('/step/'), 'utf8');
|
|
assert.match(stepPage, /<span class="wt-help-value"><COMMAND>\.\.\.<\/span>/);
|
|
assert.match(stepPage, /<h2 id="command-reference">Command reference<\/h2>/);
|
|
|
|
const references = [...stepPage.matchAll(
|
|
/<h3 id="([^"]+)"><span class="wt-pagefind-fragment-title" hidden aria-hidden="true">(wt step [^<]+) — <\/span>Command reference<\/h3>/g,
|
|
)];
|
|
for (const [, id, command] of references) {
|
|
assert.equal(id, `${command.replaceAll(' ', '-')}--command-reference`);
|
|
}
|
|
assert.deepEqual(references.map((match) => match[2]), [
|
|
'wt step commit',
|
|
'wt step squash',
|
|
'wt step rebase',
|
|
'wt step push',
|
|
'wt step diff',
|
|
'wt step copy-ignored',
|
|
'wt step eval',
|
|
'wt step for-each',
|
|
'wt step promote',
|
|
'wt step prune',
|
|
'wt step relocate',
|
|
'wt step tether',
|
|
]);
|
|
|
|
const listPage = await readFile(routeFile('/list/'), 'utf8');
|
|
assert.match(
|
|
listPage,
|
|
/<h3 id="wt-list-statusline--command-reference"><span class="wt-pagefind-fragment-title" hidden aria-hidden="true">wt list statusline — <\/span>Command reference<\/h3>/,
|
|
);
|
|
});
|
|
|
|
test('every command reference receives semantic syntax roles', async () => {
|
|
let headingCount = 0;
|
|
let frameCount = 0;
|
|
for (const page of renderedPages) {
|
|
const html = await readFile(page, 'utf8');
|
|
headingCount += [...html.matchAll(/>Command reference<\/h[23]>/g)].length;
|
|
const frames = [...html.matchAll(
|
|
/<figure class="frame wt-command-reference not-content">([\s\S]*?)<\/figure>/g,
|
|
)];
|
|
frameCount += frames.length;
|
|
for (const [, frame] of frames) {
|
|
const renderedCode = frame.match(/<code>([\s\S]*?)<\/code>/)?.[1] ?? '';
|
|
assert.match(frame, /class="wt-help-command"/, `${page} leaves the command unstyled`);
|
|
assert.match(frame, /class="wt-help-heading"/, `${page} leaves help headings unstyled`);
|
|
assert.match(frame, /class="wt-help-(?:option|value)"/, `${page} leaves help syntax unstyled`);
|
|
assert.doesNotMatch(
|
|
frame,
|
|
/<div class="code">- [^<]/,
|
|
`${page} leaves a possible-value row unstyled`,
|
|
);
|
|
const experimentalCount = [...renderedCode.matchAll(/\[experimental\]/g)].length;
|
|
const styledExperimentalCount = [
|
|
...renderedCode.matchAll(/class="wt-help-meta">\[experimental\]<\/span>/g),
|
|
].length;
|
|
assert.equal(
|
|
styledExperimentalCount,
|
|
experimentalCount,
|
|
`${page} leaves an experimental annotation unstyled`,
|
|
);
|
|
}
|
|
}
|
|
assert.ok(headingCount > 0, 'expected rendered command references');
|
|
assert.equal(frameCount, headingCount, 'some command references remain plain code blocks');
|
|
});
|
|
|
|
test('built pages only reference emitted local assets', async () => {
|
|
const files = new Set(await allFiles());
|
|
|
|
for (const page of renderedPages) {
|
|
const html = await readFile(page, 'utf8');
|
|
const assertAsset = (reference) => {
|
|
const url = new URL(reference.replaceAll('&', '&'), `https://worktrunk.dev${fileRoute(page)}`);
|
|
if (url.origin !== 'https://worktrunk.dev' || url.pathname.endsWith('/')) return;
|
|
const target = path.join(dist, decodeURIComponent(url.pathname));
|
|
assert.ok(files.has(target), `${page} references missing asset ${url.pathname}`);
|
|
};
|
|
for (const match of html.matchAll(/\s(href|src|srcset)="([^"]+)"/g)) {
|
|
const references = match[1] === 'srcset'
|
|
? match[2].split(',').map((candidate) => candidate.trim().split(/\s+/, 1)[0])
|
|
: [match[2]];
|
|
for (const reference of references) assertAsset(reference);
|
|
}
|
|
for (const match of html.matchAll(/<meta (?:property="og:image"|name="twitter:image") content="([^"]+)"/g)) {
|
|
assertAsset(match[1]);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('output-only console blocks do not expose copy controls', async () => {
|
|
let outputOnlyBlocks = 0;
|
|
for (const page of renderedPages) {
|
|
const html = await readFile(page, 'utf8');
|
|
for (const match of html.matchAll(/<figure class="frame[^"]*">([\s\S]*?)<\/figure>/g)) {
|
|
const frame = match[1];
|
|
if (!/class="ec-line wt-output"/.test(frame)) continue;
|
|
if (/class="ec-line wt-(?:command|copyable)"/.test(frame)) continue;
|
|
outputOnlyBlocks += 1;
|
|
assert.doesNotMatch(frame, /class="copy"/, `${page} exposes a copy control for captured output`);
|
|
}
|
|
}
|
|
assert.equal(outputOnlyBlocks, 1, 'expected the FAQ approval transcript to exercise this case');
|
|
});
|
|
|
|
test('command-bearing console blocks emit command-only copy payloads', async () => {
|
|
let commandBearingBlocks = 0;
|
|
let perLineBlocks = 0;
|
|
for (const page of renderedPages) {
|
|
const html = await readFile(page, 'utf8');
|
|
for (const match of html.matchAll(/<figure class="frame[^"]*">([\s\S]*?)<\/figure>/g)) {
|
|
const frame = match[1];
|
|
const lines = [...frame.matchAll(
|
|
/<div class="ec-line wt-(command|copyable|output)"><div class="code">([\s\S]*?)<\/div>/g,
|
|
)];
|
|
const expected = lines
|
|
.filter((line) => line[1] !== 'output')
|
|
.map((line) => renderedText(line[2]).replace(/\n$/u, ''));
|
|
if (expected.length === 0) continue;
|
|
|
|
commandBearingBlocks += 1;
|
|
// The block control carries the bare `copy` class; per-line controls add
|
|
// `wt-line-copy`.
|
|
const encodedPayload = frame.match(
|
|
/<div class="copy">[\s\S]*?<button\b[^>]*\bdata-code="([^"]*)"/,
|
|
)?.[1];
|
|
const commands = lines
|
|
.filter((line) => line[1] === 'command')
|
|
.map((line) => renderedText(line[2]).replace(/\n$/u, ''));
|
|
const perLine = [...frame.matchAll(
|
|
/<div class="copy wt-line-copy">[\s\S]*?<button\b[^>]*\bdata-code="([^"]*)"/g,
|
|
)].map((line) => renderedText(line[1]));
|
|
// Most blocks listing several commands are menus of alternatives, so
|
|
// each command line offers its own payload in place of the block's.
|
|
if (commands.length > 1) {
|
|
perLineBlocks += 1;
|
|
assert.equal(encodedPayload, undefined, `${page} offers a block copy beside per-line copies`);
|
|
assert.deepEqual(perLine, commands, `${page} per-line copy payloads do not match its commands`);
|
|
} else {
|
|
assert.notEqual(encodedPayload, undefined, `${page} is missing a terminal copy payload`);
|
|
assert.equal(renderedText(encodedPayload), expected.join('\u007f'), `${page} copies captured output`);
|
|
assert.deepEqual(perLine, [], `${page} adds per-line copy to a single-command block`);
|
|
}
|
|
}
|
|
}
|
|
assert.ok(commandBearingBlocks > 0, 'expected command-bearing console blocks');
|
|
assert.ok(perLineBlocks > 0, 'expected blocks listing several commands');
|
|
});
|
|
|
|
test('generated command references expose no copy control', async () => {
|
|
let references = 0;
|
|
for (const page of renderedPages) {
|
|
const html = await readFile(page, 'utf8');
|
|
for (const match of html.matchAll(
|
|
/<figure class="frame[^"]*\bwt-command-reference\b[^"]*">([\s\S]*?)<\/figure>/g,
|
|
)) {
|
|
references += 1;
|
|
assert.doesNotMatch(
|
|
match[1],
|
|
/class="copy/,
|
|
`${page} offers to copy a page of generated help text`,
|
|
);
|
|
}
|
|
}
|
|
assert.ok(references > 0, 'expected generated command references');
|
|
});
|
|
|
|
test('titleless code frames do not render decorative headers', async () => {
|
|
for (const page of renderedPages) {
|
|
const html = await readFile(page, 'utf8');
|
|
assert.doesNotMatch(
|
|
html,
|
|
/<figcaption class="header"><span class="title"><\/span>/,
|
|
`${page} renders chrome for an untitled code block`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('long desktop TOCs keep the current link inside their own scrollport', () => {
|
|
const scrollport = {
|
|
clientHeight: 1_000,
|
|
scrollHeight: 1_961,
|
|
scrollTop: 0,
|
|
getBoundingClientRect: () => ({ top: 0, bottom: 1_000 }),
|
|
};
|
|
const visibleToc = {
|
|
closest: () => scrollport,
|
|
getClientRects: () => [{}],
|
|
};
|
|
const current = {
|
|
closest: () => visibleToc,
|
|
getBoundingClientRect: () => ({ top: 1_386.84375, bottom: 1_411.09375 }),
|
|
};
|
|
|
|
assert.equal(keepCurrentTocLinkVisible(current), true);
|
|
assert.equal(scrollport.scrollTop, 459.09375);
|
|
|
|
current.getBoundingClientRect = () => ({ top: 500, bottom: 525 });
|
|
assert.equal(keepCurrentTocLinkVisible(current), false);
|
|
assert.equal(scrollport.scrollTop, 459.09375);
|
|
|
|
current.getBoundingClientRect = () => ({ top: 20, bottom: 45 });
|
|
assert.equal(keepCurrentTocLinkVisible(current), true);
|
|
assert.equal(scrollport.scrollTop, 431.09375);
|
|
|
|
visibleToc.getClientRects = () => [];
|
|
current.getBoundingClientRect = () => ({ top: 1_386.84375, bottom: 1_411.09375 });
|
|
assert.equal(keepCurrentTocLinkVisible(current), false);
|
|
assert.equal(scrollport.scrollTop, 431.09375);
|
|
|
|
visibleToc.getClientRects = () => [{}];
|
|
scrollport.scrollHeight = scrollport.clientHeight;
|
|
assert.equal(keepCurrentTocLinkVisible(current), false);
|
|
assert.equal(scrollport.scrollTop, 431.09375);
|
|
});
|
|
|
|
test('hash navigation selects the requested section in every rendered TOC', () => {
|
|
const link = (hash, current = false) => ({
|
|
hash,
|
|
current,
|
|
getAttribute(name) {
|
|
return name === 'aria-current' && this.current ? 'true' : null;
|
|
},
|
|
setAttribute(name, value) {
|
|
if (name === 'aria-current') this.current = value === 'true';
|
|
},
|
|
removeAttribute(name) {
|
|
if (name === 'aria-current') this.current = false;
|
|
},
|
|
textContent: hash.slice(1).replaceAll('-', ' '),
|
|
});
|
|
const root = (mobile = false) => {
|
|
const overview = link('#_top', true);
|
|
const requested = link('#reporting-a-problem');
|
|
const display = mobile ? { textContent: '' } : null;
|
|
return {
|
|
display,
|
|
links: [overview, requested],
|
|
querySelector(selector) {
|
|
return selector === '.display-current' ? display : null;
|
|
},
|
|
querySelectorAll(selector) {
|
|
if (selector === 'a') return this.links;
|
|
if (selector === 'a[aria-current="true"]') {
|
|
return this.links.filter((candidate) => candidate.current);
|
|
}
|
|
return [];
|
|
},
|
|
};
|
|
};
|
|
const desktop = root();
|
|
const mobile = root(true);
|
|
const doc = {
|
|
querySelectorAll: () => [desktop, mobile],
|
|
};
|
|
|
|
assert.equal(syncTocCurrentToHash(doc, '#reporting-a-problem'), true);
|
|
for (const toc of [desktop, mobile]) {
|
|
assert.equal(toc.links[0].current, false);
|
|
assert.equal(toc.links[1].current, true);
|
|
}
|
|
assert.equal(mobile.display.textContent, 'reporting a problem');
|
|
assert.equal(syncTocCurrentToHash(doc, '#missing'), false);
|
|
});
|
|
|
|
test('every structural content heading appears in the page outline', async () => {
|
|
for (const route of publicRoutes.filter((candidate) => candidate !== '/')) {
|
|
const page = await readFile(routeFile(route), 'utf8');
|
|
const headings = [...page.matchAll(/<h[12]\b[^>]*\bid="([^"]+)"/g)]
|
|
.map((match) => match[1])
|
|
.filter((id) => id !== 'starlight__on-this-page');
|
|
const toc = page.match(/<nav aria-labelledby="starlight__on-this-page">([\s\S]*?)<\/nav>/)?.[1];
|
|
assert.ok(toc, `${route} is missing its desktop table of contents`);
|
|
const links = [...toc.matchAll(/<a href="#([^"]+)"/g)].map((match) => match[1]);
|
|
assert.deepEqual(links, headings, `${route} outline does not match its structural headings`);
|
|
}
|
|
});
|
|
|
|
test('short wide tables become labeled records without capturing dense tables', async () => {
|
|
const pages = new Map(await Promise.all(publicRoutes.map(async (route) => (
|
|
[route, await readFile(routeFile(route), 'utf8')]
|
|
))));
|
|
const tables = new Map([...pages].map(([route, html]) => [route, tableSummaries(html)]));
|
|
const findTable = (route, headers) => {
|
|
const table = tables.get(route).find((candidate) => (
|
|
candidate.headers.join('|') === headers.join('|')
|
|
));
|
|
assert.ok(table, `${route} is missing table ${headers.join(' | ')}`);
|
|
return table;
|
|
};
|
|
|
|
const configFiles = findTable(
|
|
'/config/',
|
|
['File', 'Location', 'Contains', 'Committed & shared'],
|
|
);
|
|
assert.match(configFiles.attributes, /class="wt-responsive-records"/);
|
|
|
|
for (const pageTables of tables.values()) {
|
|
for (const table of pageTables) {
|
|
if (!/\bwt-responsive-records\b/.test(table.attributes)) continue;
|
|
assert.ok(table.headers.length >= 3);
|
|
assert.ok(table.rowLabels.length >= 1 && table.rowLabels.length <= 3);
|
|
assert.deepEqual(
|
|
table.rowLabels,
|
|
Array.from({ length: table.rowLabels.length }, () => table.headers),
|
|
);
|
|
}
|
|
}
|
|
|
|
assert.doesNotMatch(
|
|
findTable('/extending/', ['', 'Hooks', 'Aliases', 'Custom subcommands']).attributes,
|
|
/\bwt-responsive-records\b/,
|
|
);
|
|
assert.doesNotMatch(
|
|
findTable('/claude-code/', ['Capability', 'Claude Code', 'Codex', 'OpenCode', 'Pi', 'Gemini CLI']).attributes,
|
|
/\bwt-responsive-records\b/,
|
|
);
|
|
assert.doesNotMatch(
|
|
findTable('/hook/', ['Kind', 'Variable', 'Description']).attributes,
|
|
/\bwt-responsive-records\b/,
|
|
);
|
|
assert.doesNotMatch(
|
|
findTable('/list/', ['Column', 'Shows']).attributes,
|
|
/\bwt-responsive-records\b/,
|
|
);
|
|
assert.doesNotMatch(
|
|
findTable('/', ['Task', 'Worktrunk', 'Plain git']).attributes,
|
|
/\bwt-responsive-records\b/,
|
|
);
|
|
});
|
|
|
|
test('mobile menu control is hidden until its script is available', async () => {
|
|
const faqPage = await readFile(routeFile('/faq/'), 'utf8');
|
|
assert.match(faqPage, /<starlight-menu-button\b/);
|
|
assert.match(
|
|
faqPage,
|
|
/<style>\s*starlight-menu-button:not\(:defined\)\s*\{\s*display:\s*none;\s*\}\s*<\/style>/,
|
|
);
|
|
});
|
|
|
|
test('built pages have unique IDs and valid internal page links', async () => {
|
|
const pages = await htmlFiles();
|
|
const contents = new Map(await Promise.all(pages.map(async (file) => [file, await readFile(file, 'utf8')])));
|
|
|
|
for (const [source, html] of contents) {
|
|
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
|
assert.equal(new Set(ids).size, ids.length, `duplicate ID in ${source}`);
|
|
|
|
for (const match of html.matchAll(/\shref="([^"]+)"/g)) {
|
|
const href = match[1].replaceAll('&', '&');
|
|
const url = new URL(href, `https://worktrunk.dev${fileRoute(source)}`);
|
|
if (url.origin !== 'https://worktrunk.dev') continue;
|
|
const targetFile = routeFile(decodeURIComponent(url.pathname));
|
|
if (!targetFile) continue;
|
|
const target = contents.get(targetFile);
|
|
assert.ok(target, `${source} links to missing page ${url.pathname}`);
|
|
if (!url.hash) continue;
|
|
const id = decodeURIComponent(url.hash.slice(1));
|
|
assert.match(target, new RegExp(`\\sid="${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`), `${source} links to missing ${url.hash}`);
|
|
}
|
|
}
|
|
});
|