@abstractdata/starlight-theme 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@ import { createInterface } from 'node:readline/promises';
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
26
  const PACKAGE_ROOT = resolve(__dirname, '..');
27
27
  const SKILLS_SRC = resolve(PACKAGE_ROOT, 'skills');
28
+ const SCRIPTS_SRC = resolve(PACKAGE_ROOT, 'scripts');
28
29
  const PROJECT_ROOT = getCwd();
29
30
 
30
31
  const c = {
@@ -85,7 +86,7 @@ if (!allDeps['@abstractdata/starlight-theme']) {
85
86
  // ─── Mapping ───────────────────────────────────────────────────────
86
87
  const MAPPINGS = [
87
88
  {
88
- label: 'Claude Code',
89
+ label: 'Claude Code skill',
89
90
  detect: () =>
90
91
  existsSync(resolve(PROJECT_ROOT, '.claude')) ||
91
92
  existsSync(resolve(PROJECT_ROOT, 'CLAUDE.md')),
@@ -93,17 +94,80 @@ const MAPPINGS = [
93
94
  to: '.claude/skills/abstract-data-setup/SKILL.md',
94
95
  },
95
96
  {
96
- label: 'Cursor',
97
+ label: 'Claude Code handshake (CLAUDE.md)',
98
+ detect: () => existsSync(resolve(PROJECT_ROOT, 'CLAUDE.md')),
99
+ from: 'claude/CLAUDE.md',
100
+ to: 'CLAUDE.md',
101
+ },
102
+ {
103
+ label: 'Cursor rule',
97
104
  detect: () => existsSync(resolve(PROJECT_ROOT, '.cursor')),
98
105
  from: 'cursor/abstract-data-setup.mdc',
99
106
  to: '.cursor/rules/abstract-data-setup.mdc',
100
107
  },
101
108
  {
102
- label: 'GitHub Copilot',
109
+ label: 'Cursor welcome rule (always-apply handshake)',
110
+ detect: () => existsSync(resolve(PROJECT_ROOT, '.cursor')),
111
+ from: 'cursor/welcome.mdc',
112
+ to: '.cursor/rules/welcome.mdc',
113
+ },
114
+ // abstract-data-docs-author skill — read source code, write narrative docs
115
+ {
116
+ label: 'Claude Code docs-author skill',
117
+ detect: () =>
118
+ existsSync(resolve(PROJECT_ROOT, '.claude')) ||
119
+ existsSync(resolve(PROJECT_ROOT, 'CLAUDE.md')),
120
+ from: 'claude/abstract-data-docs-author/SKILL.md',
121
+ to: '.claude/skills/abstract-data-docs-author/SKILL.md',
122
+ },
123
+ {
124
+ label: 'Cursor docs-author rule',
125
+ detect: () => existsSync(resolve(PROJECT_ROOT, '.cursor')),
126
+ from: 'cursor/abstract-data-docs-author.mdc',
127
+ to: '.cursor/rules/abstract-data-docs-author.mdc',
128
+ },
129
+ {
130
+ label: 'GitHub Copilot instructions (covers both skills)',
103
131
  detect: () => existsSync(resolve(PROJECT_ROOT, '.github')),
104
132
  from: 'github/copilot-instructions.md',
105
133
  to: '.github/copilot-instructions.md',
106
134
  },
135
+ // Python autodoc scripts — sourced from the package's `scripts/` dir
136
+ // (not `skills/`) so their `from` paths resolve relative to PACKAGE_ROOT
137
+ // via the `fromBase` field below.
138
+ {
139
+ label: 'Python autodoc orchestrator (build-python-docs.mjs)',
140
+ detect: () => existsSync(resolve(PROJECT_ROOT, 'pyproject.toml')) ||
141
+ existsSync(resolve(PROJECT_ROOT, 'setup.py')),
142
+ fromBase: 'scripts',
143
+ from: 'build-python-docs.mjs',
144
+ to: 'scripts/build-python-docs.mjs',
145
+ },
146
+ {
147
+ label: 'Python autodoc config (python-autodoc.json)',
148
+ detect: () => existsSync(resolve(PROJECT_ROOT, 'pyproject.toml')) ||
149
+ existsSync(resolve(PROJECT_ROOT, 'setup.py')),
150
+ fromBase: 'scripts',
151
+ from: 'python-autodoc.json',
152
+ to: 'scripts/python-autodoc.json',
153
+ },
154
+ // TypeScript autodoc scripts — only offered when a TS library shape is detected
155
+ {
156
+ label: 'TypeScript autodoc orchestrator (build-ts-docs.mjs)',
157
+ detect: () => existsSync(resolve(PROJECT_ROOT, 'tsconfig.json')) &&
158
+ existsSync(resolve(PROJECT_ROOT, 'package.json')),
159
+ fromBase: 'scripts',
160
+ from: 'build-ts-docs.mjs',
161
+ to: 'scripts/build-ts-docs.mjs',
162
+ },
163
+ {
164
+ label: 'TypeScript autodoc config (ts-autodoc.json)',
165
+ detect: () => existsSync(resolve(PROJECT_ROOT, 'tsconfig.json')) &&
166
+ existsSync(resolve(PROJECT_ROOT, 'package.json')),
167
+ fromBase: 'scripts',
168
+ from: 'ts-autodoc.json',
169
+ to: 'scripts/ts-autodoc.json',
170
+ },
107
171
  ];
108
172
 
109
173
  // ─── Detection summary ─────────────────────────────────────────────
@@ -121,7 +185,11 @@ const ask = async (prompt) => (await rl.question(prompt)).trim().toLowerCase();
121
185
 
122
186
  const choices = [];
123
187
  for (const m of MAPPINGS) {
124
- const fromPath = resolve(SKILLS_SRC, m.from);
188
+ // Per-mapping source root: defaults to skills/, overridable via fromBase
189
+ // (used by the Python autodoc scripts which live in the package's
190
+ // scripts/ directory, not skills/).
191
+ const sourceBase = m.fromBase === 'scripts' ? SCRIPTS_SRC : SKILLS_SRC;
192
+ const fromPath = resolve(sourceBase, m.from);
125
193
  const toPath = resolve(PROJECT_ROOT, m.to);
126
194
  if (!existsSync(fromPath)) {
127
195
  log(`${c.dim}—${c.reset} skip ${m.label} (not present in package skills/)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abstractdata/starlight-theme",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Abstract Data Documentation Theme — the branded docs system Abstract Data uses across client projects. Built on Astro Starlight. HUD and Calm surfaces, light + dark, motion-aware. Ships with the abstract-data-setup skill (Claude Code, Cursor, GitHub Copilot) for one-command project setup.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "src/",
18
18
  "skills/",
19
+ "scripts/",
19
20
  "bin/"
20
21
  ],
21
22
  "bin": {
@@ -48,11 +49,11 @@
48
49
  "@fontsource-variable/orbitron": "^5.0.0"
49
50
  },
50
51
  "peerDependencies": {
51
- "@astrojs/starlight": ">=0.32.0",
52
- "astro": ">=5.0.0"
52
+ "@astrojs/starlight": ">=0.34.0 <0.38.0",
53
+ "astro": ">=5.0.0 <6.0.0"
53
54
  },
54
55
  "devDependencies": {
55
- "@astrojs/starlight": "^0.34.0",
56
+ "@astrojs/starlight": "~0.37.0",
56
57
  "astro": "^5.10.0",
57
58
  "typescript": "^5.6.0"
58
59
  }
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * build-python-docs.mjs
4
+ *
5
+ * Orchestrates Python autodoc generation for this Starlight site.
6
+ *
7
+ * Reads `scripts/python-autodoc.json` for configuration:
8
+ * - searchPath: relative path to your Python source root (parent of the package directory)
9
+ * - modules: list of fully-qualified module names to document
10
+ * - outputDir: where the generated .md files land (relative to project root)
11
+ * - repoUrl: (optional) base URL for "View source on GitHub" links
12
+ * - repoBranch: (optional) default 'main'
13
+ * - versions: (optional) array of { tag, label, default } for versioned API docs
14
+ * — when present, the script does one build per tag via git worktrees,
15
+ * emitting into <outputDir>/<safeTag>/. The default version's pages
16
+ * ALSO emit at <outputDir>/<page>.md (the un-versioned URL) so existing
17
+ * links keep working.
18
+ *
19
+ * For each module, in each version:
20
+ * 1. Invokes `pydoc-markdown -I <searchPath> -m <module>` to capture markdown.
21
+ * 2. Lifts the first H1 into Starlight `title:` frontmatter, synthesizes a
22
+ * `description:` from the first paragraph, injects `version: <tag>` if versioned.
23
+ * 3. Post-processes thin pages (auto-generates Submodules section on package landings,
24
+ * injects a `:::note` banner on truly-empty pages).
25
+ *
26
+ * Run via:
27
+ * bun run docs:python
28
+ *
29
+ * Requires Python ≥ 3.9 and pydoc-markdown:
30
+ * pipx install pydoc-markdown
31
+ */
32
+ import { execSync } from 'node:child_process';
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
34
+ import { join, resolve, dirname, relative } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { tmpdir } from 'node:os';
37
+
38
+ const __dirname = dirname(fileURLToPath(import.meta.url));
39
+ const PROJECT_ROOT = resolve(__dirname, '..');
40
+ const CONFIG_PATH = resolve(__dirname, 'python-autodoc.json');
41
+
42
+ const c = {
43
+ reset: '\x1b[0m', dim: '\x1b[2m', cyan: '\x1b[36m', gold: '\x1b[33m',
44
+ red: '\x1b[31m', green: '\x1b[32m',
45
+ };
46
+ const log = (...a) => console.log(...a);
47
+ const die = (msg) => { console.error(`${c.red}error${c.reset} ${msg}`); process.exit(1); };
48
+
49
+ // ─── Load config ──────────────────────────────────────────────────────
50
+ if (!existsSync(CONFIG_PATH)) die(`Missing config: ${CONFIG_PATH}`);
51
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
52
+ if (!cfg.searchPath) die('python-autodoc.json: `searchPath` is required.');
53
+ if (!Array.isArray(cfg.modules) || cfg.modules.length === 0) {
54
+ die('python-autodoc.json: `modules` must be a non-empty array.');
55
+ }
56
+
57
+ const ORIGINAL_SEARCH_PATH = resolve(PROJECT_ROOT, cfg.searchPath);
58
+ const outputDir = resolve(PROJECT_ROOT, cfg.outputDir ?? 'src/content/docs/api');
59
+
60
+ if (!existsSync(ORIGINAL_SEARCH_PATH)) {
61
+ die(`searchPath does not exist: ${ORIGINAL_SEARCH_PATH}\n Resolved from cfg.searchPath = "${cfg.searchPath}"`);
62
+ }
63
+
64
+ // ─── Verify pydoc-markdown is available ───────────────────────────────
65
+ log(`${c.dim}→ checking pydoc-markdown${c.reset}`);
66
+ try {
67
+ execSync('pydoc-markdown --version', { stdio: 'ignore' });
68
+ } catch {
69
+ die(`pydoc-markdown not found on PATH. Install it:
70
+ pipx install pydoc-markdown
71
+ # or
72
+ pip install --user pydoc-markdown
73
+ Then re-run this script.`);
74
+ }
75
+
76
+ // ─── Versioning setup ────────────────────────────────────────────────
77
+ //
78
+ // If `versions` is configured, each entry triggers an independent build
79
+ // from a `git worktree` checkout of the source repo at that tag. We need
80
+ // to know:
81
+ // - the source repo root (where `.git` lives) so we can `git -C` it
82
+ // - the relative path from source-repo-root to the original searchPath,
83
+ // so we can map it to the equivalent path inside each worktree
84
+ //
85
+ // We walk up from ORIGINAL_SEARCH_PATH looking for `.git`; bail out if we
86
+ // hit the filesystem root without finding it.
87
+ function findGitRoot(start) {
88
+ let dir = start;
89
+ while (true) {
90
+ if (existsSync(join(dir, '.git'))) return dir;
91
+ const parent = dirname(dir);
92
+ if (parent === dir) return null;
93
+ dir = parent;
94
+ }
95
+ }
96
+
97
+ const versions = Array.isArray(cfg.versions) ? cfg.versions : null;
98
+ let SOURCE_REPO_ROOT = null;
99
+ let SEARCH_PATH_REL = null;
100
+ if (versions) {
101
+ if (versions.length === 0) die('`versions` is an empty array — set it to null/omit, or list at least one version.');
102
+ if (!versions.some((v) => v.tag)) die('`versions[].tag` is required on every entry.');
103
+ if (versions.filter((v) => v.default).length > 1) die('Only one `versions[].default: true` allowed.');
104
+ if (!versions.some((v) => v.default)) {
105
+ log(`${c.gold}warn${c.reset} no version marked default; treating the first one (${versions[0].tag}) as default`);
106
+ versions[0].default = true;
107
+ }
108
+
109
+ SOURCE_REPO_ROOT = findGitRoot(ORIGINAL_SEARCH_PATH);
110
+ if (!SOURCE_REPO_ROOT) {
111
+ die(`versions[] is configured but no .git directory was found above ${ORIGINAL_SEARCH_PATH}.\n Versioned builds require the source to be a git checkout.`);
112
+ }
113
+ SEARCH_PATH_REL = relative(SOURCE_REPO_ROOT, ORIGINAL_SEARCH_PATH);
114
+ log(`${c.dim}→ source repo: ${SOURCE_REPO_ROOT}${c.reset}`);
115
+ log(`${c.dim}→ relative searchPath: ${SEARCH_PATH_REL || '(repo root)'}${c.reset}`);
116
+ }
117
+
118
+ // Make a tag filesystem-safe for use as a directory name. We have to be
119
+ // strict here: Astro's slug normalizer strips dots from URL segments
120
+ // (`0.1.0` → `010`), so if our directory names contain dots the URL the
121
+ // VersionPicker constructs won't match the rendered URL. Convert dots
122
+ // to dashes (and any other non-alphanumeric to dashes) so the directory
123
+ // name AND the URL slug Astro generates from it stay byte-identical.
124
+ // v0.3.0 → 0-3-0
125
+ // v1.0.0-rc.1 → 1-0-0-rc-1
126
+ function safeTag(tag) {
127
+ return tag.replace(/^v/, '').replace(/[^a-zA-Z0-9_-]/g, '-');
128
+ }
129
+
130
+ // ─── Per-build pipeline (one invocation per version, or one total) ─────
131
+ function buildOnce({ searchPath, version }) {
132
+ // version may be null (single-version mode) or { tag, label, default }
133
+ const versionDir = version ? join(outputDir, safeTag(version.tag)) : outputDir;
134
+ mkdirSync(versionDir, { recursive: true });
135
+
136
+ const tagPrefix = version
137
+ ? `${c.cyan}[${version.label ?? version.tag}]${c.reset} `
138
+ : '';
139
+ log(`${c.dim}→ ${tagPrefix}generating ${cfg.modules.length} module page${cfg.modules.length === 1 ? '' : 's'}${c.reset}`);
140
+
141
+ // Two-pass build: first collect every page in memory so the thin-page
142
+ // post-processor can cross-reference siblings (for "Submodules" sections
143
+ // on package landing pages), then write everything to disk.
144
+ const pages = [];
145
+
146
+ for (const mod of cfg.modules) {
147
+ const safeName = mod.replace(/\./g, '_');
148
+ const outPath = join(versionDir, `${safeName}.md`);
149
+
150
+ let markdown;
151
+ try {
152
+ markdown = execSync(
153
+ `pydoc-markdown -I "${searchPath}" -m ${mod}`,
154
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] },
155
+ );
156
+ } catch {
157
+ log(`${c.red} ✗ ${tagPrefix}${mod}${c.reset}`);
158
+ continue;
159
+ }
160
+
161
+ // ─── Frontmatter post-process ────────────────────────────────
162
+ const h1 = markdown.match(/^# (.+?)$/m);
163
+ const title = (h1?.[1] ?? mod).trim().replace(/\\_/g, '_');
164
+ let body = h1 ? markdown.replace(h1[0] + '\n', '') : markdown;
165
+ body = body.replace(/<a id="[^"]*"><\/a>\n?/g, '');
166
+ body = body.replace(
167
+ /:(?:mod|class|func|obj|attr|meth|exc|any|data|const)(?::)?\s*`([^`]+)`/g,
168
+ '`$1`',
169
+ );
170
+ const desc = body.split('\n').find((l) => {
171
+ const t = l.trim();
172
+ if (!t) return false;
173
+ if (/^#{1,6} /.test(t)) return false;
174
+ if (t.startsWith('```')) return false;
175
+ if (t.startsWith('|')) return false;
176
+ if (/^[-*+] /.test(t)) return false;
177
+ if (/^<[^>]+>/.test(t)) return false;
178
+ return true;
179
+ });
180
+ const description = (desc ?? `API reference for \`${mod}\`.`)
181
+ .trim().replace(/`/g, '').replace(/"/g, "'").slice(0, 160);
182
+
183
+ const fmLines = ['---', `title: ${title}`, `description: "${description}"`];
184
+ if (version) {
185
+ // Emit version metadata. `versionDefault: true` lets the bundled
186
+ // <VersionPicker> auto-discover which version to pre-select without
187
+ // duplicating the canonical list outside the autodoc JSON config.
188
+ fmLines.push(`version: "${version.tag}"`);
189
+ if (version.label) fmLines.push(`versionLabel: "${version.label}"`);
190
+ if (version.default) fmLines.push(`versionDefault: true`);
191
+ }
192
+ fmLines.push('---', '');
193
+ const frontmatter = fmLines.join('\n');
194
+
195
+ pages.push({ mod, safeName, outPath, title, description, body, frontmatter });
196
+ log(`${c.green} ✓${c.reset} ${tagPrefix}${mod} ${c.dim}→ ${relative(PROJECT_ROOT, outPath)}${c.reset}`);
197
+ }
198
+
199
+ if (pages.length === 0) {
200
+ log(`${c.red} no pages generated for ${version ? version.tag : 'single build'} — skipping.${c.reset}`);
201
+ return { pages: [] };
202
+ }
203
+
204
+ // ─── Thin-page post-processor ──────────────────────────────────────
205
+ const childrenOf = (mod) => pages.filter((p) => p.mod !== mod && p.mod.startsWith(mod + '.'));
206
+ let enriched = 0;
207
+ let bannered = 0;
208
+
209
+ for (const page of pages) {
210
+ const proseLines = page.body.split('\n').filter((l) => {
211
+ const t = l.trim();
212
+ if (!t) return false;
213
+ if (/^#{1,6} /.test(t)) return false;
214
+ if (t.startsWith('```')) return false;
215
+ if (t.startsWith('|') || /^[-=]{3,}/.test(t)) return false;
216
+ if (/^[-*+] /.test(t)) return false;
217
+ if (/^<[^>]+>/.test(t)) return false;
218
+ return true;
219
+ }).length;
220
+ const bodyChars = page.body.replace(/\s+/g, '').length;
221
+ const isThin = bodyChars < 150 && proseLines < 1;
222
+
223
+ const kids = childrenOf(page.mod);
224
+ const isPackageLanding = kids.length > 0;
225
+
226
+ let newBody = page.body;
227
+ let touched = false;
228
+
229
+ // Stale-version banner: non-default versions get a "Latest is X →"
230
+ // pointer at the top of every page. Lives above the thin/package
231
+ // banners since version drift is the higher-priority signal.
232
+ if (version && !version.default) {
233
+ const defaultVersion = (cfg.versions ?? []).find((v) => v.default);
234
+ const latestLabel = defaultVersion ? (defaultVersion.label ?? defaultVersion.tag) : 'latest';
235
+ const latestPath = defaultVersion
236
+ ? `/${cfg.outputDir.replace(/^src\/content\/docs\/?/, '').replace(/\/$/, '')}/${safeTag(defaultVersion.tag)}/${page.safeName}/`
237
+ : null;
238
+ const link = latestPath ? `[${latestLabel} →](${latestPath})` : latestLabel;
239
+ const stale = [
240
+ '',
241
+ `:::caution[Older version]`,
242
+ `You're viewing **${version.label ?? version.tag}**. Latest is ${link}.`,
243
+ ':::',
244
+ '',
245
+ ].join('\n');
246
+ newBody = stale + newBody;
247
+ touched = true;
248
+ }
249
+
250
+ if (isThin && !isPackageLanding) {
251
+ const noteBlock = [
252
+ '',
253
+ ':::note[This page is sparse]',
254
+ `The auto-generated reference for \`${page.mod}\` is short. Expanding the source docstring at the top of \`${page.mod.replace(/\./g, '/')}.py\` (a sentence about purpose, when to use it, and a tiny example) would populate this page with real context.`,
255
+ ':::',
256
+ '',
257
+ ].join('\n');
258
+ newBody = noteBlock + newBody;
259
+ bannered += 1;
260
+ touched = true;
261
+ log(`${c.gold} ⚠${c.reset} ${tagPrefix}thin-page banner on ${page.mod}`);
262
+ }
263
+
264
+ if (isPackageLanding) {
265
+ const lines = ['', '## Submodules', ''];
266
+ for (const kid of kids) {
267
+ const summary = kid.description && !kid.description.startsWith('API reference for')
268
+ ? ` — ${kid.description}`
269
+ : '';
270
+ lines.push(`- [\`${kid.mod}\`](./${kid.safeName}.md)${summary}`);
271
+ }
272
+ lines.push('');
273
+ const submodulesSection = lines.join('\n');
274
+
275
+ if (isThin) {
276
+ // Replace stub body with brief intro + submodules. If we already
277
+ // prepended a stale-version banner, preserve it at the very top.
278
+ const stalePrefix = newBody.startsWith('\n:::caution[Older version]')
279
+ ? newBody.slice(0, newBody.indexOf(':::\n', 1) + 4) + '\n'
280
+ : '';
281
+ newBody = stalePrefix +
282
+ `\nTop-level package — see submodules below for the documented API surface.\n${submodulesSection}`;
283
+ } else {
284
+ newBody = newBody.replace(/\s+$/, '') + '\n' + submodulesSection;
285
+ }
286
+ enriched += 1;
287
+ touched = true;
288
+ log(`${c.green} ✓${c.reset} added Submodules section to ${page.mod} (${kids.length} child${kids.length === 1 ? '' : 'ren'})`);
289
+ }
290
+
291
+ if (touched && cfg.repoUrl) {
292
+ const branch = version ? version.tag : (cfg.repoBranch ?? 'main');
293
+ const repo = cfg.repoUrl.replace(/\/$/, '');
294
+ const sourcePath = page.mod.replace(/\./g, '/');
295
+ const target = isPackageLanding
296
+ ? `${sourcePath}/__init__.py`
297
+ : `${sourcePath}.py`;
298
+ newBody = newBody.replace(/\s+$/, '') +
299
+ `\n\n## See also\n\n- [View source on GitHub](${repo}/blob/${branch}/${target})\n`;
300
+ }
301
+
302
+ writeFileSync(page.outPath, page.frontmatter + newBody);
303
+ }
304
+
305
+ log('');
306
+ log(`${c.green}✓${c.reset} ${tagPrefix}Generated ${c.gold}${pages.length}${c.reset} page${pages.length === 1 ? '' : 's'} in ${c.cyan}${relative(PROJECT_ROOT, versionDir)}${c.reset}/`);
307
+ if (enriched || bannered) {
308
+ log(`${c.dim} ${enriched} package landing${enriched === 1 ? '' : 's'} enriched, ${bannered} thin page${bannered === 1 ? '' : 's'} flagged${c.reset}`);
309
+ }
310
+
311
+ return { pages };
312
+ }
313
+
314
+ // ─── Run: single-build or per-version with worktrees ──────────────────
315
+ const createdWorktrees = [];
316
+
317
+ function cleanup() {
318
+ for (const wt of createdWorktrees) {
319
+ try {
320
+ execSync(`git -C "${SOURCE_REPO_ROOT}" worktree remove --force "${wt}"`,
321
+ { stdio: 'ignore' });
322
+ } catch {
323
+ // best-effort; if remove failed, rm -rf the directory
324
+ try { rmSync(wt, { recursive: true, force: true }); } catch {}
325
+ }
326
+ }
327
+ }
328
+
329
+ process.on('exit', cleanup);
330
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
331
+
332
+ try {
333
+ if (!versions) {
334
+ // Single-version build (existing behavior)
335
+ buildOnce({ searchPath: ORIGINAL_SEARCH_PATH, version: null });
336
+ } else {
337
+ // Per-version builds via git worktrees
338
+ for (const v of versions) {
339
+ const wt = mkdtempSync(join(tmpdir(), `autodoc-${safeTag(v.tag)}-`));
340
+ createdWorktrees.push(wt);
341
+ log(`${c.dim}→ git worktree add ${wt} ${v.tag}${c.reset}`);
342
+ try {
343
+ execSync(`git -C "${SOURCE_REPO_ROOT}" worktree add --detach "${wt}" "${v.tag}"`,
344
+ { stdio: 'inherit' });
345
+ } catch {
346
+ die(`Failed to create git worktree for ${v.tag}.\n Verify the tag exists in ${SOURCE_REPO_ROOT}: git tag --list "${v.tag}"`);
347
+ }
348
+ const wtSearchPath = SEARCH_PATH_REL ? join(wt, SEARCH_PATH_REL) : wt;
349
+ if (!existsSync(wtSearchPath)) {
350
+ log(`${c.gold}warn${c.reset} ${v.tag}: searchPath ${wtSearchPath} doesn't exist (the directory layout may have changed). Skipping.`);
351
+ continue;
352
+ }
353
+ buildOnce({ searchPath: wtSearchPath, version: v });
354
+ }
355
+
356
+ // Also emit the default version at the un-versioned path so existing
357
+ // links to /api/foo/ keep resolving without a redirect step.
358
+ const defaultV = versions.find((v) => v.default);
359
+ if (defaultV) {
360
+ log(`${c.dim}→ aliasing ${defaultV.tag} as the default (un-versioned) build${c.reset}`);
361
+ const wt = mkdtempSync(join(tmpdir(), `autodoc-default-`));
362
+ createdWorktrees.push(wt);
363
+ try {
364
+ execSync(`git -C "${SOURCE_REPO_ROOT}" worktree add --detach "${wt}" "${defaultV.tag}"`,
365
+ { stdio: 'ignore' });
366
+ const wtSearchPath = SEARCH_PATH_REL ? join(wt, SEARCH_PATH_REL) : wt;
367
+ // Stash original outputDir, point at root for un-versioned emit
368
+ buildOnce({ searchPath: wtSearchPath, version: null });
369
+ } catch (err) {
370
+ log(`${c.gold}warn${c.reset} default-alias build failed: ${err.message}`);
371
+ }
372
+ }
373
+ }
374
+ } finally {
375
+ cleanup();
376
+ }
377
+
378
+ log('');
379
+ log(`${c.dim}Sidebar wiring (astro.config.mjs):${c.reset}`);
380
+ log(`${c.dim} { label: 'API Reference', autogenerate: { directory: '${cfg.outputDir.replace(/^src\/content\/docs\/?/, '')}' } }${c.reset}`);
381
+ if (versions) {
382
+ log(`${c.dim} → with ${versions.length} version${versions.length === 1 ? '' : 's'}, the sidebar will auto-group by version subdirectory.${c.reset}`);
383
+ log(`${c.dim} → import VersionPicker: import { VersionPicker } from '@abstractdata/starlight-theme/components';${c.reset}`);
384
+ }
385
+ log('');