Files
Maximilian Roos 5dec423110 docs: leave trailing shell comments out of copied commands (#4091)
Copy buttons copied shell commands with their trailing `#` comments,
e.g. `wt hook pre-merge user: # Run all user hooks`. In zsh without
`interactivecomments`, which is the default, a pasted trailing comment
isn't a comment: the command runs as `wt hook pre-merge user: '#' Run
all user hooks`.

`withoutTrailingShellComments` in
`docs/src/plugins/worktrunk-terminal.mjs` applies the shell's rule, that
a `#` beginning an unquoted word starts a comment, to what shell copy
buttons copy: each per-line button in multi-command `console` blocks,
the command lines of single-command `console` blocks, and whole `bash`
and `sh` blocks. A line that is only a comment stays, as
`docs/CLAUDE.md` already specifies for console blocks. 56 of the site's
339 copy payloads change (47 per-line buttons and 9 `bash` blocks).
TOML, PowerShell and other payloads are unchanged, and a quoted `#`
survives.

Tests: unit tests for the scanner (quotes, escapes, `$#`, operators,
quotes spanning lines) and for a single-command `console` block with a
trailing comment, plus a built-site test that no `bash` or `sh` payload
carries a trailing comment, which fails on the build before this change.

> _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>
2026-09-13 10:57:35 -07:00

660 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
rehypeStableHeadingIds,
slugHeading,
} from '../src/plugins/stable-heading-ids.mjs';
import { rehypePagefindCommandReferences } from '../src/plugins/pagefind-command-references.mjs';
import { rehypeResponsiveTables } from '../src/plugins/responsive-tables.mjs';
import {
commandReferenceSegments,
pluginWorktrunkTerminal,
rehypeComparisonCommands,
semanticOutputSegments,
shellCommandSegments,
withoutTrailingShellComments,
} from '../src/plugins/worktrunk-terminal.mjs';
function prepareCodeBlock(plugin, codeBlock) {
codeBlock.props ??= {};
plugin.hooks.preprocessLanguage({ codeBlock });
plugin.hooks.preprocessCode({ codeBlock });
}
function markdownTable(headers, rows, { className, headerProperties } = {}) {
const row = (tagName, values, properties = []) => ({
type: 'element',
tagName: 'tr',
properties: {},
children: values.map((value, index) => ({
type: 'element',
tagName,
properties: properties[index] ?? {},
children: [{ type: 'text', value }],
})),
});
return {
type: 'element',
tagName: 'table',
properties: className ? { className } : {},
children: [
{
type: 'element',
tagName: 'thead',
properties: {},
children: [row('th', headers, headerProperties)],
},
{
type: 'element',
tagName: 'tbody',
properties: {},
children: rows.map((values) => row('td', values)),
},
],
};
}
test('heading slugs preserve existing public anchors', () => {
const headings = new Map([
['Example: CI/testing override', 'example-ci-testing-override'],
['Inline config overrides (--config-set)', 'inline-config-overrides-config-set'],
['Whats cached', 'what-s-cached'],
['Whats logged', 'what-s-logged'],
['Command log (commands.jsonl)', 'command-log-commands-jsonl'],
['vs. git-machete / git-town', 'vs-git-machete-git-town'],
["There's an issue with my shell setup", 'there-s-an-issue-with-my-shell-setup'],
['What does -v / -vv do?', 'what-does-v-vv-do'],
[
'My for-each or --execute alias prints the same value in every worktree',
'my-for-each-or-execute-alias-prints-the-same-value-in-every-worktree',
],
['working_tree object', 'working-tree-object'],
['main_state values', 'main-state-values'],
['integration_reason values', 'integration-reason-values'],
['ci.status and ci.review_state values', 'ci-status-and-ci-review-state-values'],
['Node.js', 'node-js'],
['Shell alias for new worktree + agent', 'shell-alias-for-new-worktree-agent'],
]);
for (const [heading, expected] of headings) {
assert.equal(slugHeading(heading), expected);
}
});
test('duplicate heading slugs are unique', () => {
const headings = [
{ type: 'element', tagName: 'h2', properties: {}, children: [{ type: 'text', value: 'Demo' }] },
{ type: 'element', tagName: 'h2', properties: {}, children: [{ type: 'text', value: 'Demo' }] },
];
rehypeStableHeadingIds()({ type: 'root', children: headings });
assert.deepEqual(headings.map((heading) => heading.properties.id), ['demo', 'demo-1']);
});
test('subcommand sections scope the ids of their headings', () => {
const headings = [
heading('h2', 'Examples'),
heading('h1', 'Subcommands'),
heading('h2', 'wt config state'),
heading('h3', 'Examples'),
heading('h3', 'Cache'),
heading('h4', 'Examples'),
heading('h2', 'wt config state cache'),
heading('h3', 'Examples'),
heading('h1', 'Other'),
heading('h2', 'Examples'),
];
rehypeStableHeadingIds()({ type: 'root', children: headings });
assert.deepEqual(headings.map((heading) => heading.properties.id), [
'examples',
'subcommands',
'wt-config-state',
'wt-config-state--examples',
'wt-config-state--cache',
'wt-config-state--examples-1',
'wt-config-state-cache',
'wt-config-state-cache--examples',
'other',
'examples-1',
]);
});
function heading(tagName, value) {
return {
type: 'element',
tagName,
properties: {},
children: [{ type: 'text', value }],
};
}
test('nested command references receive hidden Pagefind context', () => {
const command = heading('h2', 'wt step commit');
const reference = heading('h3', 'Command reference');
const tree = { type: 'root', children: [command, reference] };
rehypePagefindCommandReferences()(tree);
rehypePagefindCommandReferences()(tree);
assert.deepEqual(reference.children, [
{
type: 'raw',
value: '<span class="wt-pagefind-fragment-title" hidden aria-hidden="true">wt step commit — </span>',
},
{ type: 'text', value: 'Command reference' },
]);
});
test('Pagefind context rejects non-command and non-nested references', () => {
const topLevelReference = heading('h2', 'Command reference');
const orphanReference = heading('h3', 'Command reference');
const command = heading('h2', 'wt step commit');
const otherSubheading = heading('h3', 'Examples');
const unrelated = heading('h2', 'Configuration');
const unrelatedReference = heading('h3', 'Command reference');
const tree = {
type: 'root',
children: [
topLevelReference,
orphanReference,
command,
otherSubheading,
unrelated,
unrelatedReference,
],
};
rehypePagefindCommandReferences()(tree);
for (const candidate of [
topLevelReference,
orphanReference,
otherSubheading,
unrelatedReference,
]) {
assert.deepEqual(candidate.children, [{ type: 'text', value: candidate.children[0].value }]);
}
});
test('short wide tables become accessible responsive records', () => {
const table = markdownTable(
['File', 'Location', 'Contains'],
[
['User config', '~/.config/worktrunk/config.toml', 'Personal settings'],
['Project config', '.config/wt.toml', 'Shared hooks'],
],
);
rehypeResponsiveTables()({ type: 'root', children: [table] });
assert.deepEqual(table.properties, {
className: ['wt-responsive-records'],
dataRecordColumns: '3',
});
const cells = table.children[1].children.flatMap((row) => row.children);
assert.deepEqual(
cells.map((cell) => cell.children[0]),
['File', 'Location', 'Contains', 'File', 'Location', 'Contains'].map((value) => ({
type: 'element',
tagName: 'span',
properties: {
ariaHidden: 'true',
className: ['wt-responsive-record-label'],
},
children: [{ type: 'text', value }],
})),
);
assert.deepEqual(
cells.map((cell) => cell.children[1].value),
['User config', '~/.config/worktrunk/config.toml', 'Personal settings', 'Project config', '.config/wt.toml', 'Shared hooks'],
);
});
test('responsive records reject tables that cannot stack unambiguously', () => {
const twoColumns = markdownTable(['Name', 'Meaning'], [['one', 'first']]);
const dense = markdownTable(
['Name', 'Value', 'Meaning'],
Array.from({ length: 4 }, (_, index) => [`name ${index}`, `${index}`, `meaning ${index}`]),
);
const comparison = markdownTable(
['Task', 'Worktrunk', 'Plain git'],
[['Switch', 'wt switch', 'git worktree add']],
{ className: ['cmd-compare'] },
);
const complexHeader = markdownTable(
['Grouped', 'Value', 'Meaning'],
[['one', '1', 'first']],
{ headerProperties: [{ colSpan: 2 }] },
);
const emptyHeader = markdownTable(['Name', '', 'Meaning'], [['one', '1', 'first']]);
const duplicateHeader = markdownTable(['Name', 'Value', 'Value'], [['one', '1', 'first']]);
const mismatchedRow = markdownTable(['Name', 'Value', 'Meaning'], [['one', '1']]);
const multipleHeaderRows = markdownTable(['Name', 'Value', 'Meaning'], [['one', '1', 'first']]);
multipleHeaderRows.children[0].children.push(structuredClone(multipleHeaderRows.children[0].children[0]));
const tables = [
twoColumns,
dense,
comparison,
complexHeader,
emptyHeader,
duplicateHeader,
mismatchedRow,
multipleHeaderRows,
];
rehypeResponsiveTables()({ type: 'root', children: tables });
for (const table of tables) {
assert.ok(!table.properties.className?.includes('wt-responsive-records'));
assert.equal(
table.children[1].children.flatMap((row) => row.children)
.filter((cell) => cell.children[0]?.properties?.className?.includes('wt-responsive-record-label'))
.length,
0,
);
}
});
test('homepage comparison commands gain shell roles without changing their text', () => {
const command = {
type: 'element',
tagName: 'code',
properties: {},
children: [{
type: 'text',
value: 'git worktree add -b feat ../repo.feat && \\\ncd ../repo.feat',
}],
};
const outsideCode = structuredClone(command);
const comparison = {
type: 'element',
tagName: 'table',
properties: { className: ['cmd-compare'] },
children: [{
type: 'element',
tagName: 'tbody',
properties: {},
children: [{
type: 'element',
tagName: 'tr',
properties: {},
children: [{
type: 'element',
tagName: 'td',
properties: {},
children: [command],
}],
}],
}],
};
const tree = { type: 'root', children: [outsideCode, comparison] };
rehypeComparisonCommands()(tree);
assert.deepEqual(shellCommandSegments(command.children.map((child) => (
child.children?.[0]?.value ?? child.value
)).join('')), [
{ text: 'git', tone: 'command' },
{ text: ' ' },
{ text: 'worktree', tone: 'argument' },
{ text: ' ' },
{ text: 'add', tone: 'argument' },
{ text: ' ' },
{ text: '-b', tone: 'option' },
{ text: ' ' },
{ text: 'feat', tone: 'argument' },
{ text: ' ' },
{ text: '../repo.feat', tone: 'argument' },
{ text: ' && \\\n' },
{ text: 'cd', tone: 'command' },
{ text: ' ' },
{ text: '../repo.feat', tone: 'argument' },
]);
assert.equal(command.children.map((child) => child.children?.[0]?.value ?? child.value).join(''),
'git worktree add -b feat ../repo.feat && \\\ncd ../repo.feat');
assert.deepEqual(
command.children.filter((child) => child.type === 'element')
.map((child) => child.properties.className[0]),
[
'wt-shell-command',
'wt-shell-argument',
'wt-shell-argument',
'wt-shell-option',
'wt-shell-argument',
'wt-shell-argument',
'wt-shell-command',
'wt-shell-argument',
],
);
assert.deepEqual(outsideCode, structuredClone({
type: 'element',
tagName: 'code',
properties: {},
children: [{
type: 'text',
value: 'git worktree add -b feat ../repo.feat && \\\ncd ../repo.feat',
}],
}));
});
test('raw homepage comparison markup is expanded before shell roles are added', () => {
const source = 'wt switch -c feat';
const tree = {
type: 'root',
children: [{
type: 'raw',
value: '<table class="cmd-compare"><tbody><tr><td><code>'
+ source
+ '</code></td></tr></tbody></table>',
}],
};
rehypeComparisonCommands()(tree);
const table = tree.children[0];
const code = table.children[0].children[0].children[0].children[0];
assert.equal(table.type, 'element');
assert.equal(code.children.map((child) => child.children?.[0]?.value ?? child.value).join(''), source);
assert.deepEqual(
code.children.filter((child) => child.type === 'element')
.map((child) => child.properties.className[0]),
['wt-shell-command', 'wt-shell-argument', 'wt-shell-option', 'wt-shell-argument'],
);
});
test('shell command roles preserve syntax outside the styled grammar', () => {
const source = 'cmd & next\ncmd 2>&1';
assert.equal(shellCommandSegments(source).map(({ text }) => text).join(''), source);
});
test('copied shell commands leave out trailing comments', () => {
assert.equal(withoutTrailingShellComments('wt switch - # Previous worktree'), 'wt switch -');
assert.equal(withoutTrailingShellComments('wt list;# after an operator'), 'wt list;');
assert.equal(withoutTrailingShellComments('echo a\\ #b'), 'echo a\\ #b');
assert.equal(
withoutTrailingShellComments(`git config k '{"a":"#b"}' "c # d" # Direct`),
`git config k '{"a":"#b"}' "c # d"`,
);
assert.equal(withoutTrailingShellComments('echo $# ${#x} a#b \\#c'), 'echo $# ${#x} a#b \\#c');
assert.equal(
withoutTrailingShellComments('# Install\nbrew install wt # the CLI\n'),
'# Install\nbrew install wt\n',
);
assert.equal(
withoutTrailingShellComments("sh -c 'a\n# quoted' # comment"),
"sh -c 'a\n# quoted'",
);
});
test('console blocks give each of several commands its own copy control', () => {
const lines = ['# Recent', '$ wt list', '', '# Failed', '$ wt list --full'].map((text) => ({
text,
editText(start, end, replacement) {
this.text = this.text.slice(0, start) + replacement + this.text.slice(end);
},
}));
const codeBlock = { language: 'console', getLines: () => lines };
const plugin = pluginWorktrunkTerminal();
prepareCodeBlock(plugin, codeBlock);
assert.equal(codeBlock.language, 'bash');
const lineAsts = lines.map((_, lineIndex) => {
const renderData = { lineAst: { properties: {} } };
plugin.hooks.postprocessRenderedLine({ codeBlock, lineIndex, renderData });
return renderData.lineAst;
});
assert.deepEqual(
lines.map((line) => line.text),
['# Recent', 'wt list', '', '# Failed', 'wt list --full'],
);
assert.deepEqual(lineAsts.map((lineAst) => lineAst.properties.className), [
['wt-copyable'],
['wt-command'],
['wt-copyable'],
['wt-copyable'],
['wt-command'],
]);
assert.deepEqual(
lineAsts.map((lineAst) => lineAst.children?.[0].children[1].properties['data-code']),
[undefined, 'wt list', undefined, undefined, 'wt list --full'],
);
const renderData = {
blockAst: {
properties: { className: ['frame'] },
children: [{ type: 'element', properties: { className: ['copy'] }, children: [] }],
},
};
plugin.hooks.postprocessRenderedBlock({ codeBlock, renderData });
assert.deepEqual(renderData.blockAst.children, [], 'the block keeps its own copy control');
});
test('console blocks wrap several commands but never captured output', () => {
const wraps = (texts) => {
const lines = texts.map((text) => ({
text,
editText(start, end, replacement) {
this.text = this.text.slice(0, start) + replacement + this.text.slice(end);
},
}));
const codeBlock = { language: 'console', getLines: () => lines };
prepareCodeBlock(pluginWorktrunkTerminal(), codeBlock);
return codeBlock.props.wrap;
};
assert.equal(wraps(['$ wt list', '$ wt list --full']), true);
assert.equal(wraps(['$ wt list', '$ wt list --full', 'output']), undefined);
assert.equal(wraps(['$ wt list']), undefined);
});
test('console output and its blank lines stay out of copied commands', () => {
const lines = ['$ wt list # recent', 'output', '', '# shell comment'].map((text) => ({
text,
editText(start, end, replacement) {
this.text = this.text.slice(0, start) + replacement + this.text.slice(end);
},
}));
const codeBlock = { language: 'console', getLines: () => lines };
const plugin = pluginWorktrunkTerminal();
prepareCodeBlock(plugin, codeBlock);
const classes = lines.map((_, lineIndex) => {
const renderData = { lineAst: { properties: {} } };
plugin.hooks.postprocessRenderedLine({ codeBlock, lineIndex, renderData });
return renderData.lineAst.properties.className;
});
assert.deepEqual(classes, [
['wt-command'],
['wt-output'],
['wt-output'],
['wt-copyable'],
]);
const copyButton = { type: 'element', tagName: 'button', properties: { 'data-code': 'stale' } };
const renderData = {
blockAst: {
children: [{
type: 'element',
properties: { className: ['copy'] },
children: [copyButton],
}],
},
};
plugin.hooks.postprocessRenderedBlock({ codeBlock, renderData });
assert.equal(copyButton.properties['data-code'], 'wt list\u007f# shell comment');
});
test('console output retains state-color semantics without ANSI in Markdown', () => {
assert.deepEqual(
semanticOutputSegments('@ feat +54 -5 ↑4 ↓1 ⇡3 ? ✓ done'),
[
{ text: '@ feat ' },
{ text: '+54', tone: 'positive' },
{ text: ' ' },
{ text: '-5', tone: 'negative' },
{ text: ' ' },
{ text: '↑4', tone: 'positive' },
{ text: ' ' },
{ text: '↓1', tone: 'negative' },
{ text: ' ' },
{ text: '⇡3', tone: 'positive' },
{ text: ' ' },
{ text: '?', tone: 'warning' },
{ text: ' ' },
{ text: '✓', tone: 'positive' },
{ text: ' done' },
],
);
assert.deepEqual(
semanticOutputSegments('[unoptimized + debuginfo] Allow and remember?'),
[{ text: '[unoptimized + debuginfo] Allow and remember?' }],
);
assert.deepEqual(
semanticOutputSegments('release-5 v1+2 port -3000'),
[{ text: 'release-5 v1+2 port -3000' }],
);
assert.deepEqual(
semanticOutputSegments('@ feat + ↑'),
[{ text: '@ feat + ↑' }],
);
});
test('console commands retain syntax highlighter token spans', () => {
const lines = ['$ wt switch "#412" # inspect the PR'].map((text) => ({
text,
editText(start, end, replacement) {
this.text = this.text.slice(0, start) + replacement + this.text.slice(end);
},
}));
const codeBlock = { language: 'console', getLines: () => lines };
const plugin = pluginWorktrunkTerminal();
prepareCodeBlock(plugin, codeBlock);
const rendered = lines.map((line, lineIndex) => {
const code = {
type: 'element',
tagName: 'div',
properties: { className: ['code'] },
children: [
{
type: 'element',
tagName: 'span',
properties: { style: '--0:#aaa;--1:#111' },
children: [{ type: 'text', value: 'wt' }],
},
{ type: 'text', value: ' switch "#412" ' },
{
type: 'element',
tagName: 'span',
properties: { style: '--0:#bbb;--1:#222' },
children: [{ type: 'text', value: '# inspect the PR' }],
},
],
};
const renderData = {
lineAst: { type: 'element', tagName: 'div', properties: {}, children: [code] },
};
plugin.hooks.postprocessRenderedLine({ codeBlock, line, lineIndex, renderData });
return code.children;
});
assert.deepEqual(rendered, [
[
{
type: 'element',
tagName: 'span',
properties: { style: '--0:#aaa;--1:#111' },
children: [{ type: 'text', value: 'wt' }],
},
{ type: 'text', value: ' switch "#412" ' },
{
type: 'element',
tagName: 'span',
properties: { style: '--0:#bbb;--1:#222' },
children: [{ type: 'text', value: '# inspect the PR' }],
},
],
]);
});
test('command references expose clap syntax roles', () => {
const plugin = pluginWorktrunkTerminal();
const markedBlock = {
language: 'text',
metaOptions: { value: (key) => (key === 'wt-command-reference' ? true : undefined) },
};
const plainBlock = {
language: 'text',
metaOptions: { value: () => undefined },
};
plugin.hooks.preprocessLanguage({ codeBlock: markedBlock });
plugin.hooks.preprocessLanguage({ codeBlock: plainBlock });
for (const [codeBlock, expected] of [[markedBlock, true], [plainBlock, false]]) {
const blockAst = { properties: { className: ['frame'] }, children: [] };
plugin.hooks.postprocessRenderedBlock({ codeBlock, renderData: { blockAst } });
assert.equal(blockAst.properties.className.includes('wt-command-reference'), expected);
}
assert.deepEqual(commandReferenceSegments('Usage: wt list [OPTIONS] <COMMAND>'), [
{ text: 'Usage:', tone: 'heading' },
{ text: ' ' },
{ text: 'wt list', tone: 'command' },
{ text: ' ' },
{ text: '[OPTIONS]', tone: 'value' },
{ text: ' ' },
{ text: '<COMMAND>', tone: 'value' },
]);
assert.deepEqual(commandReferenceSegments(' wt list <COMMAND>'), [
{ text: ' ' },
{ text: 'wt list', tone: 'command' },
{ text: ' ' },
{ text: '<COMMAND>', tone: 'value' },
]);
assert.deepEqual(commandReferenceSegments(' --format <FORMAT>'), [
{ text: ' ' },
{ text: '--format', tone: 'option' },
{ text: ' ' },
{ text: '<FORMAT>', tone: 'value' },
]);
assert.deepEqual(commandReferenceSegments(' [default: table]'), [
{ text: ' ' },
{ text: '[default: table]', tone: 'meta' },
]);
assert.deepEqual(commandReferenceSegments(' [EXTRA_ARGS]...'), [
{ text: ' ' },
{ text: '[EXTRA_ARGS]...', tone: 'value' },
]);
assert.deepEqual(commandReferenceSegments(' <COMMAND>...'), [
{ text: ' ' },
{ text: '<COMMAND>...', tone: 'value' },
]);
assert.deepEqual(commandReferenceSegments(' Possible values:'), [
{ text: ' ' },
{ text: 'Possible values:', tone: 'meta' },
]);
assert.deepEqual(commandReferenceSegments(' - all: Stage everything'), [
{ text: ' - ' },
{ text: 'all', tone: 'value' },
{ text: ':' },
{ text: ' Stage everything' },
]);
assert.deepEqual(commandReferenceSegments(' - json'), [
{ text: ' - ' },
{ text: 'json', tone: 'value' },
]);
assert.deepEqual(commandReferenceSegments('wt step eval - [experimental] Evaluate a template'), [
{ text: 'wt step eval', tone: 'command' },
{ text: ' - ' },
{ text: '[experimental]', tone: 'meta' },
{ text: ' Evaluate a template' },
]);
assert.deepEqual(commandReferenceSegments(' eval [experimental] Evaluate a template'), [
{ text: ' ' },
{ text: 'eval', tone: 'command' },
{ text: ' ' },
{ text: '[experimental]', tone: 'meta' },
{ text: ' Evaluate a template' },
]);
});