@abstractdata/starlight-theme 0.3.1 → 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.
@@ -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('');