@dogsbay/format-starlight 0.2.0-beta.114 → 0.2.0-beta.116

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,6 +3,9 @@ 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
11
  export const STARLIGHT_ADAPTERS = {
@@ -203,18 +206,119 @@ async function importStarlight(source, opts) {
203
206
  * hole into a described placeholder; nothing else about the component can
204
207
  * survive a conversion to markdown.
205
208
  */
206
- const describeComponent = (tag) => {
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) => {
207
239
  if (!componentsDir)
208
240
  return undefined;
209
- const file = join(componentsDir, `${tag}.astro`);
210
- if (!existsSync(file))
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)
211
266
  return undefined;
212
- const source = readFileSync(file, "utf-8");
213
- const aria = source.match(/aria-label="([^"]{20,})"/);
214
- if (aria)
215
- return aria[1].replace(/\s+/g, " ").trim();
216
- const caption = source.match(/<figcaption[^>]*>([^<]{20,})<\/figcaption>/);
217
- return caption ? caption[1].replace(/\s+/g, " ").trim() : 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;
218
322
  };
219
323
  // Parse all pages
220
324
  const mdxFiles = walkMdxFiles(docsDir, docsDir);
@@ -232,9 +336,124 @@ async function importStarlight(source, opts) {
232
336
  const taxonomyNames = Array.isArray(opts.taxonomyNames)
233
337
  ? opts.taxonomyNames
234
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
+ };
235
451
  for (const { filePath, slug } of mdxFiles) {
236
452
  const content = readFileSync(filePath, "utf-8");
453
+ let usesTemplates = false;
454
+ const directives = new DirectiveTable();
237
455
  const { frontmatter, raw, body } = extractFrontmatter(content);
456
+ pageImports = importsOf(body);
238
457
  // Use new MDX parser (markdown-it-mdx-jsx plugin) by default
239
458
  // Fall back to legacy regex parser with --legacy-parser flag
240
459
  const useLegacy = opts.legacyParser === true;
@@ -242,9 +461,14 @@ async function importStarlight(source, opts) {
242
461
  const tree = starlightToTree(body, {
243
462
  adapter,
244
463
  partialsDir,
464
+ frontmatter: raw,
245
465
  onDrop: (tag) => record(tag, slug),
246
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)),
247
469
  });
470
+ if (usesTemplates)
471
+ restoreDirectives(tree, directives, restoreStats);
248
472
  // Catch what the callback cannot: a component nested inside a MAPPED
249
473
  // container is not dropped, it LEAKS — its opening tag lands in the page
250
474
  // as visible text and its body often becomes a code block, because nothing
@@ -255,7 +479,7 @@ async function importStarlight(source, opts) {
255
479
  // Scanning the finished tree catches every route into the output, present
256
480
  // and future, in one place. `code` nodes are skipped: a code sample
257
481
  // containing `<Response>` is content, not a leak.
258
- for (const tag of leakedComponentTags(tree))
482
+ for (const tag of leakedComponentTags(tree, escapedIn(body, new Map([...includedEscapes(body, slug.split("/")[0]), ...(body.includes("<ProductChangelog") ? changelogEscapes() : [])]))))
259
483
  record(tag, slug);
260
484
  // Extract headings from tree
261
485
  const headings = tree
@@ -301,7 +525,9 @@ async function importStarlight(source, opts) {
301
525
  ],
302
526
  });
303
527
  }
304
- 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;
305
531
  const meta = parseMeta(raw, { slug, taxonomyNames });
306
532
  pages.push({
307
533
  slug,
@@ -311,8 +537,164 @@ async function importStarlight(source, opts) {
311
537
  redirect,
312
538
  frontmatter: raw,
313
539
  meta,
540
+ ...(usesTemplates ? { template: true } : {}),
314
541
  });
315
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
+ }
316
698
  // Set site name from opts or infer
317
699
  if (!opts.siteName && pages.length > 0) {
318
700
  const indexPage = pages.find((p) => p.slug === "index" || p.slug === "");
@@ -359,20 +741,93 @@ async function importStarlight(source, opts) {
359
741
  * A CamelCase tag only — `<ACCOUNT_ID>` and `<API_TOKEN>` are placeholders the
360
742
  * docs write on purpose, and flagging those would bury the real signal.
361
743
  */
362
- function leakedComponentTags(nodes) {
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()) {
363
798
  const found = [];
799
+ const excused = new Map(escapes);
364
800
  const scan = (list) => {
365
801
  for (const node of list) {
366
802
  if (node.type === "code" || node.type === "diagram")
367
803
  continue;
368
- for (const text of [node.html, ...(node.inline ?? []).map((i) => i.text)]) {
369
- if (typeof text !== "string")
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")
370
812
  continue;
813
+ // Inline code is skipped above: `Promise<Response>` is a type, not a
814
+ // leaked component (it was 60 false reports).
371
815
  // `</?` — a leak is often only the CLOSING tag, when the opener was
372
816
  // consumed by the container above it but the closer had nothing to
373
817
  // pair with. Matching openers alone missed exactly that case.
374
- for (const m of text.matchAll(/<\/?([A-Z][a-z0-9]+[A-Za-z0-9]*)[\s/>]/g))
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
+ }
375
829
  found.push(m[1]);
830
+ }
376
831
  }
377
832
  if (node.children)
378
833
  scan(node.children);
@@ -381,6 +836,38 @@ function leakedComponentTags(nodes) {
381
836
  scan(nodes);
382
837
  return found;
383
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;
870
+ }
384
871
  export const plugin = {
385
872
  name: "starlight",
386
873
  canImport: true,
@@ -397,6 +884,10 @@ export const plugin = {
397
884
  flags: "--adapter <name>",
398
885
  description: `Site adapter for custom components (${Object.keys(STARLIGHT_ADAPTERS).join(", ")})`,
399
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
+ },
400
891
  ],
401
892
  exportOptions: [],
402
893
  async import(source, opts) {
@@ -0,0 +1,16 @@
1
+ /** Fence-aware text helpers shared by the parser and adapters. */
2
+ /**
3
+ * Apply `fn` to the text outside fenced code blocks; fences pass through
4
+ * untouched. A fence closes on a line holding only the same character, at
5
+ * least as long, as CommonMark reads it.
6
+ */
7
+ export declare function outsideFences(source: string, fn: (text: string) => string): string;
8
+ /**
9
+ * Apply `fn` with inline code spans (`` `x` ``, ``` ``x`` ```) masked, so a
10
+ * rewrite cannot reach into them. A style guide writes "the
11
+ * `<GlossaryTooltip>` component", and a tag rewrite reaching into that span
12
+ * ate the prose up to the next real closing tag. MASKED, not split around:
13
+ * a tag's body may itself be code (`<GlossaryTooltip …>`robots.txt`</…>`),
14
+ * and splitting separated the opener from its closer.
15
+ */
16
+ export declare function outsideInlineCode(text: string, fn: (text: string) => string): string;