Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ Use root-relative links in canonical Markdown:

The sync pipeline expands them to full `https://worktrunk.dev/...` URLs for
README and agent-skill copies. Do not add framework-specific link syntax.
`stable-heading-ids.mjs` preserves the site's established anchor scheme, and
`test:site` verifies every built internal page link and fragment.
`stable-heading-ids.mjs` defines the heading-anchor scheme, including the ids
scoped under each subcommand section, and `test:site` verifies every built
internal page link and fragment.

Images and demos use root-relative paths into `public/`:

Expand Down
12 changes: 6 additions & 6 deletions docs/src/content/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ Manage command approvals.

Project hooks and project aliases prompt for approval on first run to prevent untrusted projects from running arbitrary commands. Approvals from both flows are stored together.

### Approval examples
### Examples

List commands and their approval status for current project:
```console
Expand Down Expand Up @@ -936,7 +936,7 @@ Inspect and preview aliases.

Aliases are command templates configured in user (`~/.config/worktrunk/config.toml`) or project (`.config/wt.toml`) config and run as `wt <name>`. See the [Extending Worktrunk guide](/extending/#aliases) for the configuration format.

### Alias examples
### Examples

Show every configured alias's template:
```console
Expand Down Expand Up @@ -984,7 +984,7 @@ State is stored in `.git/` (config entries and log files), separate from configu
- **vars**: [Custom variables per branch](/config/#wt-config-state-vars)
- **logs**: [Operation and debug logs](/config/#wt-config-state-logs)

### State examples
### Examples

Get the default branch:
```console
Expand Down Expand Up @@ -1060,7 +1060,7 @@ View or drop worktrunk's regenerable caches in one place. Everything here is reb

Without a subcommand, runs `get`.

### Cache examples
### Examples

Show cache contents:
```console
Expand Down Expand Up @@ -1201,7 +1201,7 @@ All logs are stored in `.git/wt/logs/` (in the main worktree's git directory). A

`wt config state logs --format=json` emits three arrays — `command_log`, `hook_output`, `diagnostic`. Each entry carries a `file` (relative), `path` (absolute), `size`, and `modified_at` (unix seconds). Hook-output entries additionally expose `branch`, `source` (`user` / `project` / `internal`), `hook_type` (the `post-*` kind, or `null` for internal ops), and `name`. Filter with `jq` to pick out a specific entry.

### Log examples
### Examples

List all log files:
```console
Expand Down Expand Up @@ -1343,7 +1343,7 @@ Custom variables per branch.

Store custom variables per branch. Values are stored as-is — plain strings or JSON.

### Variable examples
### Examples

Set and get values:
```console
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Git TUIs operate on a single repository. Worktrunk manages multiple worktrees, r

Worktrees share one `.git`. Each adds a checkout of the tracked files, plus whatever gitignored build output you copy in.

On APFS, btrfs, and XFS (not ext4 or NTFS), [`wt step copy-ignored`](/step/#copy-on-write) reflinks that output, so a new worktree shares the primary worktree's disk blocks. A later build rewrites only what changed, and the rest stays shared. On one machine, 56 worktrees of a Rust repository with a 40GB `target/` came to 2.6TB by `du` and 0.7TB on disk.
On APFS, btrfs, and XFS (not ext4 or NTFS), [`wt step copy-ignored`](/step/#wt-step-copy-ignored--copy-on-write) reflinks that output, so a new worktree shares the primary worktree's disk blocks. A later build rewrites only what changed, and the rest stays shared. On one machine, 56 worktrees of a Rust repository with a 40GB `target/` came to 2.6TB by `du` and 0.7TB on disk.

## Does Worktrunk support stacked branches?

Expand Down
11 changes: 2 additions & 9 deletions docs/src/plugins/pagefind-command-references.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
const fragmentTitleClass = 'wt-pagefind-fragment-title';

function nodeText(node) {
if (node.type === 'text') return node.value;
return node.children?.map(nodeText).join('') ?? '';
}
import { isCommandHeading, nodeText } from './stable-heading-ids.mjs';

function isCommandHeading(text) {
return /^wt\s+\S/u.test(text.trim());
}
const fragmentTitleClass = 'wt-pagefind-fragment-title';

function escapeHtml(text) {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
Expand Down
28 changes: 26 additions & 2 deletions docs/src/plugins/stable-heading-ids.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
function nodeText(node) {
export function nodeText(node) {
if (node.type === 'text') return node.value;
return node.children?.map(nodeText).join('') ?? '';
}

/** Whether a heading opens a generated subcommand section, such as "wt step commit". */
export function isCommandHeading(text) {
return /^wt\s+\S/u.test(text.trim());
}

/** Preserve the heading-anchor scheme used by Worktrunk's original site. */
export function slugHeading(text) {
return text
Expand All @@ -12,17 +17,36 @@ export function slugHeading(text) {
.replace(/^-|-$/g, '');
}

/**
* Gives every heading a public anchor id: its slug.
*
* Command pages append each subcommand's help under an H2 such as "wt step push",
* so headings like "Examples" and "Command reference" repeat down the page. Below
* a subcommand's H2, until the next H1 or H2, ids are scoped by that section:
* "Examples" under "wt step push" is `wt-step-push--examples`. The id then depends
* only on the heading and its subcommand, not on how many same-named headings
* precede it, and the help text keeps its unqualified headings for the terminal.
* A slug never contains `--`, so a scoped id cannot equal another heading's slug:
* "Cache" under "wt config state" and the "wt config state cache" section differ.
*
* A heading that still repeats within one scope takes `-1`, `-2`, … in page order.
*/
export function rehypeStableHeadingIds() {
return (tree) => {
const slugs = new Map();
let scope;

function visit(node) {
if (node.type === 'element' && /^h[1-6]$/.test(node.tagName)) {
const base = slugHeading(nodeText(node));
const text = nodeText(node);
const level = Number(node.tagName[1]);
if (level <= 2) scope = undefined;
const base = scope ? `${scope}--${slugHeading(text)}` : slugHeading(text);
const occurrence = slugs.get(base) ?? 0;
slugs.set(base, occurrence + 1);
node.properties ??= {};
node.properties.id = occurrence === 0 ? base : `${base}-${occurrence}`;
if (level === 2 && isCommandHeading(text)) scope = node.properties.id;
}
node.children?.forEach(visit);
}
Expand Down
13 changes: 8 additions & 5 deletions docs/tests/built-site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ test('build preserves the public route contract', async () => {
);
}
assert.doesNotMatch(configToc, /href="#claude-code"/);
assert.doesNotMatch(configToc, /href="#command-reference-1"/);
assert.doesNotMatch(configToc, /href="#wt-config-[^"]*--command-reference"/);

const structuralSections = new Map([
['/hook/', ['hook-types', 'security', 'configuration', 'running-hooks-manually', 'recipes']],
Expand Down Expand Up @@ -276,9 +276,12 @@ test('nested command references expose unique search-only fragment titles', asyn
assert.match(stepPage, /<h2 id="command-reference">Command reference<\/h2>/);

const references = [...stepPage.matchAll(
/<h3 id="command-reference-\d+"><span class="wt-pagefind-fragment-title" hidden aria-hidden="true">(wt step [^<]+) — <\/span>Command reference<\/h3>/g,
)].map((match) => match[1]);
assert.deepEqual(references, [
/<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',
Expand All @@ -296,7 +299,7 @@ test('nested command references expose unique search-only fragment titles', asyn
const listPage = await readFile(routeFile('/list/'), 'utf8');
assert.match(
listPage,
/<h3 id="command-reference-1"><span class="wt-pagefind-fragment-title" hidden aria-hidden="true">wt list statusline — <\/span>Command reference<\/h3>/,
/<h3 id="wt-list-statusline--command-reference"><span class="wt-pagefind-fragment-title" hidden aria-hidden="true">wt list statusline — <\/span>Command reference<\/h3>/,
);
});

Expand Down
28 changes: 28 additions & 0 deletions docs/tests/plugins.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,34 @@ test('duplicate heading slugs are unique', () => {
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',
Expand Down
12 changes: 6 additions & 6 deletions plugins/worktrunk/skills/worktrunk/reference/config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/faq.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions skills/worktrunk/reference/config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/worktrunk/reference/faq.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading