@dogsbay/format-starlight 0.2.0-beta.13 → 0.2.0-beta.130

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.
package/dist/cli.js CHANGED
@@ -3,11 +3,15 @@ import { join, relative, dirname } from "node:path";
3
3
  import { parseMeta } from "@dogsbay/types";
4
4
  import { starlightToTree as starlightToTreeLegacy, extractFrontmatter } from "./parse.js";
5
5
  import { starlightToTree as starlightToTreeMdx } from "./parse-mdx.js";
6
+ import { jsxPropsToJinja, newStats as newJsxStats } from "./jsx-to-jinja.js";
7
+ import { DirectiveTable, escapeStrayOpeners, restoreDirectives } from "./template-tokens.js";
8
+ import { TemplatePages, dogsbayMdToTree, extractHeadings, treeToDogsbayMd } from "@dogsbay/format-dogsbay-md/browser";
6
9
  import { cloudflareAdapter } from "./adapters/cloudflare.js";
7
10
  import { buildNavFromDirectory, buildNavFromConfig } from "./nav.js";
8
- const ADAPTERS = {
11
+ export const STARLIGHT_ADAPTERS = {
9
12
  cloudflare: cloudflareAdapter,
10
13
  };
14
+ const ADAPTERS = STARLIGHT_ADAPTERS;
11
15
  /**
12
16
  * Detect if a directory contains a Starlight docs source.
13
17
  *
@@ -136,22 +140,347 @@ async function importStarlight(source, opts) {
136
140
  if (opts.partialsDir) {
137
141
  partialsDir = opts.partialsDir;
138
142
  }
143
+ // Where the images live. Found the same way as partialsDir, because it is
144
+ // the same Astro layout: `src/content/docs` and `src/assets` are siblings
145
+ // under `src/`.
146
+ //
147
+ // Without this the conversion emitted `/_assets/images/x.png` refs and
148
+ // nothing ever copied a file there — every local image on a converted site
149
+ // was a 404. The reference survived and the file did not, which is why a
150
+ // page count, a retention score and a clean exit all looked fine.
151
+ let assetsDir;
152
+ searchDir = docsDir;
153
+ for (let depth = 0; depth < 10; depth++) {
154
+ const candidate = join(searchDir, "src", "assets");
155
+ if (existsSync(candidate)) {
156
+ assetsDir = candidate;
157
+ break;
158
+ }
159
+ const sibling = join(dirname(searchDir), "assets");
160
+ if (existsSync(sibling)) {
161
+ assetsDir = sibling;
162
+ break;
163
+ }
164
+ const parent = dirname(searchDir);
165
+ if (parent === searchDir)
166
+ break;
167
+ searchDir = parent;
168
+ }
169
+ // Product/brand icons, mounted under `_assets/icons` to match the refs the
170
+ // adapters emit for them.
171
+ let iconsDir;
172
+ searchDir = docsDir;
173
+ for (let depth = 0; depth < 10; depth++) {
174
+ const candidate = join(searchDir, "src", "icons");
175
+ if (existsSync(candidate)) {
176
+ iconsDir = candidate;
177
+ break;
178
+ }
179
+ const parent = dirname(searchDir);
180
+ if (parent === searchDir)
181
+ break;
182
+ searchDir = parent;
183
+ }
184
+ // Component sources, for recovering an accessible description from a
185
+ // component we cannot render. Same sibling layout as assets and partials.
186
+ let componentsDir;
187
+ searchDir = docsDir;
188
+ for (let depth = 0; depth < 10; depth++) {
189
+ const candidate = join(searchDir, "src", "components");
190
+ if (existsSync(candidate)) {
191
+ componentsDir = candidate;
192
+ break;
193
+ }
194
+ const parent = dirname(searchDir);
195
+ if (parent === searchDir)
196
+ break;
197
+ searchDir = parent;
198
+ }
199
+ /**
200
+ * The `aria-label` (or `<figcaption>`) a component declares about itself.
201
+ *
202
+ * Cloudflare's diagrams are Astro components — inline SVG, a web component,
203
+ * CSS animation — with no image file to carry across. But their `aria-label`
204
+ * is a full description written by their docs team, and it is the
205
+ * information the diagram exists to convey. Recovering it turns a silent
206
+ * hole into a described placeholder; nothing else about the component can
207
+ * survive a conversion to markdown.
208
+ */
209
+ /** `tag\u0000how` → count of conversions to a lesser form. */
210
+ const degraded = new Map();
211
+ const DESCRIBED = "its description only: the diagram is drawn by the component";
212
+ /** Tag → component file, from the site's `src/components.ts` barrel (what `~/components` imports resolve to). */
213
+ let barrel;
214
+ let byName;
215
+ /**
216
+ * Where the page being parsed imports each component from: a path under
217
+ * `~/components/` (that file), `null` for the barrel (`~/components`),
218
+ * or `false` for anywhere else (a package, a relative file) — then no
219
+ * description is claimed: a same-named file under components/ would be the
220
+ * WRONG component, and its label would hide the loss as a success.
221
+ */
222
+ let pageImports = new Map();
223
+ const importsOf = (src) => {
224
+ const map = new Map();
225
+ for (const m of src.matchAll(/^import\s+(?:(\w+)|\{([^}]*)\})\s+from\s+["']([^"']+)["']/gm)) {
226
+ const from = m[3];
227
+ const where = from === "~/components" ? null : from.startsWith("~/components/") && from.endsWith(".astro") ? from.slice("~/components/".length) : false;
228
+ if (m[1])
229
+ map.set(m[1], where);
230
+ for (const n of (m[2] ?? "").split(",")) {
231
+ const name = n.trim().split(/\s+as\s+/).pop();
232
+ if (name)
233
+ map.set(name, where === false ? false : null);
234
+ }
235
+ }
236
+ return map;
237
+ };
238
+ const componentFile = (tag) => {
239
+ if (!componentsDir)
240
+ return undefined;
241
+ const imported = pageImports.get(tag);
242
+ if (imported === false)
243
+ return undefined;
244
+ if (typeof imported === "string") {
245
+ const f = join(componentsDir, imported);
246
+ return existsSync(f) ? f : undefined;
247
+ }
248
+ if (!barrel) {
249
+ barrel = new Map();
250
+ try {
251
+ const src = readFileSync(join(dirname(componentsDir), "components.ts"), "utf-8");
252
+ for (const m of src.matchAll(/export\s*\{\s*default\s+as\s+(\w+)\s*\}\s*from\s*["']\.\/components\/([^"']+\.astro)["']/g)) {
253
+ barrel.set(m[1], join(componentsDir, m[2]));
254
+ }
255
+ }
256
+ catch {
257
+ // No barrel: the directory guesses below.
258
+ }
259
+ }
260
+ const candidates = [barrel.get(tag), join(componentsDir, `${tag}.astro`), join(componentsDir, "cf", `${tag}.astro`)];
261
+ const found = candidates.find((f) => !!f && existsSync(f));
262
+ if (found)
263
+ return found;
264
+ // Imported from the barrel but not in it: nothing to guess.
265
+ if (imported === null)
266
+ return undefined;
267
+ // Pages also import components by PATH (`~/components/realtime/sfu/X.astro`),
268
+ // outside the barrel: a name unique under components/ is that file.
269
+ if (!byName) {
270
+ byName = new Map();
271
+ const seen = new Set();
272
+ const walk = (dir) => {
273
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
274
+ const p = join(dir, e.name);
275
+ if (e.isDirectory())
276
+ walk(p);
277
+ else if (e.name.endsWith(".astro")) {
278
+ const n = e.name.slice(0, -6);
279
+ if (seen.has(n))
280
+ byName.delete(n);
281
+ else
282
+ byName.set(n, p);
283
+ seen.add(n);
284
+ }
285
+ }
286
+ };
287
+ try {
288
+ walk(componentsDir);
289
+ }
290
+ catch {
291
+ // An unreadable folder: fewer components described.
292
+ }
293
+ }
294
+ return byName.get(tag);
295
+ };
296
+ const describeComponent = (tag) => {
297
+ const file = componentFile(tag);
298
+ if (!file)
299
+ return undefined;
300
+ const raw = readFileSync(file, "utf-8");
301
+ // The template only: a string in the frontmatter script is not a label.
302
+ const fmEnd = raw.startsWith("---") ? raw.indexOf("\n---", 3) : -1;
303
+ const source = raw.slice(fmEnd < 0 ? 0 : fmEnd + 4);
304
+ // 20+ characters: a sub-part label ("Tools") must not stand in for the
305
+ // whole diagram.
306
+ const aria = source.match(/aria-label="([^"{}]{20,})"/);
307
+ // A caption may hold markup (`<strong>`, links): its TEXT is the description.
308
+ const caption = aria ? undefined : source.match(/<figcaption[^>]*>([\s\S]*?)<\/figcaption>/);
309
+ // `{" "}` and other string literals are text; a dynamic `{…}` is refused
310
+ // (it would leave "Figure : …"); common entities decoded.
311
+ const captionSrc = caption?.[1].replace(/\{\s*(["'])([^"']*)\1\s*\}/g, "$2");
312
+ const captionText = captionSrc && !/[{}]/.test(captionSrc)
313
+ ? captionSrc
314
+ .replace(/<[^>]+>/g, "")
315
+ .replace(/&(mdash|ndash|rsquo|lsquo|rdquo|ldquo|amp|nbsp|hellip|rarr);/g, (_m, e) => ({ mdash: "—", ndash: "–", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", amp: "&", nbsp: " ", hellip: "…", rarr: "→" })[e])
316
+ : undefined;
317
+ const text = (aria?.[1] ?? (captionText && captionText.replace(/\s+/g, " ").trim().length >= 20 ? captionText : undefined))?.replace(/\s+/g, " ").trim();
318
+ // A placeholder is a loss: reported, not silent (9 uses were).
319
+ if (text)
320
+ degraded.set(`${tag}\u0000${DESCRIBED}`, (degraded.get(`${tag}\u0000${DESCRIBED}`) ?? 0) + 1);
321
+ return text;
322
+ };
139
323
  // Parse all pages
140
324
  const mdxFiles = walkMdxFiles(docsDir, docsDir);
141
325
  const pages = [];
326
+ /** Components no builtin or adapter mapping claimed. */
327
+ const dropped = new Map();
328
+ const record = (tag, slug) => {
329
+ const entry = dropped.get(tag) ?? { count: 0, pages: new Set() };
330
+ entry.count++;
331
+ entry.pages.add(slug);
332
+ dropped.set(tag, entry);
333
+ };
142
334
  // Custom taxonomy names from dogsbay.config.yml's `taxonomies:` block
143
335
  // (threaded through cli's import-content.ts).
144
336
  const taxonomyNames = Array.isArray(opts.taxonomyNames)
145
337
  ? opts.taxonomyNames
146
338
  : undefined;
339
+ // Template mode (`--partial-mode template`, plans/page-templates.md):
340
+ // `<Render>` becomes `{% with %}{% include %}` and each referenced partial
341
+ // becomes a TEMPLATE partial. Only a Dogsbay-MD output can carry that
342
+ // (convert refuses other targets); `inline` is the default everywhere.
343
+ const templateMode = opts.partialMode === "template" && Boolean(partialsDir) && adapter?.resolveIncludes !== undefined;
344
+ // INLINE mode (the default) for Cloudflare is template mode PLUS rendering:
345
+ // the same conversion, then every page's includes are rendered in memory
346
+ // with the shared TemplatePages and the result parsed back. Inline output
347
+ // therefore equals the template-mode build by construction. (A resolver
348
+ // that spliced partial MDX into the page left unevaluated conditionals,
349
+ // stray code blocks and empty tabs.)
350
+ const inlineMode = !templateMode && opts.partialMode !== "include" && Boolean(partialsDir) && adapter?.name === "cloudflare";
351
+ const converting = templateMode || inlineMode;
352
+ // Inline mode (the default) renders each partial in place on the SAME
353
+ // converter template mode uses (inline-partials.ts).
354
+ const usedPartials = [];
355
+ /** Parameter names callers pass to each partial (`product/file`). */
356
+ const passedParams = new Map();
357
+ const restoreStats = { escaped: 0, inCode: 0 };
358
+ const inlined = new Map();
359
+ let templateIncludes = 0;
360
+ const apiRequests = { converted: 0, left: 0, missing: new Set() };
361
+ const apiSchema = loadApiSchema(opts.apiSchema);
362
+ // The schema is used by the converting modes only (inline, template):
363
+ // parsed and then silently ignored was the failure mode.
364
+ if (apiSchema && !converting) {
365
+ console.warn(`warning: --api-schema is used with the cloudflare adapter and a partials directory, in inline or template partial mode; it was ignored here.`);
366
+ }
367
+ const includeOptionsFor = (product, directives, onUse, names, pageDir) => converting
368
+ ? {
369
+ mode: "template",
370
+ pageDir,
371
+ defaultProduct: product,
372
+ directives,
373
+ names,
374
+ onApiRequest: (converted) => (converted ? apiRequests.converted++ : apiRequests.left++),
375
+ apiSchema,
376
+ onApiMissing: (key) => apiRequests.missing.add(key),
377
+ onDegraded: (tag, how) => {
378
+ const key = `${tag}\u0000${how}`;
379
+ degraded.set(key, (degraded.get(key) ?? 0) + 1);
380
+ },
381
+ onTemplateInclude: ({ product: p, file, params }) => {
382
+ templateIncludes++;
383
+ const key = `${p}/${file}`;
384
+ usedPartials.push(key);
385
+ const passed = passedParams.get(key) ?? new Set();
386
+ for (const n of params)
387
+ passed.add(n);
388
+ passedParams.set(key, passed);
389
+ onUse?.();
390
+ },
391
+ onInlined: ({ reason }) => inlined.set(reason, (inlined.get(reason) ?? 0) + 1),
392
+ }
393
+ : undefined;
394
+ // Include mode splices partial TEXT into the page at parse time, so the
395
+ // escapes the page's partials wrote count for it too — only the partials
396
+ // it names (every partial's escapes would excuse leaks on unrelated pages).
397
+ // Converting modes scan each partial on its own, below.
398
+ const partialCache = new Map();
399
+ // A `<ProductChangelog>` page splices changelog ENTRIES into itself: their
400
+ // escapes count too (`Array\<Number>` in an entry was reported as a leak).
401
+ let changelogCache;
402
+ const changelogEscapes = () => {
403
+ if (changelogCache)
404
+ return changelogCache;
405
+ changelogCache = new Map();
406
+ const dir = partialsDir ? join(dirname(partialsDir), "changelog") : undefined;
407
+ const walkDir = (d) => {
408
+ for (const e of readdirSync(d, { withFileTypes: true })) {
409
+ const p = join(d, e.name);
410
+ if (e.isDirectory())
411
+ walkDir(p);
412
+ else if (/\.mdx?$/.test(e.name))
413
+ for (const n of escapedIn(readFileSync(p, "utf-8")).keys())
414
+ changelogCache.set(n, Infinity);
415
+ }
416
+ };
417
+ try {
418
+ if (dir && existsSync(dir))
419
+ walkDir(dir);
420
+ }
421
+ catch {
422
+ // Unreadable: fewer escapes excused, never fewer leaks reported.
423
+ }
424
+ return changelogCache;
425
+ };
426
+ const includedEscapes = (source, product, depth = 0) => {
427
+ const names = new Map();
428
+ if (converting || !partialsDir || depth > 8)
429
+ return names;
430
+ for (const m of source.matchAll(/<Render\b[^>]*?\bfile=["']([^"']+)["'][^>]*>/g)) {
431
+ const prod = /\bproduct=["']([^"']+)["']/.exec(m[0])?.[1] ?? product;
432
+ const path = join(partialsDir, prod, `${m[1]}.mdx`);
433
+ let found = partialCache.get(path);
434
+ if (!found) {
435
+ let text = "";
436
+ try {
437
+ text = readFileSync(path, "utf-8");
438
+ }
439
+ catch {
440
+ // A missing partial is reported by the include path, not here.
441
+ }
442
+ found = escapedIn(text, includedEscapes(text, prod, depth + 1));
443
+ partialCache.set(path, found);
444
+ }
445
+ // A partial can be included more than once: its escapes are not counted.
446
+ for (const n of found.keys())
447
+ names.set(n, Infinity);
448
+ }
449
+ return names;
450
+ };
147
451
  for (const { filePath, slug } of mdxFiles) {
148
452
  const content = readFileSync(filePath, "utf-8");
453
+ let usesTemplates = false;
454
+ const directives = new DirectiveTable();
149
455
  const { frontmatter, raw, body } = extractFrontmatter(content);
456
+ pageImports = importsOf(body);
150
457
  // Use new MDX parser (markdown-it-mdx-jsx plugin) by default
151
458
  // Fall back to legacy regex parser with --legacy-parser flag
152
459
  const useLegacy = opts.legacyParser === true;
153
460
  const starlightToTree = useLegacy ? starlightToTreeLegacy : starlightToTreeMdx;
154
- const tree = starlightToTree(body, { adapter, partialsDir });
461
+ const tree = starlightToTree(body, {
462
+ adapter,
463
+ partialsDir,
464
+ frontmatter: raw,
465
+ onDrop: (tag) => record(tag, slug),
466
+ describeComponent,
467
+ // A <Render> with no product= means the page's own product: its first path segment.
468
+ includeOptions: includeOptionsFor(slug.split("/")[0], directives, () => (usesTemplates = true), undefined, dirname(filePath)),
469
+ });
470
+ if (usesTemplates)
471
+ restoreDirectives(tree, directives, restoreStats);
472
+ // Catch what the callback cannot: a component nested inside a MAPPED
473
+ // container is not dropped, it LEAKS — its opening tag lands in the page
474
+ // as visible text and its body often becomes a code block, because nothing
475
+ // claimed the block. `<LinkTitleCard>` inside `<CardGrid>` did exactly
476
+ // that on Cloudflare's AI Search overview while the drop warning stayed
477
+ // silent.
478
+ //
479
+ // Scanning the finished tree catches every route into the output, present
480
+ // and future, in one place. `code` nodes are skipped: a code sample
481
+ // containing `<Response>` is content, not a leak.
482
+ for (const tag of leakedComponentTags(tree, escapedIn(body, new Map([...includedEscapes(body, slug.split("/")[0]), ...(body.includes("<ProductChangelog") ? changelogEscapes() : [])]))))
483
+ record(tag, slug);
155
484
  // Extract headings from tree
156
485
  const headings = tree
157
486
  .filter((n) => n.type === "heading")
@@ -196,7 +525,9 @@ async function importStarlight(source, opts) {
196
525
  ],
197
526
  });
198
527
  }
199
- const title = frontmatter.title || slug;
528
+ // A template page's title is rendered too: a literal `{{` in it would be syntax.
529
+ // (Template mode only: in inline mode the page is rendered here and its title never is.)
530
+ const title = usesTemplates && templateMode ? escapeStrayOpeners(frontmatter.title || slug) : frontmatter.title || slug;
200
531
  const meta = parseMeta(raw, { slug, taxonomyNames });
201
532
  pages.push({
202
533
  slug,
@@ -206,8 +537,164 @@ async function importStarlight(source, opts) {
206
537
  redirect,
207
538
  frontmatter: raw,
208
539
  meta,
540
+ ...(usesTemplates ? { template: true } : {}),
209
541
  });
210
542
  }
543
+ if (converting) {
544
+ const stats = newJsxStats();
545
+ const converted = new Set();
546
+ let undeclaredPassed = 0;
547
+ const partialPages = [];
548
+ for (let i = 0; i < usedPartials.length; i++) {
549
+ const key = usedPartials[i];
550
+ if (converted.has(key))
551
+ continue;
552
+ converted.add(key);
553
+ const [product, ...rest] = key.split("/");
554
+ const file = rest.join("/");
555
+ const source = readFileSync(join(partialsDir, product, `${file}.mdx`), "utf-8");
556
+ const { raw, body } = extractFrontmatter(source);
557
+ pageImports = importsOf(body);
558
+ const names = new Set();
559
+ const directives = new DirectiveTable();
560
+ const partialStats = { ...newJsxStats(), names, table: directives };
561
+ const withoutImports = body.replace(/^import\s+\{[\s\S]*?\}\s+from\s+["'][^"']*["']\s*;?\s*$/gm, "");
562
+ const jinja = jsxPropsToJinja(withoutImports, partialStats);
563
+ stats.converted += partialStats.converted;
564
+ stats.refused += partialStats.refused;
565
+ for (const s of partialStats.refusedSamples)
566
+ if (stats.refusedSamples.length < 20)
567
+ stats.refusedSamples.push(`${key}: ${s}`);
568
+ const tree = starlightToTreeMdx(jinja, {
569
+ adapter,
570
+ partialsDir,
571
+ onDrop: (tag) => record(tag, `_partials/${key}`),
572
+ describeComponent,
573
+ // Every partial a nested <Render> names is queued for conversion too.
574
+ // A nested <Render> with no product= is reported, not guessed: in
575
+ // Cloudflare it takes the product of the PAGE being built.
576
+ includeOptions: includeOptionsFor(undefined, directives, undefined, names),
577
+ });
578
+ restoreDirectives(tree, directives, restoreStats);
579
+ // Leaks inside a partial count too (they reach every page including it).
580
+ for (const tag of leakedComponentTags(tree, escapedIn(source)))
581
+ record(tag, `_partials/${key}`);
582
+ stats.markdownInlined += partialStats.markdownInlined;
583
+ // Cloudflare declares `params: [a, b?]` on most partials — the same
584
+ // syntax template partials use. One that reads props without declaring
585
+ // them gets every name it reads, as optional (Cloudflare does not
586
+ // validate those either).
587
+ const declared = Array.isArray(raw.params) && raw.params.every((p) => typeof p === "string") ? raw.params : undefined;
588
+ // Declared parameters, plus every `props` name the converted body reads
589
+ // or forwards, as optional: an undeclared read would otherwise be a
590
+ // strict-undefined error on every page that does not pass it.
591
+ const declaredNames = new Set((declared ?? []).map((p) => p.replace(/\?$/, "")));
592
+ const readOnly = [...names].filter((n) => !declaredNames.has(n)).sort().map((n) => `${n}?`);
593
+ const baseParams = declared || readOnly.length ? [...(declared ?? []), ...readOnly] : undefined;
594
+ // Each partial is its own MDX document upstream, so each numbers its
595
+ // own footnotes from 1; spliced into one page they collided and the
596
+ // first `[^1]:` won (warp-client/get-started lost its RHEL note).
597
+ relabelFootnotes(tree, (label) => `${key.split("/").pop().replace(/[^A-Za-z0-9_-]/g, "")}-${label}`);
598
+ const page = { slug: `_partials/${key}`, title: "", tree, headings: [], fragment: true, template: true };
599
+ partialPages.push({ key, page, baseParams });
600
+ pages.push(page);
601
+ }
602
+ // Parameters are settled only once every caller is known: a partial may
603
+ // be converted before another partial, later in the queue, passes it a
604
+ // name. Names callers pass that a partial does not declare were
605
+ // tolerated by Cloudflare's build and would be refused by a template
606
+ // partial, so they are declared as optional — and counted.
607
+ for (const { key, page, baseParams } of partialPages) {
608
+ let params = baseParams;
609
+ if (params) {
610
+ const known = new Set(params.map((p) => p.replace(/\?$/, "")));
611
+ const extra = [...(passedParams.get(key) ?? [])].filter((n) => !known.has(n)).sort();
612
+ if (extra.length) {
613
+ params = [...params, ...extra.map((n) => `${n}?`)];
614
+ undeclaredPassed += extra.length;
615
+ }
616
+ }
617
+ if (params)
618
+ page.templateParams = params;
619
+ }
620
+ // Inline mode: render each page's includes now, parse the result back,
621
+ // and drop the partials (they are spliced into the pages).
622
+ let rendered = 0;
623
+ const renderFailures = [];
624
+ if (inlineMode) {
625
+ const fragments = new Map();
626
+ for (const { page } of partialPages) {
627
+ const body = treeToDogsbayMd(page.tree, page.templateParams ? { frontmatter: { params: page.templateParams } } : undefined);
628
+ // Same hazard as the page reparse below: a partial opening with `---`.
629
+ fragments.set(`${page.slug}.md`, page.templateParams ? body : `---\n---\n${body}`);
630
+ }
631
+ const templates = new TemplatePages({ load: (p) => fragments.get(p), sourceDefault: true });
632
+ const partialSet = new Set(partialPages.map((p) => p.page));
633
+ for (let k = pages.length - 1; k >= 0; k--)
634
+ if (partialSet.has(pages[k]))
635
+ pages.splice(k, 1);
636
+ for (const page of pages) {
637
+ if (!page.template)
638
+ continue;
639
+ const path = `${page.slug || "index"}.md`;
640
+ try {
641
+ const md = await templates.render(treeToDogsbayMd(page.tree), path, page.frontmatter ?? {});
642
+ // An empty front matter block first: a page whose body opens with
643
+ // a thematic break (`---`) was otherwise read as front matter, and
644
+ // everything up to the next rule vanished (waiting-room/get-started).
645
+ page.tree = dogsbayMdToTree(`---\n---\n${md}`).tree;
646
+ // Partial labels (`linux-1`) back to 1..n, in order of first reference.
647
+ const order = new Map();
648
+ collectFootnoteRefs(page.tree, (label) => order.has(label) || order.set(label, String(order.size + 1)));
649
+ if (order.size)
650
+ relabelFootnotes(page.tree, (label) => order.get(label) ?? label);
651
+ page.headings = extractHeadings(page.tree).map((h) => ({ depth: h.depth, text: h.text, slug: h.slug }));
652
+ delete page.template;
653
+ rendered++;
654
+ }
655
+ catch (err) {
656
+ renderFailures.push(`${path}: ${err.message.slice(0, 160)}`);
657
+ }
658
+ }
659
+ // A page that did not render keeps directive nodes no exporter but
660
+ // dogsbay-md emits, and its partials were removed above: its partial
661
+ // content is simply gone. That must not exit 0 (the rule for includes
662
+ // everywhere, plans/page-templates.md).
663
+ if (renderFailures.length && opts.allowMissingIncludes !== true) {
664
+ throw new Error(`${renderFailures.length} page(s) could not render their partials:\n` +
665
+ renderFailures.slice(0, 20).map((f) => ` ${f}`).join("\n") +
666
+ "\nRe-run with --allow-missing-includes to write them without that content.");
667
+ }
668
+ }
669
+ const inlinedTotal = [...inlined.values()].reduce((a, b) => a + b, 0);
670
+ const lines = [
671
+ inlineMode
672
+ ? `partials: ${templateIncludes} <Render> converted and rendered in place on ${rendered} page(s), ${converted.size} partial(s)`
673
+ : `template partials: ${templateIncludes} <Render> → {% include %}, ${converted.size} partial(s) converted`,
674
+ ...(renderFailures.length ? [` ${renderFailures.length} page(s) could not be rendered; their partial content is MISSING (--allow-missing-includes):`, ...renderFailures.slice(0, 8).map((f) => ` ${f}`)] : []),
675
+ ` props expressions: ${stats.converted} converted, ${stats.refused} left as written; ${stats.markdownInlined} <Markdown> wrapper(s) replaced by their text`,
676
+ ` ${undeclaredPassed} parameter(s) callers pass but partials did not declare, added as optional`,
677
+ ...(inlineMode ? [] : [` ${restoreStats.escaped} stray template opener(s) in template pages escaped to print literally`]),
678
+ ...(apiRequests.converted || apiRequests.left
679
+ ? [
680
+ ` ${apiRequests.converted} <APIRequest> converted to curl ` +
681
+ (apiSchema
682
+ ? `with titles, auth and permission panels from ${String(opts.apiSchema)} (${apiRequests.missing.size} operation(s) not in it${apiRequests.missing.size ? `: ${[...apiRequests.missing].slice(0, 3).join(", ")}${apiRequests.missing.size > 3 ? ", …" : ""}` : ""}); \`code\` line highlights are not carried`
683
+ : "(without the token-permission panel, title and `code` line highlights; the first two come from the OpenAPI schema — pass a local copy with --api-schema)") +
684
+ (apiRequests.left ? `; ${apiRequests.left} left as written (props not readable)` : ""),
685
+ ]
686
+ : []),
687
+ ...[...degraded].map(([key, n]) => {
688
+ const [tag, how] = key.split("\u0000");
689
+ return ` ${n} <${tag}> converted to a lesser form (${how})`;
690
+ }),
691
+ ...(restoreStats.fencesMarked ? [` ${restoreStats.fencesMarked} code block(s) take a parameter and are marked {minja} so it renders`] : []),
692
+ ...(restoreStats.inCode ? [` ${restoreStats.inCode} directive(s) landed inside code and will show literally — check those pages`] : []),
693
+ ...(inlinedTotal ? [` ${inlinedTotal} <Render> left as tags or inlined the old way (reported as unmapped):`, ...[...inlined].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([r, n]) => ` ×${n} ${r}`)] : []),
694
+ ...(stats.refusedSamples.length ? [" expressions left as written (first few):", ...stats.refusedSamples.slice(0, 8).map((s) => ` ${s}`)] : []),
695
+ ];
696
+ console.warn(lines.join("\n"));
697
+ }
211
698
  // Set site name from opts or infer
212
699
  if (!opts.siteName && pages.length > 0) {
213
700
  const indexPage = pages.find((p) => p.slug === "index" || p.slug === "");
@@ -215,16 +702,194 @@ async function importStarlight(source, opts) {
215
702
  opts.siteName = indexPage.title;
216
703
  }
217
704
  }
218
- return { pages, nav };
705
+ // Say what was dropped, loudly and countably.
706
+ //
707
+ // A component nothing claimed is CONTENT THAT IS NOT ON THE PAGE — the
708
+ // Cloudflare AI Search overview lost its architecture diagram this way, and
709
+ // the only way anyone found out was comparing the converted page with the
710
+ // live one by eye. There is no ledger on this path, so the warning IS the
711
+ // report: it names each tag, how many times it appeared and how many pages
712
+ // it touched, so `convert … 2>&1 | grep "dropped"` answers "how much of this
713
+ // corpus does this affect" without reading any output files.
714
+ if (dropped.size > 0) {
715
+ const rows = [...dropped.entries()]
716
+ .sort((a, b) => b[1].count - a[1].count)
717
+ .map(([tag, d]) => ` <${tag}> ×${d.count} on ${d.pages.size} page(s): ${[...d.pages].slice(0, 3).join(", ")}${d.pages.size > 3 ? ", …" : ""}`);
718
+ const total = [...dropped.values()].reduce((n, d) => n + d.count, 0);
719
+ console.warn(`warning: ${total} unmapped component(s) — dropped, or leaked into the ` +
720
+ `page as literal text:\n${rows.join("\n")}\n` +
721
+ ` Map them with an adapter, or accept the loss knowingly.`);
722
+ }
723
+ return {
724
+ pages,
725
+ nav,
726
+ // Contents land in `public/_assets/`, matching the `/_assets/…` refs the
727
+ // parser now emits for the `~/assets/` alias.
728
+ ...(assetsDir || iconsDir
729
+ ? {
730
+ assetMounts: [
731
+ ...(assetsDir ? [{ dir: assetsDir }] : []),
732
+ ...(iconsDir ? [{ dir: iconsDir, into: "icons" }] : []),
733
+ ],
734
+ }
735
+ : {}),
736
+ };
737
+ }
738
+ /**
739
+ * Component tags left as literal text/HTML in a finished tree.
740
+ *
741
+ * A CamelCase tag only — `<ACCOUNT_ID>` and `<API_TOKEN>` are placeholders the
742
+ * docs write on purpose, and flagging those would bury the real signal.
743
+ */
744
+ /** Rename every footnote label in a tree (references and definitions). */
745
+ function relabelFootnotes(nodes, rename) {
746
+ const walk = (value) => {
747
+ if (Array.isArray(value))
748
+ return value.forEach(walk);
749
+ if (!value || typeof value !== "object")
750
+ return;
751
+ const node = value;
752
+ if (node.type === "footnote-ref" && typeof node.label === "string")
753
+ node.label = rename(node.label);
754
+ if (node.type === "footnote-def" && node.props && typeof node.props === "object") {
755
+ const props = node.props;
756
+ if (typeof props.label === "string") {
757
+ const label = rename(props.label);
758
+ Object.assign(props, { label, id: `fn-${label}`, backrefId: `fnref-${label}` });
759
+ }
760
+ }
761
+ for (const [k, v] of Object.entries(node))
762
+ if (k !== "props" && typeof v === "object")
763
+ walk(v);
764
+ };
765
+ walk(nodes);
766
+ }
767
+ function collectFootnoteRefs(nodes, visit) {
768
+ const walk = (value) => {
769
+ if (Array.isArray(value))
770
+ return value.forEach(walk);
771
+ if (!value || typeof value !== "object")
772
+ return;
773
+ const node = value;
774
+ if (node.type === "footnote-ref" && typeof node.label === "string")
775
+ visit(node.label);
776
+ for (const [k, v] of Object.entries(node))
777
+ if (k !== "props" && typeof v === "object")
778
+ walk(v);
779
+ };
780
+ walk(nodes);
781
+ }
782
+ /**
783
+ * Names the source writes ESCAPED (`Promise\<ArrayBuffer>`), with how many
784
+ * times: that is prose about a type, rendered as text, and parsed text no
785
+ * longer shows the backslash. Reporting them was ~40 false leaks
786
+ * (`<ArrayBuffer>`, `<Response>`, `<CryptoKey>` …) on the Cloudflare corpus.
787
+ * Each escape excuses ONE text occurrence, so a real leak of the same name
788
+ * on the page is still reported.
789
+ */
790
+ function escapedIn(source, also) {
791
+ const names = new Map(also);
792
+ // `\<Name` and `&lt;Name` (an entity, decoded to text by the parser: `<code>Promise&lt;WorkflowInstance&gt;</code>`).
793
+ for (const m of source.matchAll(/(?:\\<|&lt;)\/?([A-Z][A-Za-z0-9]*)/g))
794
+ names.set(m[1], (names.get(m[1]) ?? 0) + 1);
795
+ return names;
796
+ }
797
+ function leakedComponentTags(nodes, escapes = new Map()) {
798
+ const found = [];
799
+ const excused = new Map(escapes);
800
+ const scan = (list) => {
801
+ for (const node of list) {
802
+ if (node.type === "code" || node.type === "diagram")
803
+ continue;
804
+ const inline = (node.inline ?? []).filter((i) => i.type !== "code");
805
+ // Inline text is JOINED: a multi-line tag arrives as `<APIRequest`, then
806
+ // a separate newline node, and no single piece matched. Inline raw HTML
807
+ // is joined too, with text standing in as spaces, and scanned as HTML.
808
+ const text = inline.map((i) => (i.type === "html-inline" ? " " : (i.text ?? " "))).join("");
809
+ const html = inline.map((i) => (i.type === "html-inline" ? (i.html ?? "") : " ")).join("");
810
+ for (const [source, isText] of [[node.html, false], [html, false], [text, true]]) {
811
+ if (typeof source !== "string")
812
+ continue;
813
+ // Inline code is skipped above: `Promise<Response>` is a type, not a
814
+ // leaked component (it was 60 false reports).
815
+ // `</?` — a leak is often only the CLOSING tag, when the opener was
816
+ // consumed by the container above it but the closer had nothing to
817
+ // pair with. Matching openers alone missed exactly that case.
818
+ // A lowercase letter ANYWHERE, not straight after the capital: that
819
+ // skipped `<APIRequest>` and every other name opening with an acronym,
820
+ // while the point was only to skip ALL-CAPS placeholders (`<ACCOUNT_ID>`).
821
+ for (const m of source.matchAll(/<\/?([A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*)[\s/>]/g)) {
822
+ // Only TEXT is excused by an escape: in raw HTML `<String>` is a tag
823
+ // (a backticked `Map<Array<String>>` in a `<td>` is not code there).
824
+ const left = excused.get(m[1]) ?? 0;
825
+ if (isText && left > 0) {
826
+ excused.set(m[1], left - 1);
827
+ continue;
828
+ }
829
+ found.push(m[1]);
830
+ }
831
+ }
832
+ if (node.children)
833
+ scan(node.children);
834
+ }
835
+ };
836
+ scan(nodes);
837
+ return found;
838
+ }
839
+ /**
840
+ * `--api-schema <openapi.json>`: the operations `<APIRequest>` looks up, by
841
+ * `METHOD /path`. Read as JSON — never fetched, never executed; only the
842
+ * operation fields the component reads are kept (the file is ~11 MB).
843
+ */
844
+ function loadApiSchema(file) {
845
+ if (typeof file !== "string" || !file)
846
+ return undefined;
847
+ let doc;
848
+ try {
849
+ doc = JSON.parse(readFileSync(file, "utf-8"));
850
+ }
851
+ catch (e) {
852
+ throw new Error(`--api-schema ${file}: not readable as JSON (${e.message})`);
853
+ }
854
+ const ops = new Map();
855
+ for (const [path, item] of Object.entries(doc.paths ?? {})) {
856
+ for (const [method, raw] of Object.entries(item ?? {})) {
857
+ if (!["get", "put", "post", "delete", "patch", "head", "options"].includes(method) || !raw || typeof raw !== "object")
858
+ continue;
859
+ const o = raw;
860
+ const security = Array.isArray(o.security) ? o.security.flatMap((r) => (r && typeof r === "object" ? Object.keys(r) : [])) : undefined;
861
+ const groups = Array.isArray(o["x-api-token-group"]) ? o["x-api-token-group"].filter((g) => typeof g === "string") : undefined;
862
+ ops.set(`${method.toUpperCase()} ${path}`, {
863
+ ...(typeof o.summary === "string" ? { summary: o.summary } : {}),
864
+ ...(security ? { security } : {}),
865
+ ...(groups ? { tokenGroups: groups } : {}),
866
+ });
867
+ }
868
+ }
869
+ return ops;
219
870
  }
220
871
  export const plugin = {
221
872
  name: "starlight",
222
873
  canImport: true,
223
874
  canExport: false,
224
875
  detectSource: detectStarlightSource,
225
- exportOptions: [
226
- { flags: "--adapter <name>", description: "Site adapter for custom components (e.g. cloudflare)" },
876
+ // importOptions, not exportOptions: this plugin declares `canExport: false`,
877
+ // so an export-side declaration described a flag on a command that does not
878
+ // exist — and `convert`'s "unusable flag" warning reads the IMPORT list, so
879
+ // `--adapter cloudflare` was reported as ignored while being consumed. That
880
+ // warning is load-bearing on this corpus: the adapter is the difference
881
+ // between every `<Render>` partial resolving and none of them.
882
+ importOptions: [
883
+ {
884
+ flags: "--adapter <name>",
885
+ description: `Site adapter for custom components (${Object.keys(STARLIGHT_ADAPTERS).join(", ")})`,
886
+ },
887
+ {
888
+ flags: "--api-schema <file>",
889
+ description: "Cloudflare: a local copy of the API's OpenAPI schema (openapi.json), for <APIRequest> titles, auth and permission panels",
890
+ },
227
891
  ],
892
+ exportOptions: [],
228
893
  async import(source, opts) {
229
894
  return importStarlight(source, opts);
230
895
  },