@tikoci/rosetta 0.10.0 → 0.11.0-alpha.87

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,643 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * extract-docusaurus.ts — Parse manual.mikrotik.com /docs prose into SQLite.
5
+ *
6
+ * Replaces extract-html.ts's role for fresh prose extraction (extract-html.ts stays
7
+ * for rebuilding historical Confluence-era release DBs only — see MANUAL.md and
8
+ * DESIGN.md). Scope: /docs/** prose only — CLI Reference (/docs/cli-reference/*) and
9
+ * /docs/tags/* are excluded (separate, not-yet-built tasks per B-0012's "Proposed
10
+ * migration task files" #2/#3), and the standalone /hardware section is a different
11
+ * URL prefix entirely, out of scope here.
12
+ *
13
+ * Discovers pages via sitemap.xml, fetches each page's raw Markdown (`{url}.md`),
14
+ * and populates pages/sections/properties/callouts using the rosetta-id scheme
15
+ * validated by T-0034 (see src/rosetta-id.ts, briefings/B-0012-docusaurus-manual-migration.md
16
+ * "H7 — Identity / rosetta-id design").
17
+ *
18
+ * Usage:
19
+ * bun run src/extract-docusaurus.ts # live fetch, caches .md to CACHE_DIR
20
+ * bun run src/extract-docusaurus.ts --from-cache # re-extract from CACHE_DIR, no network
21
+ * bun run src/extract-docusaurus.ts --limit=25 # cap page count (smoke-testing)
22
+ * bun run src/extract-docusaurus.ts --check-counts # compare extracted count vs llms.txt (non-blocking)
23
+ * bun run src/extract-docusaurus.ts --check-counts --strict # same, but exit 1 on mismatch
24
+ */
25
+
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { dirname, join, resolve, sep } from "node:path";
28
+ import { db, initDb } from "./db.ts";
29
+ import { deriveRosettaId, loadSitemapUrls, rosettaIdToUrl } from "./rosetta-id.ts";
30
+
31
+ const BASE = "https://manual.mikrotik.com";
32
+ const SITEMAP_URL = `${BASE}/sitemap.xml`;
33
+ const LLMS_TXT_URL = `${BASE}/llms.txt`;
34
+ const PROJECT_ROOT = join(import.meta.dirname, "..");
35
+ const DEFAULT_CACHE_DIR = join(PROJECT_ROOT, "manual", "pages");
36
+ const FETCH_DELAY_MS = 100;
37
+
38
+ // ── CLI flags ──
39
+
40
+ const argv = process.argv.slice(2);
41
+ const FROM_CACHE = argv.includes("--from-cache");
42
+ const CHECK_COUNTS = argv.includes("--check-counts");
43
+ const STRICT = argv.includes("--strict");
44
+ const limitArg = argv.find((a) => a.startsWith("--limit="));
45
+ const LIMIT = limitArg ? Number(limitArg.slice("--limit=".length)) : undefined;
46
+ const cacheDirArg = argv.find((a) => a.startsWith("--cache-dir="));
47
+ const CACHE_DIR = cacheDirArg ? cacheDirArg.slice("--cache-dir=".length) : DEFAULT_CACHE_DIR;
48
+
49
+ // ── Scope filter ──
50
+
51
+ /**
52
+ * In scope: /docs/** prose only, excluding CLI Reference and the tag-index pages.
53
+ * The standalone /hardware/* section uses a different URL prefix and is already
54
+ * excluded by the leading "/docs/" check.
55
+ */
56
+ export function isInScopeDocsUrl(urlOrPath: string): boolean {
57
+ let path: string;
58
+ try {
59
+ path = new URL(urlOrPath).pathname;
60
+ } catch {
61
+ path = urlOrPath;
62
+ }
63
+ if (!path.startsWith("/docs/")) return false;
64
+ // Strip an optional Docusaurus version prefix (/docs/next/… or /docs/<semver>/…)
65
+ // before the exclusion checks so versioned CLI-reference/tag pages are excluded the
66
+ // same as unversioned ones — mirrors deriveRosettaId()'s version handling.
67
+ // manual.mikrotik.com is unversioned today, but B-0012 H7 treats this as cheap now.
68
+ path = path.replace(/^\/docs\/(?:next|v?\d+(?:\.\d+)*(?:-[a-z0-9.]+)?)\//, "/docs/");
69
+ if (path.startsWith("/docs/cli-reference/")) return false;
70
+ // The tag-index root ("/docs/tags", no trailing slash, no real .md content —
71
+ // confirmed live 2026-07-07: 404s) and individual tag pages both excluded.
72
+ if (path === "/docs/tags" || path.startsWith("/docs/tags/")) return false;
73
+ return true;
74
+ }
75
+
76
+ // ── Markdown parsing ──
77
+
78
+ export interface ParsedProperty {
79
+ name: string;
80
+ rawType: string | null;
81
+ defaultVal: string | null;
82
+ description: string;
83
+ section: string | null;
84
+ malformedEmphasis: boolean;
85
+ }
86
+
87
+ export interface ParsedCallout {
88
+ type: string;
89
+ content: string;
90
+ sortOrder: number;
91
+ }
92
+
93
+ export interface ParsedSection {
94
+ heading: string;
95
+ level: number;
96
+ anchorId: string;
97
+ text: string;
98
+ code: string;
99
+ wordCount: number;
100
+ sortOrder: number;
101
+ }
102
+
103
+ export interface ParsedPage {
104
+ rosettaId: string;
105
+ url: string;
106
+ slug: string;
107
+ title: string;
108
+ path: string;
109
+ depth: number;
110
+ text: string;
111
+ code: string;
112
+ codeLang: string | null;
113
+ wordCount: number;
114
+ codeLines: number;
115
+ sections: ParsedSection[];
116
+ properties: ParsedProperty[];
117
+ callouts: ParsedCallout[];
118
+ }
119
+
120
+ /** GitHub-slugger-style approximation of Docusaurus's auto-generated heading anchors. */
121
+ export function slugify(text: string): string {
122
+ return text
123
+ .toLowerCase()
124
+ .trim()
125
+ .replace(/[^a-z0-9\s-]/g, "")
126
+ .replace(/\s+/g, "-")
127
+ .replace(/-+/g, "-")
128
+ .replace(/^-|-$/g, "");
129
+ }
130
+
131
+ /**
132
+ * Tracks whether the current line is inside a fenced code block (``` ... ```).
133
+ * RouterOS example scripts use `#` for comments (e.g. `# Drop ARP frames...`), which
134
+ * looks exactly like a Markdown heading to a naive line-by-line regex — without this,
135
+ * heading/section-context detection misfires inside code fences (found via dot1x.md's
136
+ * `RouterOS Authenticator configuration` example, which contains several `# ...` comment
137
+ * lines that were wrongly detected as new top-level page sections).
138
+ */
139
+ function makeFenceTracker() {
140
+ let inFence = false;
141
+ return (line: string): boolean => {
142
+ if (/^```/.test(line)) {
143
+ inFence = !inFence;
144
+ return true; // the fence delimiter line itself counts as "inside" for callers' purposes
145
+ }
146
+ return inFence;
147
+ };
148
+ }
149
+
150
+ /** Split a Markdown table row into cells, respecting `\|` as an escaped literal pipe. */
151
+ function splitTableRow(line: string): string[] {
152
+ const cells: string[] = [];
153
+ let current = "";
154
+ for (let i = 0; i < line.length; i++) {
155
+ if (line[i] === "\\" && line[i + 1] === "|") {
156
+ current += "|";
157
+ i++;
158
+ continue;
159
+ }
160
+ if (line[i] === "|") {
161
+ cells.push(current.trim());
162
+ current = "";
163
+ continue;
164
+ }
165
+ current += line[i];
166
+ }
167
+ cells.push(current.trim());
168
+ if (cells[0] === "") cells.shift();
169
+ if (cells[cells.length - 1] === "") cells.pop();
170
+ return cells;
171
+ }
172
+
173
+ /**
174
+ * Parse a property-cell's raw text into name/type/default.
175
+ * Tolerant of the malformed-bold-emphasis pattern found in dhcp.md's check-gateway
176
+ * row (B-0012 H4): `**check-gateway** *(none \| arp \| bfd \| ping***;** Default: **none)**`
177
+ * — the closing bold delimiter lands in the wrong place.
178
+ */
179
+ function parsePropertyCell(
180
+ cellText: string,
181
+ ): { name: string; rawType: string | null; defaultVal: string | null; malformed: boolean } | null {
182
+ const nameMatch = cellText.match(/\*\*([a-z0-9][a-z0-9-]*)\*\*/i);
183
+ if (!nameMatch) return null;
184
+ const name = nameMatch[1];
185
+
186
+ const defaultMatch = cellText.match(/Default:\s*\*{0,2}([^)*]*)\*{0,2}\)?/i);
187
+ const defaultVal = defaultMatch ? defaultMatch[1].trim() || null : null;
188
+
189
+ const parenMatch = cellText.match(/\(([^)]*)\)/);
190
+ const rawType = parenMatch ? parenMatch[1].replace(/Default:.*/i, "").replace(/[*;]+$/, "").trim() || null : null;
191
+
192
+ // Malformed-emphasis heuristic: an odd number of `**` markers, or `***` runs
193
+ // (three-or-more asterisks in a row), which well-formed bold/italic never produces.
194
+ const boldMarkers = (cellText.match(/\*\*/g) || []).length;
195
+ const malformed = boldMarkers % 2 !== 0 || /\*{3,}/.test(cellText);
196
+
197
+ return { name, rawType, defaultVal, malformed };
198
+ }
199
+
200
+ /**
201
+ * Parse Markdown property tables: `| Property | Description |` or `| Parameter | Description |`
202
+ * (both header spellings observed live — sms.md uses "Parameter", dhcp.md uses "Property").
203
+ * Section attribution is the nearest preceding heading of any level, matching extract-html.ts's
204
+ * "nearest preceding heading" behavior for the Confluence corpus.
205
+ */
206
+ export function parseProperties(md: string): ParsedProperty[] {
207
+ const lines = md.split("\n");
208
+ const properties: ParsedProperty[] = [];
209
+ let currentSection: string | null = null;
210
+ const isFenced = makeFenceTracker();
211
+
212
+ for (let i = 0; i < lines.length; i++) {
213
+ const line = lines[i];
214
+ if (isFenced(line)) continue;
215
+
216
+ const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
217
+ if (headingMatch) {
218
+ currentSection = headingMatch[1].trim();
219
+ continue;
220
+ }
221
+
222
+ if (
223
+ /^\|.*\b(property|parameter)\b.*\|$/i.test(line) &&
224
+ lines[i + 1] &&
225
+ /^\|[\s:|-]+\|$/.test(lines[i + 1])
226
+ ) {
227
+ i += 2; // skip header + separator
228
+ while (i < lines.length && lines[i].trim().startsWith("|")) {
229
+ const cells = splitTableRow(lines[i]);
230
+ if (cells.length >= 2) {
231
+ const parsed = parsePropertyCell(cells[0]);
232
+ if (parsed) {
233
+ properties.push({
234
+ name: parsed.name,
235
+ rawType: parsed.rawType,
236
+ defaultVal: parsed.defaultVal,
237
+ description: cells[1],
238
+ section: currentSection,
239
+ malformedEmphasis: parsed.malformed,
240
+ });
241
+ }
242
+ }
243
+ i++;
244
+ }
245
+ i--; // outer loop will increment
246
+ }
247
+ }
248
+
249
+ return properties;
250
+ }
251
+
252
+ /** Parse :::type ... ::: (and ::::-nested) admonition blocks into callouts. */
253
+ export function parseCallouts(md: string): ParsedCallout[] {
254
+ const lines = md.split("\n");
255
+ const callouts: ParsedCallout[] = [];
256
+ const stack: Array<{ type: string; fenceWidth: number; contentLines: string[] }> = [];
257
+ let sortOrder = 0;
258
+
259
+ for (const line of lines) {
260
+ const fenceMatch = line.match(/^(:{3,})(\w+)?\s*$/);
261
+ if (fenceMatch) {
262
+ const fenceWidth = fenceMatch[1].length;
263
+ const type = fenceMatch[2];
264
+ if (type) {
265
+ stack.push({ type, fenceWidth, contentLines: [] });
266
+ } else if (stack.length > 0 && stack[stack.length - 1].fenceWidth === fenceWidth) {
267
+ const closed = stack.pop();
268
+ if (closed) {
269
+ callouts.push({ type: closed.type, content: closed.contentLines.join("\n").trim(), sortOrder: sortOrder++ });
270
+ }
271
+ } else if (stack.length > 0) {
272
+ stack[stack.length - 1].contentLines.push(line);
273
+ }
274
+ continue;
275
+ }
276
+ if (stack.length > 0) stack[stack.length - 1].contentLines.push(line);
277
+ }
278
+
279
+ return callouts;
280
+ }
281
+
282
+ /**
283
+ * Resolve a single relative Markdown link target against the page's own URL.
284
+ * Preserves a `#anchor` fragment (e.g. `./dhcp.md#dhcp-server`) — deriveRosettaId
285
+ * intentionally drops the fragment for identity purposes (H7), but a link rewritten
286
+ * for human/agent navigation should still land on the right section, not just the page.
287
+ */
288
+ function resolveLinkTarget(target: string, pageUrl: string): string {
289
+ if (/^https?:\/\//.test(target)) return target;
290
+ if (target.startsWith("#")) return target;
291
+ const resolved = new URL(target, pageUrl);
292
+ const base = rosettaIdToUrl(deriveRosettaId(resolved.toString()));
293
+ return resolved.hash ? `${base}${resolved.hash}` : base;
294
+ }
295
+
296
+ /**
297
+ * Rewrite relative Markdown links `[text](target)` inside a description to live
298
+ * manual.mikrotik.com URLs so descriptions never carry a broken relative path once
299
+ * stored outside their original page context (B-0012 H4/H7).
300
+ */
301
+ export function resolveDescriptionLinks(description: string, pageUrl: string): string {
302
+ return description.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_whole, text, target) => {
303
+ const resolved = resolveLinkTarget(target, pageUrl);
304
+ return `[${text}](${resolved})`;
305
+ });
306
+ }
307
+
308
+ /** Extract fenced code blocks (```lang ... ```), returning joined content and observed languages. */
309
+ export function extractCodeBlocks(md: string): { code: string; codeLang: string | null } {
310
+ const blocks: string[] = [];
311
+ const langs = new Set<string>();
312
+ const re = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
313
+ let m: RegExpExecArray | null;
314
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec-loop idiom
315
+ while ((m = re.exec(md))) {
316
+ const [, lang, body] = m;
317
+ if (lang) langs.add(lang);
318
+ const trimmed = body.trim();
319
+ if (trimmed) blocks.push(trimmed);
320
+ }
321
+ return { code: blocks.join("\n\n"), codeLang: langs.size > 0 ? [...langs].join(",") : null };
322
+ }
323
+
324
+ /**
325
+ * Split page body into sections by h1–h3 headings, mirroring extract-html.ts's
326
+ * extractSections (which also only splits on h1–h3, folding deeper headings into
327
+ * the enclosing section's text). Skips a leading duplicate of the page title — the
328
+ * raw .md source repeats the H1 immediately after the AI-summary blockquote (observed
329
+ * live on dhcp.md, dot1x.md, address-lists.md), which would otherwise mint a spurious
330
+ * empty section.
331
+ */
332
+ export function parseSections(md: string, title: string): ParsedSection[] {
333
+ const lines = md.split("\n");
334
+ const headings: Array<{ level: number; heading: string; lineIndex: number }> = [];
335
+ const isFenced = makeFenceTracker();
336
+
337
+ for (let i = 0; i < lines.length; i++) {
338
+ if (isFenced(lines[i])) continue;
339
+ const m = lines[i].match(/^(#{1,3})\s+(.+)$/);
340
+ if (m) headings.push({ level: m[1].length, heading: m[2].trim(), lineIndex: i });
341
+ }
342
+
343
+ // Drop leading duplicate(s) of the page title (see doc comment above) — the live
344
+ // source has repeated it exactly once so far, but loop rather than a single `if`
345
+ // in case a future page repeats it more than once.
346
+ while (headings.length > 0 && headings[0].heading === title) headings.shift();
347
+
348
+ const usedAnchors = new Map<string, number>();
349
+ const anchorFor = (heading: string): string => {
350
+ const base = slugify(heading);
351
+ const count = usedAnchors.get(base) ?? 0;
352
+ usedAnchors.set(base, count + 1);
353
+ return count === 0 ? base : `${base}-${count}`;
354
+ };
355
+
356
+ return headings.map((h, i) => {
357
+ const startLine = h.lineIndex + 1;
358
+ const endLine = headings[i + 1]?.lineIndex ?? lines.length;
359
+ const sectionMd = lines.slice(startLine, endLine).join("\n").trim();
360
+ const { code } = extractCodeBlocks(sectionMd);
361
+ const text = sectionMd;
362
+ return {
363
+ heading: h.heading,
364
+ level: h.level,
365
+ anchorId: anchorFor(h.heading),
366
+ text,
367
+ code,
368
+ wordCount: text.split(/\s+/).filter(Boolean).length,
369
+ sortOrder: i,
370
+ };
371
+ });
372
+ }
373
+
374
+ /** Parse one page's raw Markdown body into the full structured shape stored in the DB. */
375
+ export function parsePage(md: string, pageUrl: string): ParsedPage {
376
+ const rosettaId = deriveRosettaId(pageUrl);
377
+ const titleMatch = md.match(/^#\s+(.+)$/m);
378
+ const title = titleMatch ? titleMatch[1].trim() : rosettaId;
379
+
380
+ const segments = rosettaId.split("/");
381
+ const slug = segments[segments.length - 1];
382
+ const path = segments.join(" > ");
383
+ const depth = segments.length;
384
+
385
+ const { code, codeLang } = extractCodeBlocks(md);
386
+ const text = md.trim();
387
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
388
+ const codeLines = code.split("\n").filter((l) => l.trim()).length;
389
+
390
+ const properties = parseProperties(md).map((p) => ({
391
+ ...p,
392
+ description: resolveDescriptionLinks(p.description, pageUrl),
393
+ }));
394
+ const callouts = parseCallouts(md);
395
+ const sections = parseSections(md, title);
396
+
397
+ return { rosettaId, url: pageUrl, slug, title, path, depth, text, code, codeLang, wordCount, codeLines, sections, properties, callouts };
398
+ }
399
+
400
+ // ── Fetching / caching ──
401
+
402
+ function cachePathFor(rosettaId: string): string {
403
+ const target = join(CACHE_DIR, `${rosettaId}.md`);
404
+ // Defense-in-depth: rosettaId derives from network-fetched sitemap <loc> URLs, so a
405
+ // malformed/hostile path must never let a cache write escape CACHE_DIR (CodeQL:
406
+ // "Network data written to file"). URL normalization already collapses `..`, but
407
+ // validate the resolved target stays contained rather than trusting that.
408
+ const root = resolve(CACHE_DIR);
409
+ const full = resolve(target);
410
+ if (full !== root && !full.startsWith(root + sep)) {
411
+ throw new Error(`rosetta-id ${JSON.stringify(rosettaId)} resolves outside cache dir ${CACHE_DIR}`);
412
+ }
413
+ return target;
414
+ }
415
+
416
+ /**
417
+ * Category/index pages (a directory's landing page, e.g.
418
+ * .../authentication-authorization-accounting/) are listed in sitemap.xml with a
419
+ * trailing slash and serve their Markdown at `index.md`, not `<slug>.md` — confirmed
420
+ * live (2026-07-07): `.../accounting.md` 404s, `.../accounting/index.md` is 200.
421
+ */
422
+ export function markdownUrlFor(url: string): string {
423
+ return url.endsWith("/") ? `${url}index.md` : `${url}.md`;
424
+ }
425
+
426
+ async function fetchMarkdown(url: string): Promise<string> {
427
+ const mdUrl = markdownUrlFor(url);
428
+ const res = await fetch(mdUrl, { signal: AbortSignal.timeout(10_000) });
429
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${mdUrl}`);
430
+ return res.text();
431
+ }
432
+
433
+ function delay(ms: number): Promise<void> {
434
+ return new Promise((resolve) => setTimeout(resolve, ms));
435
+ }
436
+
437
+ /** Recursively list every cached .md file under CACHE_DIR, returning rosetta-ids. */
438
+ function listCachedRosettaIds(dir: string, prefix = ""): string[] {
439
+ if (!existsSync(dir)) return [];
440
+ const ids: string[] = [];
441
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
442
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
443
+ if (entry.isDirectory()) {
444
+ ids.push(...listCachedRosettaIds(join(dir, entry.name), rel));
445
+ } else if (entry.name.endsWith(".md")) {
446
+ ids.push(rel.slice(0, -".md".length));
447
+ }
448
+ }
449
+ return ids;
450
+ }
451
+
452
+ // ── Count cross-check (B-0012 H8, V-docusaurus-docs-count) ──
453
+
454
+ /** Parse llms.txt link entries, scoped to the same /docs prose rule as isInScopeDocsUrl. */
455
+ export function parseLlmsTxtInScopeCount(llmsTxt: string): number {
456
+ const links = [...llmsTxt.matchAll(/\[[^\]]+\]\((https?:\/\/[^)]+\.mdx?)\)/g)].map((m) => m[1]);
457
+ return links.filter((u) => isInScopeDocsUrl(u)).length;
458
+ }
459
+
460
+ async function checkCounts(extractedCount: number): Promise<boolean> {
461
+ try {
462
+ const res = await fetch(LLMS_TXT_URL, { signal: AbortSignal.timeout(10_000) });
463
+ // Don't parse an error page as if it were llms.txt — a non-2xx here would yield a
464
+ // bogus expected count (misleading mismatch, or a false match). Route it into the
465
+ // catch below so the cross-check is reported as skipped rather than wrong.
466
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${LLMS_TXT_URL}`);
467
+ const llmsTxt = await res.text();
468
+ const expected = parseLlmsTxtInScopeCount(llmsTxt);
469
+ const ok = expected === extractedCount;
470
+ console.log(`\nCount cross-check (V-docusaurus-docs-count${STRICT ? "" : ", non-blocking"}): llms.txt in-scope=${expected}, extracted=${extractedCount} — ${ok ? "MATCH" : "MISMATCH"}`);
471
+ return ok;
472
+ } catch (e) {
473
+ console.log(`\nCount cross-check skipped (fetch failed): ${e}`);
474
+ // Plain --check-counts (local/manual) stays soft: a network blip shouldn't fail
475
+ // a dev run. But --strict is release.yml's blocking use (V-docusaurus-docs-count) —
476
+ // there, a skipped cross-check must not silently read as a pass.
477
+ return !STRICT;
478
+ }
479
+ }
480
+
481
+ // ── Main ──
482
+
483
+ async function main() {
484
+ console.log("Initializing database...");
485
+ initDb();
486
+
487
+ console.log(FROM_CACHE ? `Discovering pages from cache: ${CACHE_DIR}` : `Discovering pages from sitemap: ${SITEMAP_URL}`);
488
+
489
+ let rosettaIds: string[];
490
+ let urlByRosettaId: Map<string, string>;
491
+
492
+ if (FROM_CACHE) {
493
+ rosettaIds = listCachedRosettaIds(CACHE_DIR);
494
+ urlByRosettaId = new Map(rosettaIds.map((id) => [id, rosettaIdToUrl(id)]));
495
+ } else {
496
+ const sitemapUrls = (await loadSitemapUrls()).filter(isInScopeDocsUrl);
497
+ urlByRosettaId = new Map(sitemapUrls.map((u) => [deriveRosettaId(u), u]));
498
+ rosettaIds = [...urlByRosettaId.keys()];
499
+ }
500
+
501
+ rosettaIds.sort();
502
+ if (LIMIT) rosettaIds = rosettaIds.slice(0, LIMIT);
503
+
504
+ console.log(`Pages in scope: ${rosettaIds.length}`);
505
+
506
+ const parsedPages: ParsedPage[] = [];
507
+ let fetchErrors = 0;
508
+
509
+ for (const rosettaId of rosettaIds) {
510
+ const url = urlByRosettaId.get(rosettaId) ?? rosettaIdToUrl(rosettaId);
511
+ const cacheFile = cachePathFor(rosettaId);
512
+ let md: string;
513
+
514
+ if (FROM_CACHE) {
515
+ md = readFileSync(cacheFile, "utf-8");
516
+ } else {
517
+ try {
518
+ md = await fetchMarkdown(url);
519
+ mkdirSync(dirname(cacheFile), { recursive: true });
520
+ writeFileSync(cacheFile, md);
521
+ await delay(FETCH_DELAY_MS);
522
+ } catch (e) {
523
+ console.log(` ERROR: ${url}: ${e}`);
524
+ fetchErrors++;
525
+ continue;
526
+ }
527
+ }
528
+
529
+ parsedPages.push(parsePage(md, url));
530
+ }
531
+
532
+ console.log(`Fetched/read: ${parsedPages.length}, errors: ${fetchErrors}`);
533
+
534
+ // Guard BEFORE the destructive rebuild below: if every fetch failed (network/sitemap
535
+ // outage) or the cache was empty, bail without wiping an existing, good DB. Deleting
536
+ // first and then discovering there's nothing to insert leaves downstream consumers an
537
+ // empty DB (Copilot/CodeRabbit review, PR #13).
538
+ if (parsedPages.length === 0) {
539
+ console.error(`\n::error::extract-docusaurus: 0 pages extracted. Check sitemap/network/cache-dir.`);
540
+ process.exit(1);
541
+ }
542
+
543
+ // Idempotent rebuild — this extractor owns pages/sections/properties/callouts
544
+ // for the Docusaurus era the same way extract-html.ts owned them for Confluence;
545
+ // the two are not meant to populate the same DB together (MANUAL.md).
546
+ //
547
+ // Unlike extract-html.ts (which preserves stable explicit page ids via INSERT OR
548
+ // REPLACE), this extractor re-mints pages.id as fresh rowids each run, so any existing
549
+ // commands.page_id / schema_nodes.page_id would dangle or point at unrelated new rows
550
+ // after the wipe. NULL those links first so a STANDALONE run stays internally
551
+ // consistent; the pipeline's `link` step (link-commands.ts) and extract-schema
552
+ // re-establish them afterward. Both columns are nullable, so this is safe with FKs on.
553
+ db.run("UPDATE commands SET page_id = NULL;");
554
+ db.run("UPDATE schema_nodes SET page_id = NULL;");
555
+ db.run("DELETE FROM sections;");
556
+ db.run("DELETE FROM callouts;");
557
+ db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
558
+ db.run("DELETE FROM properties;");
559
+ db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
560
+ db.run("PRAGMA foreign_keys = OFF;");
561
+ db.run("DELETE FROM pages;");
562
+ db.run("PRAGMA foreign_keys = ON;");
563
+ db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
564
+
565
+ const insertPage = db.prepare(`
566
+ INSERT INTO pages
567
+ (rosetta_id, slug, title, path, depth, parent_id, url, text, code, code_lang,
568
+ author, last_updated, word_count, code_lines, html_file)
569
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)
570
+ `);
571
+ const insertSection = db.prepare(`
572
+ INSERT INTO sections (page_id, heading, level, anchor_id, text, code, word_count, sort_order)
573
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
574
+ `);
575
+ const insertProperty = db.prepare(`
576
+ INSERT OR IGNORE INTO properties (page_id, name, type, default_val, description, section, sort_order)
577
+ VALUES (?, ?, ?, ?, ?, ?, ?)
578
+ `);
579
+ const insertCallout = db.prepare(`
580
+ INSERT INTO callouts (page_id, type, content, sort_order)
581
+ VALUES (?, ?, ?, ?)
582
+ `);
583
+
584
+ let totalSections = 0;
585
+ let totalProperties = 0;
586
+ let malformedProperties = 0;
587
+ let totalCallouts = 0;
588
+
589
+ const insertAll = db.transaction(() => {
590
+ for (const page of parsedPages) {
591
+ const cacheRelPath = cachePathFor(page.rosettaId).slice(PROJECT_ROOT.length + 1);
592
+ const result = insertPage.run(
593
+ page.rosettaId,
594
+ page.slug,
595
+ page.title,
596
+ page.path,
597
+ page.depth,
598
+ page.url,
599
+ page.text,
600
+ page.code,
601
+ page.codeLang,
602
+ page.wordCount,
603
+ page.codeLines,
604
+ cacheRelPath,
605
+ );
606
+ const pageId = Number(result.lastInsertRowid);
607
+
608
+ for (const s of page.sections) {
609
+ insertSection.run(pageId, s.heading, s.level, s.anchorId, s.text, s.code, s.wordCount, s.sortOrder);
610
+ totalSections++;
611
+ }
612
+ let propOrder = 0;
613
+ for (const p of page.properties) {
614
+ insertProperty.run(pageId, p.name, p.rawType, p.defaultVal, p.description, p.section, propOrder++);
615
+ totalProperties++;
616
+ if (p.malformedEmphasis) malformedProperties++;
617
+ }
618
+ for (const c of page.callouts) {
619
+ insertCallout.run(pageId, c.type, c.content, c.sortOrder);
620
+ totalCallouts++;
621
+ }
622
+ }
623
+ });
624
+ insertAll();
625
+
626
+ console.log(`\nExtraction complete:`);
627
+ console.log(` Pages: ${parsedPages.length}`);
628
+ console.log(` Sections: ${totalSections}`);
629
+ console.log(` Properties: ${totalProperties} (${malformedProperties} malformed-emphasis)`);
630
+ console.log(` Callouts: ${totalCallouts}`);
631
+
632
+ if (CHECK_COUNTS) {
633
+ const ok = await checkCounts(parsedPages.length);
634
+ if (!ok && STRICT) process.exit(1);
635
+ }
636
+ }
637
+
638
+ if (import.meta.main) {
639
+ main().catch((e) => {
640
+ console.error("Fatal:", e);
641
+ process.exit(1);
642
+ });
643
+ }
@@ -1,8 +1,15 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
 
3
5
  process.env.DB_PATH = ":memory:";
4
6
 
5
- const { githubApiHeaders } = await import("./extract-skills.ts");
7
+ const { githubApiHeaders } = await import("./github.ts");
8
+ const ROOT = join(import.meta.dirname, "..");
9
+
10
+ function readText(relPath: string): string {
11
+ return readFileSync(join(ROOT, relPath), "utf-8");
12
+ }
6
13
 
7
14
  describe("extract-skills GitHub API headers", () => {
8
15
  afterEach(() => {
@@ -36,4 +43,36 @@ describe("extract-skills GitHub API headers", () => {
36
43
  Authorization: "Bearer gh-token",
37
44
  });
38
45
  });
39
- });
46
+
47
+ test("supports GitHub raw content API accept header while preserving auth", () => {
48
+ process.env.GITHUB_TOKEN = "raw-token";
49
+
50
+ expect(githubApiHeaders("application/vnd.github.v3.raw")).toEqual({
51
+ Accept: "application/vnd.github.v3.raw",
52
+ Authorization: "Bearer raw-token",
53
+ });
54
+ });
55
+ });
56
+
57
+ describe("extract-skills GitHub fetch shape", () => {
58
+ test("fetches skill files through the authenticated Contents API, not raw.githubusercontent.com", () => {
59
+ const src = readText("src/extract-skills.ts");
60
+
61
+ expect(src).not.toContain("raw.githubusercontent.com");
62
+ expect(src).toContain("/contents/");
63
+ expect(src).toContain('githubApiHeaders("application/vnd.github.v3.raw")');
64
+ expect(src).toContain("fetchGitHub");
65
+ });
66
+
67
+ test("release.yml passes GITHUB_TOKEN to the skills extraction step", () => {
68
+ const yml = readText(".github/workflows/release.yml");
69
+ const skillsIdx = yml.indexOf("Extract agent skills from GitHub");
70
+ const linkIdx = yml.indexOf("Link commands to pages");
71
+ expect(skillsIdx).toBeGreaterThanOrEqual(0);
72
+ expect(linkIdx).toBeGreaterThan(skillsIdx);
73
+
74
+ const block = yml.slice(skillsIdx, linkIdx);
75
+ expect(block).toContain("GITHUB_TOKEN: $" + "{{ github.token }}");
76
+ expect(block).toContain("bun run src/extract-skills.ts");
77
+ });
78
+ });