@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.
@@ -6,10 +6,15 @@
6
6
  *
7
7
  * Source: https://github.com/cloudflare/cloudflare-docs/tree/production/src/components
8
8
  */
9
- import { readFileSync, existsSync } from "node:fs";
10
- import { join, dirname } from "node:path";
9
+ import { readFileSync, existsSync, readdirSync, realpathSync } from "node:fs";
10
+ import { join, dirname, resolve, sep } from "node:path";
11
11
  import TOML from "@iarna/toml";
12
12
  import { parse as jsoncParse } from "jsonc-parser";
13
+ import matter from "gray-matter";
14
+ import { FIXED_SLUG, githubSlug } from "../github-slug.js";
15
+ import { outsideFences, outsideInlineCode } from "../fences.js";
16
+ import { findSelfClosingTags, parseJsLiteral } from "../js-literal.js";
17
+ import { componentPropDefaults, jsValueToJinja, renderComponentTemplate, splitTopLevel } from "../jsx-to-jinja.js";
13
18
  // ── Indentation helper ─────────────────────────────────
14
19
  /**
15
20
  * Indent all lines of content to match the indentation of the original tag.
@@ -83,6 +88,23 @@ function getCommand(mgr, type, pkg, opts = {}) {
83
88
  * Resolve `<PackageManagers>` to Tabs with npm/yarn/pnpm/bun commands.
84
89
  */
85
90
  function resolvePackageManagers(source) {
91
+ if (!source.includes("<PackageManagers"))
92
+ return source;
93
+ // A tag opening a LIST ITEM (`1. <PackageManagers … />`): the marker stays
94
+ // on its own line and the tabs sit at the item's content column. A tag in a
95
+ // BLOCKQUOTE (`> <PackageManagers` over `> `-prefixed lines): the prefixes
96
+ // come off to read it and go back on every line of the result. Both were
97
+ // left as written (reported) — only a tag at the start of a line was read.
98
+ source = source.replace(/^([ \t]*)((?:\d+\.|[-*+])[ \t]+)(<PackageManagers\s[\s\S]*?\/>)[ \t]*$/gm, (_m, indent, marker, tag) => {
99
+ const inner = resolvePackageManagers(tag).replace(/^\n/, "");
100
+ return `${indent}${marker.trimEnd()}\n\n${indentContent(inner, indent + " ".repeat(marker.length))}`;
101
+ });
102
+ source = source.replace(/^([ \t]*>[ \t]?)(<PackageManagers\s(?:[^\n]|\n[ \t]*>)*?\/>)[ \t]*$/gm, (_m, prefix, tag) => {
103
+ const bare = tag.replace(/\n[ \t]*>[ \t]?/g, "\n");
104
+ const inner = resolvePackageManagers(bare).replace(/^\n/, "");
105
+ // An empty quote line first: after a paragraph line `<Tabs>` cannot open a block.
106
+ return ["", ...inner.split("\n")].map((l) => `${prefix}${l}`.trimEnd()).join("\n");
107
+ });
86
108
  return source.replace(/^([ \t]*)<PackageManagers\s+([\s\S]*?)\/>/gm, (_match, indent, attrsStr) => {
87
109
  // Parse attributes
88
110
  const type = (attrsStr.match(/type\s*=\s*"([^"]*)"/)?.[1] || "add");
@@ -98,7 +120,10 @@ function resolvePackageManagers(source) {
98
120
  })
99
121
  .filter(Boolean)
100
122
  .join("\n");
101
- return indentContent(`<Tabs>\n${tabs}\n</Tabs>`, indent);
123
+ // Blank line first: written straight after a prose line (as the source
124
+ // often does, where MDX renders it inline), `<Tabs>` cannot open a block
125
+ // and the tabs were lost.
126
+ return "\n" + indentContent(`<Tabs>\n${tabs}\n</Tabs>`, indent);
102
127
  });
103
128
  }
104
129
  // ── WranglerConfig resolution ───────────────────────────
@@ -167,7 +192,7 @@ ${toml.replace(/\s*$/, "")}
167
192
 
168
193
  </TabItem>
169
194
  </Tabs>`;
170
- return indentContent(result, indent);
195
+ return "\n" + indentContent(result, indent); // a blank line first, as for PackageManagers
171
196
  }
172
197
  catch {
173
198
  // If conversion fails, output the original code block
@@ -211,45 +236,175 @@ function resolveJsxSwitch(content) {
211
236
  * substituting `{X}` with the file content in the source.
212
237
  * Vite/Astro's ?raw imports file contents as strings.
213
238
  */
214
- function resolveRawImports(source, partialsDir) {
239
+ function resolveRawImports(source, partialsDir, pageDir) {
215
240
  if (!partialsDir)
216
241
  return source;
217
242
  // src/content/partials/ → src/ (go up two levels to repo src/)
218
243
  const srcDir = dirname(dirname(partialsDir));
219
- const importRegex = /^import\s+(\w+)\s+from\s+["']~\/([^"']+)\?raw["']\s*;?\s*$/gm;
244
+ // `~/x?raw` is the site's src/; `./x?raw` (or `../`) is the page's own
245
+ // directory. Either must stay inside src/ once symlinks are resolved: the
246
+ // path is author-controlled, and an import is a file READ.
247
+ const importRegex = /^import\s+(\w+)\s+from\s+["'](~\/|\.{1,2}\/)([^"']+)\?raw["']\s*;?\s*$/gm;
220
248
  let result = source;
221
249
  let match;
222
250
  while ((match = importRegex.exec(source)) !== null) {
223
251
  const varName = match[1];
224
- const filePath = join(srcDir, match[2]);
225
- if (existsSync(filePath)) {
226
- try {
227
- const content = readFileSync(filePath, "utf-8");
228
- result = result.replace(match[0], ""); // remove import line
229
- // If used in <Code code={X} lang="..." />, replace with a fenced code block
230
- const codeTagRegex = new RegExp(`<Code\\s+code=\\{${varName}\\}[^>]*(?:lang=["']([^"']*)["'])?[^>]*/>`);
231
- const codeMatch = result.match(codeTagRegex);
232
- if (codeMatch) {
233
- const lang = codeMatch[1] || "plaintext";
234
- // Use a fence that doesn't appear in the content
235
- let fence = "```";
236
- const backtickRuns = content.match(/`{3,}/g);
237
- if (backtickRuns) {
238
- const maxLen = Math.max(...backtickRuns.map((r) => r.length));
239
- fence = "`".repeat(maxLen + 1);
240
- }
241
- result = result.replace(codeMatch[0], `${fence}${lang}\n${content}\n${fence}`);
242
- }
243
- else {
244
- // Fallback: substitute {X} directly
245
- result = result.replace(new RegExp(`\\{${varName}\\}`, "g"), content);
252
+ const base = match[2] === "~/" ? srcDir : pageDir;
253
+ if (!base)
254
+ continue;
255
+ const filePath = within(srcDir, resolve(base, match[2] === "~/" ? match[3] : match[2] + match[3]));
256
+ if (!filePath)
257
+ continue;
258
+ try {
259
+ const content = readFileSync(filePath, "utf-8");
260
+ result = result.replace(match[0], ""); // remove import line
261
+ // `<Markdown text={X} />`: the file IS Markdown (third-party license
262
+ // pages); left to the fallback it became `<Markdown text=…whole file…`.
263
+ const mdTag = new RegExp(`<Markdown\\s+text=\\{${varName}\\}[^>]*?/>`, "g");
264
+ if (new RegExp(mdTag.source).test(result)) {
265
+ result = result.replace(mdTag, () => content.trim());
266
+ continue;
267
+ }
268
+ // If used in <Code code={X} lang="..." />, replace with a fenced code block
269
+ const codeTagRegex = new RegExp(`<Code\\s+code=\\{${varName}\\}[^>]*(?:lang=["']([^"']*)["'])?[^>]*/>`);
270
+ const codeMatch = result.match(codeTagRegex);
271
+ if (codeMatch) {
272
+ const lang = codeMatch[1] || "plaintext";
273
+ // Use a fence that doesn't appear in the content
274
+ let fence = "```";
275
+ const backtickRuns = content.match(/`{3,}/g);
276
+ if (backtickRuns) {
277
+ const maxLen = Math.max(...backtickRuns.map((r) => r.length));
278
+ fence = "`".repeat(maxLen + 1);
246
279
  }
280
+ result = result.replace(codeMatch[0], () => `${fence}${lang}\n${content}\n${fence}`);
281
+ }
282
+ else {
283
+ // Fallback: substitute {X} directly
284
+ result = result.replace(new RegExp(`\\{${varName}\\}`, "g"), () => content);
247
285
  }
248
- catch { /* ignore read errors */ }
249
286
  }
287
+ catch { /* ignore read errors */ }
250
288
  }
251
289
  return result;
252
290
  }
291
+ /**
292
+ * Page-level string constants: `export const x = "…";` (or a backtick
293
+ * template with no `${}`), used as `{x}`, `<Code code={x} lang="…" />` or
294
+ * `<Markdown text={x} />`. ESM is never rendered, but nothing read it: the
295
+ * statement showed as text, `{owaspCrsVersion}` literally, and a JSON-mode
296
+ * page's three code samples were missing (5 pages). Other values are left
297
+ * alone.
298
+ */
299
+ export function resolvePageConstants(source) {
300
+ if (!/^export\s+const\s/m.test(source))
301
+ return source;
302
+ const values = new Map();
303
+ // Pass 1: read and remove the statements. Not inside inline code — a
304
+ // template literal STARTS with a backtick.
305
+ let result = outsideFences(source, (text) => {
306
+ let out = "";
307
+ let last = 0;
308
+ for (const m of text.matchAll(/^export\s+const\s+(\w+)\s*=\s*/gm)) {
309
+ // Not a line inside a template-literal attribute (`code={`…`}`): the
310
+ // backticks before it are unbalanced there.
311
+ if ((text.slice(0, m.index).match(/(?<!\\)`/g)?.length ?? 0) % 2)
312
+ continue;
313
+ const at = m.index + m[0].length;
314
+ const q = text[at];
315
+ if (q !== '"' && q !== "'" && q !== "`")
316
+ continue;
317
+ let i = at + 1;
318
+ while (i < text.length && text[i] !== q)
319
+ i += text[i] === "\\" ? 2 : 1;
320
+ const tail = /^;?[ \t]*(?:\n|$)/.exec(text.slice(i + 1));
321
+ if (i >= text.length || !tail)
322
+ continue;
323
+ const value = parseJsLiteral(text.slice(at, i + 1));
324
+ if (typeof value !== "string")
325
+ continue;
326
+ values.set(m[1], value);
327
+ out += text.slice(last, m.index);
328
+ last = i + 1 + tail[0].length;
329
+ }
330
+ return out + text.slice(last);
331
+ });
332
+ if (!values.size)
333
+ return source;
334
+ // Pass 2: the uses. `{x}` in text first — never inside an open tag, where
335
+ // `params={{ x }}` is an object shorthand and `code={x}` is taken below.
336
+ // JSX prints a string as TEXT: it is entity-encoded, which reads the same
337
+ // in Markdown and in a raw HTML block (a `\_` shows its backslash there).
338
+ const asText = (v) => v.replace(/[&<>|*_`[\]\\#+\-!~]/g, (c) => `&#${c.charCodeAt(0)};`);
339
+ result = outsideFences(result, (text) => outsideInlineCode(text, (t) => t.replace(/\{\s*(\w+)\s*\}/g, (whole, name, offset) => {
340
+ const value = values.get(name);
341
+ if (value === undefined || /<[A-Za-z][^<>]*$/.test(t.slice(0, offset)))
342
+ return whole;
343
+ return asText(value);
344
+ })));
345
+ result = outsideFences(result, (text) => {
346
+ for (const tag of findSelfClosingTags(text, "Code").reverse()) {
347
+ const name = /^\{\s*(\w+)\s*\}$/.exec(String(tag.attrs.code ?? ""))?.[1];
348
+ const value = name !== undefined ? values.get(name) : undefined;
349
+ const lineStart = text.lastIndexOf("\n", tag.start - 1) + 1;
350
+ const indent = text.slice(lineStart, tag.start);
351
+ if (value === undefined || !/^[ \t]*$/.test(indent))
352
+ continue;
353
+ const lang = typeof tag.attrs.lang === "string" ? tag.attrs.lang : "";
354
+ const runs = value.match(/`{3,}/g);
355
+ const fence = "`".repeat(runs ? Math.max(...runs.map((r) => r.length)) + 1 : 3);
356
+ const body = `${fence}${lang}\n${value.replace(/^\n+|\s+$/g, "")}\n${fence}`.split("\n").join(`\n${indent}`);
357
+ text = text.slice(0, tag.start) + body + text.slice(tag.end);
358
+ }
359
+ for (const tag of findSelfClosingTags(text, "Markdown").reverse()) {
360
+ const name = /^\{\s*(\w+)\s*\}$/.exec(String(tag.attrs.text ?? ""))?.[1];
361
+ const value = name !== undefined ? values.get(name) : undefined;
362
+ if (value === undefined)
363
+ continue;
364
+ text = text.slice(0, tag.start) + value.trim().replace(/\s*\n\s*/g, " ") + text.slice(tag.end);
365
+ }
366
+ return text;
367
+ });
368
+ return result;
369
+ }
370
+ /**
371
+ * A `<LinkButton>` inside RAW HTML (a `<DashButton>` in an `<ol><li>`, which
372
+ * becomes a LinkButton above) is not a component there, it is a leaked tag.
373
+ * In HTML the button is a link: `<a href>label</a>`.
374
+ */
375
+ export function linkButtonsInHtml(tree) {
376
+ const fix = (html) => html.includes("<LinkButton")
377
+ ? html.replace(/<LinkButton\b([^>]*)>([\s\S]*?)<\/LinkButton>/g, (whole, attrs, label) => {
378
+ const href = /\bhref=("[^"]*"|'[^']*')/.exec(attrs)?.[1];
379
+ return href ? `<a href=${href}>${label}</a>` : whole;
380
+ })
381
+ : html;
382
+ const walk = (value) => {
383
+ if (Array.isArray(value))
384
+ return value.forEach(walk);
385
+ if (!value || typeof value !== "object")
386
+ return;
387
+ const node = value;
388
+ if (typeof node.html === "string")
389
+ node.html = fix(node.html);
390
+ for (const [k, v] of Object.entries(node))
391
+ if (k !== "props" && typeof v === "object")
392
+ walk(v);
393
+ };
394
+ walk(tree);
395
+ return tree;
396
+ }
397
+ /** `path` if it resolves (symlinks included) inside `root`, else undefined. */
398
+ function within(root, path) {
399
+ try {
400
+ const real = realpathSync(path);
401
+ const top = realpathSync(root);
402
+ return real === top || real.startsWith(top + sep) ? real : undefined;
403
+ }
404
+ catch {
405
+ return undefined;
406
+ }
407
+ }
253
408
  // ── Render partial resolution ──────────────────────────
254
409
  /**
255
410
  * Resolve `<Render>` tags by inlining partial MDX content.
@@ -305,6 +460,211 @@ function resolveRenderTags(source, partialsDir, depth = 0) {
305
460
  }
306
461
  });
307
462
  }
463
+ /**
464
+ * Template mode (plans/page-templates.md): each `<Render>` becomes
465
+ *
466
+ * {% with a="x", b=true %}{% include "/_partials/<product>/<file>.md" %}{% endwith %}
467
+ *
468
+ * on its own line at the tag's indent. The partial itself is converted by
469
+ * the caller (cli.ts). A tag whose params fall outside the convertible
470
+ * subset is left for `resolveRenderTags`, which inlines it as before, and is
471
+ * reported; a tag whose partial does not exist is left for the same path.
472
+ */
473
+ function renderTagsToTemplates(source, partialsDir, options) {
474
+ if (!partialsDir)
475
+ return source;
476
+ let out = "";
477
+ let last = 0;
478
+ for (const [start, end] of findRenderTags(source)) {
479
+ const whole = source.slice(start, end);
480
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
481
+ const lineEnd = source.indexOf("\n", end);
482
+ const alone = /^[ \t]*$/.test(source.slice(lineStart, start)) && /^[ \t]*$/.test(source.slice(end, lineEnd < 0 ? source.length : lineEnd));
483
+ const directive = renderTagToDirective(whole.slice("<Render".length, -2), partialsDir, options);
484
+ out += source.slice(last, start);
485
+ last = end;
486
+ if (directive === undefined) {
487
+ out += whole;
488
+ continue;
489
+ }
490
+ const token = options.directives ? options.directives.token(directive) : directive;
491
+ if (alone) {
492
+ // In place of the tag, at the Markdown indent of its surroundings. NOT
493
+ // padded with blank lines: after a footnote label (`[^1]:`) a blank line
494
+ // turned the indented line into an indented code block.
495
+ out = out.slice(0, out.length - (start - lineStart)) + markdownIndent(source, start, source.slice(lineStart, start)) + token;
496
+ }
497
+ else {
498
+ // Mid-sentence (`add two entries: <Render file="ipv4" …/>`): inline.
499
+ out += token;
500
+ }
501
+ }
502
+ return out + source.slice(last);
503
+ }
504
+ /** One `<Render>` tag's attributes → its directive, or undefined when it stays a tag (reported). */
505
+ function renderTagToDirective(attrsStr, partialsDir, options) {
506
+ const file = attrsStr.match(/\bfile\s*=\s*"([^"]*)"/)?.[1];
507
+ const product = attrsStr.match(/\bproduct\s*=\s*"([^"]*)"/)?.[1] ?? options.defaultProduct;
508
+ if (!file)
509
+ return undefined;
510
+ if (!product) {
511
+ // Inside a partial, Cloudflare takes a missing product= from the PAGE
512
+ // being built, which a shared partial cannot know. Reported, not guessed
513
+ // (none in the corpus: 0 of 289).
514
+ options.onInlined?.({ product: "?", file, reason: "no product= inside a partial (Cloudflare uses the page's product)" });
515
+ return undefined;
516
+ }
517
+ if (!existsSync(join(partialsDir, product, `${file}.mdx`))) {
518
+ options.onInlined?.({ product, file, reason: "partial not found" });
519
+ return undefined;
520
+ }
521
+ let assignments = [];
522
+ const paramsAt = attrsStr.search(/\bparams\s*=\s*\{/);
523
+ if (paramsAt >= 0) {
524
+ const open = attrsStr.indexOf("{", paramsAt);
525
+ const obj = balanced(attrsStr, open)?.trim();
526
+ if (!obj || !obj.startsWith("{") || !obj.endsWith("}")) {
527
+ options.onInlined?.({ product, file, reason: "params is not an object literal" });
528
+ return undefined;
529
+ }
530
+ const converted = paramsToAssignments(obj.slice(1, -1), options.names);
531
+ if (!converted.ok) {
532
+ options.onInlined?.({ product, file, reason: converted.reason });
533
+ return undefined;
534
+ }
535
+ assignments = converted.assignments;
536
+ }
537
+ options.onTemplateInclude?.({ product, file, params: assignments.map((a) => a.slice(0, a.indexOf("="))) });
538
+ const include = `{% include "/_partials/${product}/${file}.md" %}`;
539
+ return assignments.length ? `{% with ${assignments.join(", ")} %}${include}{% endwith %}` : include;
540
+ }
541
+ /** `<Render …/>` tags outside code, as [start, end) offsets; mid-line tags included. */
542
+ export function findRenderTags(src) {
543
+ const out = [];
544
+ let i = 0;
545
+ let lineStart = true;
546
+ let fence = null;
547
+ while (i < src.length) {
548
+ if (lineStart) {
549
+ const m = /^[ \t]*(`{3,}|~{3,})/.exec(src.slice(i, i + 200));
550
+ if (m) {
551
+ if (!fence)
552
+ fence = m[1];
553
+ else if (m[1][0] === fence[0] && m[1].length >= fence.length)
554
+ fence = null;
555
+ const nl = src.indexOf("\n", i);
556
+ i = nl < 0 ? src.length : nl + 1;
557
+ continue;
558
+ }
559
+ }
560
+ const c = src[i];
561
+ lineStart = c === "\n";
562
+ if (fence) {
563
+ i++;
564
+ continue;
565
+ }
566
+ if (c === "`") {
567
+ const run = /^`+/.exec(src.slice(i))[0];
568
+ const close = src.indexOf(run, i + run.length);
569
+ i = close < 0 ? i + run.length : close + run.length;
570
+ continue;
571
+ }
572
+ if (c === "<" && src.startsWith("<Render", i) && /[\s/]/.test(src[i + 7] ?? "")) {
573
+ const end = selfClosingEnd(src, i);
574
+ if (end > 0) {
575
+ out.push([i, end]);
576
+ i = end;
577
+ continue;
578
+ }
579
+ }
580
+ i++;
581
+ }
582
+ return out;
583
+ }
584
+ /** End (exclusive) of a self-closing tag at `i`, skipping strings and `{…}` groups; -1 if none. */
585
+ function selfClosingEnd(src, i) {
586
+ let depth = 0;
587
+ for (let j = i + 1; j < src.length; j++) {
588
+ const c = src[j];
589
+ if (c === '"' || c === "'" || c === "`") {
590
+ const close = src.indexOf(c, j + 1);
591
+ if (close < 0)
592
+ return -1;
593
+ j = close;
594
+ }
595
+ else if (c === "{")
596
+ depth++;
597
+ else if (c === "}")
598
+ depth--;
599
+ else if (depth === 0 && c === "/" && src[j + 1] === ">")
600
+ return j + 2;
601
+ else if (depth === 0 && c === ">")
602
+ return -1;
603
+ }
604
+ return -1;
605
+ }
606
+ /**
607
+ * The indent a directive may keep where the tag stood. In MDX, whitespace
608
+ * before a JSX element means nothing — Cloudflare writes
609
+ * ` <Render …/>` inside a <TabItem> after unindented prose. In
610
+ * Markdown four spaces after a blank line open an INDENTED CODE BLOCK, and
611
+ * the include came out as a literal code sample. So the indent is capped at
612
+ * the content column of the previous non-blank line: a list item's text
613
+ * column (the directive stays in the item), otherwise that line's indent.
614
+ */
615
+ export function markdownIndent(source, offset, indent) {
616
+ const before = source.slice(0, offset).split("\n");
617
+ before.pop(); // the tag's own line
618
+ let prev = "";
619
+ while (before.length && !(prev = before.pop()).trim())
620
+ ;
621
+ if (!prev.trim())
622
+ return "";
623
+ const m = /^(\s*)(?:([-*+]|\d+[.)])\s+)?/.exec(prev);
624
+ const column = m[0].length;
625
+ return indent.length > column ? " ".repeat(column) : indent;
626
+ }
627
+ /** The text of the `{…}` group opening at `open` (inclusive), or undefined. */
628
+ function balanced(src, open) {
629
+ let depth = 0;
630
+ let q = null;
631
+ for (let i = open; i < src.length; i++) {
632
+ const c = src[i];
633
+ if (q) {
634
+ if (c === "\\")
635
+ i++;
636
+ else if (c === q)
637
+ q = null;
638
+ continue;
639
+ }
640
+ if (c === '"' || c === "'" || c === "`")
641
+ q = c;
642
+ else if (c === "{")
643
+ depth++;
644
+ else if (c === "}" && --depth === 0)
645
+ return src.slice(open + 1, i);
646
+ }
647
+ return undefined;
648
+ }
649
+ /** `key: value, …` → Jinja `key=value` assignments, or why not. */
650
+ function paramsToAssignments(src, names) {
651
+ const assignments = [];
652
+ for (const part of splitTopLevel(src)) {
653
+ if (!part.trim())
654
+ continue;
655
+ const m = /^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][\w$]*))\s*:\s*([\s\S]+)$/.exec(part);
656
+ if (!m)
657
+ return { ok: false, reason: `unsupported param entry: ${part.trim().slice(0, 60)}` };
658
+ const key = m[1] ?? m[2] ?? m[3];
659
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
660
+ return { ok: false, reason: `param name "${key}" is not a template identifier` };
661
+ const value = jsValueToJinja(m[4], names);
662
+ if (value === undefined)
663
+ return { ok: false, reason: `param "${key}" value outside the convertible subset: ${m[4].trim().slice(0, 60)}` };
664
+ assignments.push(`${key}=${value}`);
665
+ }
666
+ return { ok: true, assignments };
667
+ }
308
668
  function parseRenderAttrs(str) {
309
669
  const file = str.match(/file\s*=\s*"([^"]*)"/)?.[1];
310
670
  const product = str.match(/product\s*=\s*"([^"]*)"/)?.[1];
@@ -320,8 +680,1396 @@ function parseRenderAttrs(str) {
320
680
  return { file, product, params };
321
681
  }
322
682
  // ── Adapter export ─────────────────────────────────────
683
+ /**
684
+ * The SDK ids of `id="x"`, `id={["a", "b"]}` — and `id={"a", "b"}`, which is
685
+ * a JS COMMA expression: its value is the LAST string, so upstream shows that
686
+ * snippet for "b" only (2 corpus uses). Anything else (a variable) has no
687
+ * ids and lands in an "other" tab rather than inventing an SDK name.
688
+ */
689
+ function rtkIds(raw) {
690
+ const strings = [...raw.matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
691
+ if (!raw.startsWith("{"))
692
+ return raw ? [raw] : [];
693
+ if (/^\{\s*\[/.test(raw))
694
+ return strings;
695
+ return strings.length ? [strings[strings.length - 1]] : [];
696
+ }
697
+ /**
698
+ * Markdown for ONE table cell: a cell is one line, so paragraphs and list
699
+ * items keep apart with <br> (upstream renders real <p>/<ul> there; spaces
700
+ * ran "…in time. - Bookmarks are…" together), and pipes are escaped.
701
+ */
702
+ function tableCell(value) {
703
+ if (value === undefined || value === null)
704
+ return "";
705
+ return String(value)
706
+ .trim()
707
+ .split(/\n\s*\n/)
708
+ .map((para) => para.split(/\n(?=\s*(?:[-*+]|\d+[.)])\s)/).map((l) => l.replace(/\s*\n\s*/g, " ").trim()).join("<br>"))
709
+ .join("<br><br>")
710
+ .replace(/\|/g, "\\|");
711
+ }
712
+ // ── Plans (FeatureTable, ProductFeatures) ───────────────
713
+ // Per process, like the glossary cache: a one-shot import reads the file once;
714
+ // a long-lived host (a watch mode) would need to drop it on change.
715
+ const plansCache = new Map();
716
+ /** `src/content/plans/index.json`, a sibling of the partials dir. */
717
+ function loadPlans(partialsDir) {
718
+ const file = partialsDir ? join(dirname(partialsDir), "plans", "index.json") : "";
719
+ if (!plansCache.has(file)) {
720
+ let data;
721
+ try {
722
+ data = file && existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined;
723
+ }
724
+ catch {
725
+ data = undefined; // unreadable: tags stay, and are reported
726
+ }
727
+ plansCache.set(file, data);
728
+ }
729
+ return plansCache.get(file);
730
+ }
731
+ const byPath = (obj, path) => path.split(".").reduce((acc, key) => (acc && typeof acc === "object" ? acc[key] : undefined), obj);
732
+ /** `<FeatureTable id>`: the plan comparison table FeatureTable.astro renders. */
733
+ function featureTable(plan) {
734
+ const features = Object.values((plan.properties ?? {}));
735
+ if (!features.length)
736
+ return undefined;
737
+ const cols = [
738
+ ["free", "Free"],
739
+ ["pro", "Pro"],
740
+ ["biz", "Business"],
741
+ ["ent", "Enterprise"],
742
+ ["ent_plus", String(plan.ent_plus ?? "Enterprise Plus")],
743
+ ];
744
+ const present = cols.filter(([k]) => features.some((f) => f[k]));
745
+ return [
746
+ `| | ${present.map(([, label]) => label).join(" | ")} |`,
747
+ `| --- | ${present.map(() => "---").join(" | ")} |`,
748
+ // Row titles are header cells upstream (<th>): bold here.
749
+ ...features.map((f) => `| ${f.title ? `**${tableCell(f.title)}**` : ""} | ${present.map(([k]) => tableCell(f[k])).join(" | ")} |`),
750
+ ];
751
+ }
752
+ /** `<ProductFeatures id additional_descriptions>`: each feature's per-plan availability. */
753
+ function productFeatures(product, more) {
754
+ const out = [];
755
+ for (const [key, value] of Object.entries(product)) {
756
+ if (key === "title" || key === "link" || !value || typeof value !== "object")
757
+ continue;
758
+ const feature = value;
759
+ out.push(anchorHeading(String(feature.title ?? key), 3), "");
760
+ if (feature.link)
761
+ out.push(`**Link:** [${feature.title ?? key}](${feature.link})`, "");
762
+ for (const p of Object.values(feature.properties ?? {})) {
763
+ out.push(`**${p.title === "Availability" ? "Feature availability" : p.title ?? ""}**`, "");
764
+ if (p.title !== "Availability" && p.summary)
765
+ out.push(String(p.summary).trim(), "");
766
+ const items = [
767
+ ["Free", p.free],
768
+ ...(more ? [["Lite", p.lite ?? p.free]] : []),
769
+ ["Pro", p.pro],
770
+ ...(more ? [["Pro Plus", p.pro_plus ?? p.pro]] : []),
771
+ ["Business", p.biz],
772
+ ["Enterprise", p.ent],
773
+ ];
774
+ for (const [label, v] of items)
775
+ if (v !== undefined)
776
+ out.push(`- **${label}:** ${String(v).replace(/\s*\n\s*/g, " ")}`);
777
+ out.push("");
778
+ }
779
+ }
780
+ return out.length ? out : undefined;
781
+ }
782
+ /**
783
+ * Cloudflare's plan components, from `src/content/plans/index.json` at
784
+ * import, outside fenced and inline code. An unknown id leaves the tag, which
785
+ * is reported (both are lossy strips in the component map).
786
+ */
787
+ export function resolvePlans(source, partialsDir) {
788
+ if (!/<(?:FeatureTable|ProductFeatures)\b/.test(source))
789
+ return source;
790
+ const plans = loadPlans(partialsDir);
791
+ if (!plans)
792
+ return source;
793
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/^([ \t]*)<(FeatureTable|ProductFeatures)\b([^>]*?)\/>/gm, (whole, indent, tag, attrs) => {
794
+ const id = attr(attrs, "id");
795
+ const entry = id ? byPath(plans, id) : undefined;
796
+ if (!entry || typeof entry !== "object")
797
+ return whole;
798
+ const lines = tag === "FeatureTable"
799
+ ? featureTable(entry)
800
+ : productFeatures(entry, /\badditional_descriptions\b(?!\s*=\s*\{\s*false\s*\})/.test(attrs));
801
+ // Every LINE takes the indent (a multi-line summary is one entry).
802
+ return lines ? `\n${lines.join("\n").split("\n").map((l) => (l ? indent + l : l)).join("\n")}\n` : whole;
803
+ })));
804
+ }
805
+ /** `<Plan type>` labels, as `components/cf/Plan.astro` maps them. */
806
+ const PLAN_TYPES = {
807
+ all: "Available on all plans",
808
+ paid: "Available on Paid plans",
809
+ pro: "Pro and above",
810
+ business: "Business and above",
811
+ enterprise: "Enterprise-only",
812
+ "add-on": "Add-on feature",
813
+ "ent-add-on": "Enterprise-only paid add-on",
814
+ "workers-all": "Available on Free and Paid plans",
815
+ "workers-paid": "Available on Workers Paid plan",
816
+ beta: "Available in open beta",
817
+ };
818
+ /**
819
+ * `<Plan type="…" />` → its label, `<Plan id="a.b.c" />` → that string from
820
+ * `plans/index.json` — in bold, as the component's emphasised line (a
821
+ * `<div>`: alone on its line it ends its paragraph). The old transform
822
+ * printed the raw type (`**ent-add-on plan**`) and never read `id=` (7
823
+ * reported). An unresolved tag is left to the parser: alone on a line it is
824
+ * dropped and reported as an unknown component; mid-line the leak scan
825
+ * reports it.
826
+ */
827
+ export function resolvePlanBadges(source, partialsDir) {
828
+ if (!source.includes("<Plan"))
829
+ return source;
830
+ let plans = null;
831
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/<Plan\s+(type|id)=["']([^"']+)["']\s*\/?>/g, (whole, kind, key, offset, all) => {
832
+ let label;
833
+ if (kind === "type")
834
+ label = PLAN_TYPES[key];
835
+ else {
836
+ if (plans === null)
837
+ plans = loadPlans(partialsDir);
838
+ label = plans ? byPath(plans, key) : undefined;
839
+ }
840
+ if (typeof label !== "string" || !label.trim())
841
+ return whole;
842
+ const bold = `**${label.trim().replace(/[*_`[\]\\<]/g, "\\$&")}**`;
843
+ // Alone on its line with text on the next: a blank line, so the
844
+ // following text does not join the badge's paragraph.
845
+ const lineStart = all.lastIndexOf("\n", offset - 1) + 1;
846
+ const lineEnd = all.indexOf("\n", offset);
847
+ const alone = !all.slice(lineStart, offset).trim() && !all.slice(offset + whole.length, lineEnd < 0 ? all.length : lineEnd).trim();
848
+ const next = lineEnd < 0 ? "" : all.slice(lineEnd + 1, all.indexOf("\n", lineEnd + 1) < 0 ? all.length : all.indexOf("\n", lineEnd + 1));
849
+ return alone && next.trim() ? `${bold}\n` : bold;
850
+ })));
851
+ }
852
+ /**
853
+ * A YAML data file, via gray-matter's YAML engine (no new dependency). The
854
+ * files may open with their own `---` document marker; left in, the wrapper
855
+ * read as EMPTY front matter (every glossary term was once lost to that).
856
+ */
857
+ function readYaml(file) {
858
+ const yaml = readFileSync(file, "utf8").replace(/^\uFEFF?---[ \t]*\r?\n/, "");
859
+ return matter(`---\n${yaml}\n---\n`).data;
860
+ }
861
+ const yamlCache = new Map();
862
+ function cachedYaml(file) {
863
+ if (!yamlCache.has(file)) {
864
+ let data;
865
+ try {
866
+ data = existsSync(file) ? readYaml(file) : undefined;
867
+ }
868
+ catch {
869
+ data = undefined; // unreadable: the tags stay, and are reported
870
+ }
871
+ yamlCache.set(file, data);
872
+ }
873
+ return yamlCache.get(file);
874
+ }
875
+ /** Re-indent a block of lines to a tag's own indent (every line). */
876
+ /**
877
+ * A heading written for COMPONENT data (flags, notifications, changelog
878
+ * dates, plan features): upstream renders these as `<AnchorHeading>`, whose
879
+ * id is its own and takes no slot in the page's dedupe — so they are emitted
880
+ * as one too, not as `##` Markdown.
881
+ */
882
+ const anchorHeading = (title, depth) => `<AnchorHeading ${jsxAttr("title", title.replace(/\s+/g, " ").trim())} depth={${depth}} />`;
883
+ const indented = (block, indent) => block.split("\n").map((l) => (l ? indent + l : l)).join("\n");
884
+ /**
885
+ * A JSX attribute value, quoted so it survives: the attribute parser does not
886
+ * decode `&quot;`, so a value holding `"` is single-quoted instead.
887
+ */
888
+ const jsxAttr = (name, s) => (s.includes('"') && !s.includes("'") ? `${name}='${s}'` : `${name}="${s.replace(/"/g, "'")}"`);
889
+ /**
890
+ * `<AvailableNotifications product notificationFilter />` → a `<Details>`
891
+ * per notification (grouped under `##` product headings when unfiltered), as
892
+ * AvailableNotifications.astro renders, from `notifications/index.yaml`. The
893
+ * Details go through the normal component mapping.
894
+ */
895
+ export function resolveNotifications(source, partialsDir) {
896
+ if (!source.includes("<AvailableNotifications"))
897
+ return source;
898
+ const data = partialsDir ? cachedYaml(join(dirname(partialsDir), "notifications", "index.yaml")) : undefined;
899
+ const all = data?.entries;
900
+ if (!Array.isArray(all))
901
+ return source;
902
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/^([ \t]*)<AvailableNotifications\b([^>]*?)\/>/gm, (whole, indent, attrs) => {
903
+ const product = attr(attrs, "product");
904
+ const filter = attr(attrs, "notificationFilter");
905
+ let list = all;
906
+ if (product)
907
+ list = list.filter((n) => String(n.associatedProducts ?? "").toLowerCase() === product.toLowerCase());
908
+ if (filter)
909
+ list = list.filter((n) => String(n.name ?? "").toLowerCase() === filter.toLowerCase());
910
+ if (!list.length)
911
+ return whole;
912
+ const groups = new Map();
913
+ for (const n of list) {
914
+ const k = String(n.associatedProducts ?? "");
915
+ if (!groups.has(k))
916
+ groups.set(k, []);
917
+ groups.get(k).push(n);
918
+ }
919
+ const out = [];
920
+ const headings = !product && !filter;
921
+ // Code-unit order, as upstream's `.sort()` ("SSL/TLS" before "Security Center").
922
+ for (const [group, entries] of [...groups.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
923
+ if (headings)
924
+ out.push(anchorHeading(group, 2), "");
925
+ for (const n of entries) {
926
+ out.push(`<Details ${jsxAttr("header", String(n.name ?? ""))}>`, "");
927
+ const part = (label, v) => {
928
+ if (v)
929
+ out.push(`**${label}**`, "", String(v).trim(), "");
930
+ };
931
+ part("Who is it for?", n.audience);
932
+ part("Other options / filters", n.otherFilters);
933
+ part("Included with", n.availability);
934
+ part("What should you do if you receive one?", n.nextSteps);
935
+ part("Additional information", n.additional_information);
936
+ part("Limitations", n.limitations);
937
+ out.push("</Details>", "");
938
+ }
939
+ }
940
+ return indented(out.join("\n").trimEnd(), indent);
941
+ })));
942
+ }
943
+ /** Inline code for a table cell: a backtick fence long enough, pipes escaped. */
944
+ function codeCell(value) {
945
+ const s = String(value ?? "");
946
+ if (!s)
947
+ return "";
948
+ const run = Math.max(0, ...[...s.matchAll(/`+/g)].map((m) => m[0].length)) + 1;
949
+ const fence = "`".repeat(run);
950
+ return `${fence}${run > 1 ? " " : ""}${s.replace(/\|/g, "\\|")}${run > 1 ? " " : ""}${fence}`;
951
+ }
952
+ /**
953
+ * `<PagesBuildPreset framework />` → its configuration table, and
954
+ * `<PagesBuildPresetsTable />` → every preset, as the components render them,
955
+ * from `pages-framework-presets/index.yaml`. An unknown framework leaves the
956
+ * tag, which is reported.
957
+ */
958
+ export function resolvePagesPresets(source, partialsDir) {
959
+ if (!/<PagesBuildPreset/.test(source))
960
+ return source;
961
+ const data = partialsDir
962
+ ? cachedYaml(join(dirname(partialsDir), "pages-framework-presets", "index.yaml"))
963
+ : undefined;
964
+ const presets = data?.build_configs;
965
+ if (!presets || typeof presets !== "object")
966
+ return source;
967
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/^([ \t]*)<(PagesBuildPresetsTable|PagesBuildPreset)\b([^>]*?)\/>/gm, (whole, indent, tag, attrs) => {
968
+ let lines;
969
+ if (tag === "PagesBuildPresetsTable") {
970
+ lines = [
971
+ "| Framework/tool | Build command | Build directory |",
972
+ "| --- | --- | --- |",
973
+ ...Object.values(presets).map((p) => `| ${tableCell(p.display_name)} | ${codeCell(p.build_command)} | ${codeCell(p.build_output_directory)} |`),
974
+ ];
975
+ }
976
+ else {
977
+ const name = attr(attrs, "framework") ?? "";
978
+ // Own keys only: `framework="constructor"` is not a preset.
979
+ const preset = Object.hasOwn(presets, name) ? presets[name] : undefined;
980
+ if (!preset || typeof preset !== "object")
981
+ return whole;
982
+ lines = [
983
+ "| Configuration option | Value |",
984
+ "| --- | --- |",
985
+ `| Production branch | ${codeCell("main")} |`,
986
+ `| Build command | ${codeCell(preset.build_command)} |`,
987
+ `| Build directory | ${codeCell(preset.build_output_directory)} |`,
988
+ ];
989
+ }
990
+ // Blank lines around: a table must not merge with an adjacent table or
991
+ // take the next line as a row.
992
+ return `\n${indented(lines.join("\n"), indent)}\n`;
993
+ })));
994
+ }
995
+ // ── GitHubCode ──────────────────────────────────────────
996
+ /**
997
+ * `<GitHubCode repo commit file lines tag lang />` fetches a file from GitHub
998
+ * at build time and shows it (or its `lines`, or a tagged region). An import
999
+ * does not reach the network, so it becomes a LINK to exactly that file at
1000
+ * that commit (and those lines), and is counted as degraded — the reader
1001
+ * can still get the code; the page no longer shows it.
1002
+ */
1003
+ export function resolveGitHubCode(source, onDegraded) {
1004
+ if (!source.includes("<GitHubCode"))
1005
+ return source;
1006
+ return outsideFences(source, (text) => {
1007
+ const tags = findSelfClosingTags(text, "GitHubCode");
1008
+ if (!tags.length)
1009
+ return text;
1010
+ let out = "";
1011
+ let last = 0;
1012
+ for (const tag of tags) {
1013
+ const a = tag.attrs;
1014
+ const str = (k) => (typeof a[k] === "string" && !a[k].startsWith("{") ? a[k] : undefined);
1015
+ const repo = str("repo");
1016
+ const commit = str("commit");
1017
+ const file = str("file");
1018
+ const lineStart = text.lastIndexOf("\n", tag.start - 1) + 1;
1019
+ const indent = text.slice(lineStart, tag.start);
1020
+ if (!repo || !commit || !file || !/^[\w.-]+\/[\w.-]+$/.test(repo) || !/^[\w./-]+$/.test(commit) || !/^[ \t]*$/.test(indent))
1021
+ continue;
1022
+ // Upstream: 1-based, inclusive; a single number means from there on.
1023
+ const lines = /^(\d+)(?:-(\d+))?$/.exec(str("lines") ?? "");
1024
+ const anchor = lines ? (lines[2] ? `#L${lines[1]}-L${lines[2]}` : `#L${lines[1]}`) : "";
1025
+ const path = file.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
1026
+ // In <…>: a `)` in a path would otherwise end the destination.
1027
+ const url = `https://github.com/${repo}/blob/${commit}/${path}${anchor}`;
1028
+ const range = lines ? (lines[2] ? ` (lines ${lines[1]}–${lines[2]})` : ` (from line ${lines[1]})`) : "";
1029
+ const what = `${file}${range}${str("tag") ? ` (region "${str("tag")}")` : ""}`;
1030
+ onDegraded?.("GitHubCode", "a link to the source: the content is fetched at build time upstream");
1031
+ out += text.slice(last, tag.start) + `Code: [\`${what.replace(/`/g, "")}\` in ${repo}](<${url}>)`;
1032
+ last = tag.end;
1033
+ }
1034
+ return out + text.slice(last);
1035
+ });
1036
+ }
1037
+ // ── BuildAgentsCallout ──────────────────────────────────
1038
+ /**
1039
+ * `<AutoconfigDiagram framework configs={[…]}>` draws DATA — the framework,
1040
+ * the configuration Wrangler generates, Workers — so it becomes that sentence
1041
+ * and a File / Setting / Value table (nothing lost, not reported). Other
1042
+ * diagrams are illustrations: the CLI's `describeComponent` keeps the
1043
+ * description their component declares, reported as degraded.
1044
+ */
1045
+ export function resolveDiagrams(source) {
1046
+ if (!source.includes("<AutoconfigDiagram"))
1047
+ return source;
1048
+ return outsideFences(source, (text) => {
1049
+ for (const tag of findSelfClosingTags(text, "AutoconfigDiagram").reverse()) {
1050
+ const lineStart = text.lastIndexOf("\n", tag.start - 1) + 1;
1051
+ const indent = text.slice(lineStart, tag.start);
1052
+ const framework = tag.attrs.framework;
1053
+ const configs = typeof tag.attrs.configs === "string" && tag.attrs.configs.startsWith("{") ? parseJsLiteral(tag.attrs.configs.slice(1, -1)) : undefined;
1054
+ const rows = Array.isArray(configs)
1055
+ ? configs.filter((c) => !!c && typeof c === "object" && ["label", "file", "value"].every((k) => typeof c[k] === "string"))
1056
+ : [];
1057
+ // `framework={…}` is an expression (the attribute keeps its braces): not read.
1058
+ if (!/^[ \t]*$/.test(indent) || typeof framework !== "string" || framework.startsWith("{") || !Array.isArray(configs) || rows.length !== configs.length || !rows.length)
1059
+ continue;
1060
+ const md = [
1061
+ `Wrangler detects **${framework.replace(/[*_`[\]\\<]/g, "\\$&")}** and generates the configuration below, then deploys to Workers.`,
1062
+ "",
1063
+ "| File | Setting | Value |",
1064
+ "| --- | --- | --- |",
1065
+ ...rows.map((r) => `| ${codeCell(r.file.replace(/\s+/g, " "))} | ${codeCell(r.label.replace(/\s+/g, " "))} | ${codeCell(r.value.replace(/\s+/g, " "))} |`),
1066
+ ].join("\n");
1067
+ text = text.slice(0, tag.start) + `\n${indented(md, indent)}\n` + text.slice(tag.end);
1068
+ }
1069
+ return text;
1070
+ });
1071
+ }
1072
+ /** pretty-bytes' output for the sizes it is given here (base 1000, 3 significant digits). */
1073
+ function prettyBytes(n) {
1074
+ const units = ["B", "kB", "MB", "GB", "TB"];
1075
+ let i = 0;
1076
+ while (n >= 1000 && i < units.length - 1) {
1077
+ n /= 1000;
1078
+ i++;
1079
+ }
1080
+ return `${Number(n.toPrecision(3))} ${units[i]}`;
1081
+ }
1082
+ /**
1083
+ * `<TypeScriptExample omitTabs>` inside a `<Tabs>` contributes its tabs to
1084
+ * THAT group (upstream: JavaScript, generated from the TypeScript, then
1085
+ * TypeScript). Stripped like the plain form, it left a bare code block as a
1086
+ * direct child of the tab group, which broke the group. It becomes a
1087
+ * TypeScript tab; the JavaScript variant is not generated by the import.
1088
+ */
1089
+ export function resolveOmitTabsTypeScript(source) {
1090
+ if (!/<TypeScriptExample\b[^>]*\bomitTabs\b/.test(source))
1091
+ return source;
1092
+ // Line by line with fence tracking: the TypeScript fence sits BETWEEN the
1093
+ // opener and closer, so a per-chunk outsideFences pass never sees both.
1094
+ const lines = source.split("\n");
1095
+ let fence = null;
1096
+ let open = -1;
1097
+ for (let i = 0; i < lines.length; i++) {
1098
+ const line = lines[i];
1099
+ const f = /^[ \t]*(`{3,}|~{3,})/.exec(line);
1100
+ if (fence) {
1101
+ if (f && f[1][0] === fence[0] && f[1].length >= fence.length && line.trim() === f[1])
1102
+ fence = null;
1103
+ continue;
1104
+ }
1105
+ if (f) {
1106
+ fence = f[1];
1107
+ continue;
1108
+ }
1109
+ const opener = /^([ \t]*)<TypeScriptExample\b([^>]*)>[ \t]*$/.exec(line);
1110
+ if (opener) {
1111
+ // Any other TypeScriptExample opener ends a pending one: a stray
1112
+ // omitTabs opener must not pair with a later, unrelated closer.
1113
+ open = !opener[2].trimEnd().endsWith("/") && /\bomitTabs\b(?!\s*=\s*\{\s*false\s*\})/.test(opener[2]) ? i : -1;
1114
+ continue;
1115
+ }
1116
+ if (/^[ \t]*<\/Tabs>/.test(line))
1117
+ open = -1;
1118
+ const closer = /^([ \t]*)<\/TypeScriptExample>[ \t]*$/.exec(line);
1119
+ // The closer at the opener's indent.
1120
+ if (closer && open >= 0 && closer[1] === /^[ \t]*/.exec(lines[open])[0]) {
1121
+ lines[open] = `${/^[ \t]*/.exec(lines[open])[0]}<TabItem label="TypeScript">`;
1122
+ lines[i] = `${closer[1]}</TabItem>`;
1123
+ open = -1;
1124
+ }
1125
+ }
1126
+ return lines.join("\n");
1127
+ }
1128
+ /**
1129
+ * Three small components over LOCAL data, read at import:
1130
+ * - `<RSSButton href|changelog text?>` → a link (it renders one).
1131
+ * - `<WARPReleases track>` → the latest release open and the rest in a
1132
+ * collapsed history, from `src/content/warp-releases/<track>/*.yaml`.
1133
+ * Upstream keeps the releases of the year before NOW; an import keeps the
1134
+ * year before the LATEST release, so the output does not depend on the day
1135
+ * it runs. The Linux distribution picker becomes one link per distribution.
1136
+ * - `<CompatibilityFlags experimental?>` → each flag (`src/content/
1137
+ * compatibility-flags/*.md`) as a heading, its dates and flags, and its
1138
+ * body, newest first.
1139
+ * Anything unreadable leaves the tag, which is reported.
1140
+ */
1141
+ export function resolveLocalDataComponents(source, partialsDir) {
1142
+ if (!/<(RSSButton|WARPReleases|CompatibilityFlags|PagesBuildEnvironment\w*)\b/.test(source))
1143
+ return source;
1144
+ const content = partialsDir ? dirname(partialsDir) : undefined;
1145
+ const src = content ? dirname(content) : undefined;
1146
+ const warp = (track, indent) => {
1147
+ if (!content || !/^[a-z]+\/[a-z]+$/.test(track))
1148
+ return undefined;
1149
+ const dir = join(content, "warp-releases", track);
1150
+ let releases;
1151
+ try {
1152
+ const files = readdirSync(dir).filter((f) => /\.(ya?ml|json)$/.test(f));
1153
+ releases = files.map((f) => {
1154
+ // Symlinks resolved and contained: a corpus is untrusted input.
1155
+ const file = within(content, join(dir, f));
1156
+ if (!file)
1157
+ throw new Error("outside");
1158
+ const d = (f.endsWith(".json") ? JSON.parse(readFileSync(file, "utf8")) : readYaml(file));
1159
+ return { ...d, releaseDate: d.releaseDate instanceof Date ? d.releaseDate : new Date(String(d.releaseDate)) };
1160
+ });
1161
+ }
1162
+ catch {
1163
+ return undefined;
1164
+ }
1165
+ // Upstream's schema fails the build on a malformed release: leave the tag
1166
+ // (reported) rather than drop the entry silently.
1167
+ if (releases.some((r) => typeof r.version !== "string" || typeof r.releaseNotes !== "string" || typeof r.platformName !== "string" || Number.isNaN(r.releaseDate.getTime())))
1168
+ return undefined;
1169
+ releases.sort((a, b) => b.releaseDate.getTime() - a.releaseDate.getTime());
1170
+ const latest = releases[0];
1171
+ if (!latest)
1172
+ return undefined;
1173
+ const cutoff = new Date(latest.releaseDate);
1174
+ cutoff.setFullYear(cutoff.getFullYear() - 1);
1175
+ const shown = releases.filter((r) => r.releaseDate >= cutoff);
1176
+ // Notes are Markdown upstream (marked), but here they join the MDX
1177
+ // stream: anything that would be read as STRUCTURE (a heading, `:::`,
1178
+ // a tag, a brace expression) leaves the tag rather than reshape the page.
1179
+ const risky = /^\s*(:::|#{1,6}\s)|<\/?[A-Za-z]|[{}]/m;
1180
+ if (shown.some((r) => risky.test(outsideFences(r.releaseNotes, (t) => t.replace(/`[^`\n]*`/g, "")))))
1181
+ return undefined;
1182
+ let names = {};
1183
+ try {
1184
+ const list = JSON.parse(readFileSync(join(src, "util", "warp-platforms.json"), "utf8"));
1185
+ names = Object.fromEntries(list.map((p) => [p.platform, p.display_name]));
1186
+ }
1187
+ catch {
1188
+ // Platform ids are shown as they are.
1189
+ }
1190
+ const one = (r, header, open) => {
1191
+ const facts = [
1192
+ `**Version:** ${r.platformName} ${r.version}`,
1193
+ `**Date:** ${r.releaseDate.toISOString().split("T")[0]}`,
1194
+ ...(typeof r.packageSize === "number" ? [`**Size:** ${prettyBytes(r.packageSize)}`] : []),
1195
+ ].join(" · ");
1196
+ const linux = Object.keys(r.linuxPlatforms ?? {})
1197
+ .map((p) => [names[p] ?? p, p])
1198
+ .sort((a, b) => a[0].localeCompare(b[0]));
1199
+ const download = linux.length
1200
+ ? ["Download:", "", ...linux.map(([name, p]) => `- [${name}](https://downloads.cloudflareclient.com/v1/download/${p}/version/${r.version})`)]
1201
+ : r.packageURL
1202
+ ? [`[Download](${r.packageURL})`]
1203
+ : [];
1204
+ return [
1205
+ `<Details ${jsxAttr("header", header)}${open ? " open" : ""}>`,
1206
+ "",
1207
+ facts,
1208
+ "",
1209
+ ...download,
1210
+ "",
1211
+ // Not a heading: upstream's is an <h4> with no id, and 48 stamped
1212
+ // `{#release-notes}` ids on one page are duplicate DOM ids.
1213
+ "**Release notes**",
1214
+ "",
1215
+ r.releaseNotes.trim(),
1216
+ "",
1217
+ "</Details>",
1218
+ ].join("\n");
1219
+ };
1220
+ const md = [
1221
+ one(latest, "Latest release", true),
1222
+ "",
1223
+ `<Details header="Previous version history (${shown.length - 1})">`,
1224
+ "",
1225
+ ...shown.slice(1).flatMap((r) => [one(r, `${latest.platformName} ${r.version}`, false), ""]),
1226
+ "</Details>",
1227
+ ].join("\n");
1228
+ return indented(md, indent);
1229
+ };
1230
+ const flags = (experimental, indent) => {
1231
+ if (!content)
1232
+ return undefined;
1233
+ const dir = join(content, "compatibility-flags");
1234
+ let entries;
1235
+ try {
1236
+ entries = readdirSync(dir)
1237
+ .filter((f) => /\.mdx?$/.test(f))
1238
+ .map((f) => {
1239
+ const file = within(content, join(dir, f));
1240
+ if (!file)
1241
+ throw new Error("outside");
1242
+ const parsed = matter(readFileSync(file, "utf8"));
1243
+ // The body's own imports go (the page's are stripped the same way).
1244
+ const body = outsideFences(parsed.content, (t) => t.replace(/^import\s[^\n]*from\s*["'][^"']+["'];?[ \t]*$/gm, ""));
1245
+ return { data: parsed.data, body: body.trim() };
1246
+ });
1247
+ }
1248
+ catch {
1249
+ return undefined;
1250
+ }
1251
+ // A flag with no name: upstream fails the build; leave the tag (reported).
1252
+ if (entries.some((e) => typeof e.data.name !== "string"))
1253
+ return undefined;
1254
+ if (experimental)
1255
+ entries = entries.filter((e) => e.data.experimental === true);
1256
+ const date = (v) => (v instanceof Date ? v.toISOString().split("T")[0] : v == null ? "" : String(v));
1257
+ entries.sort((a, b) => new Date(date(b.data.sort_date)).getTime() - new Date(date(a.data.sort_date)).getTime());
1258
+ if (!entries.length)
1259
+ return undefined;
1260
+ const md = entries
1261
+ .map((e) => {
1262
+ const facts = [
1263
+ e.data.enable_date ? `- **Default as of:** ${date(e.data.enable_date)}` : "",
1264
+ e.data.enable_flag ? `- **Flag to enable:** ${codeCell(e.data.enable_flag)}` : "",
1265
+ e.data.disable_flag ? `- **Flag to disable:** ${codeCell(e.data.disable_flag)}` : "",
1266
+ ].filter(Boolean);
1267
+ return [anchorHeading(String(e.data.name), 3), "", ...(facts.length ? [...facts, ""] : []), e.body].join("\n");
1268
+ })
1269
+ .join("\n\n");
1270
+ return indented(md, indent);
1271
+ };
1272
+ // `<PagesBuildEnvironment{,Languages,Tools} />` — the Pages build image,
1273
+ // one tab per version (newest first, as upstream reverses the glob order),
1274
+ // from content/pages-build-environment/v*.yaml.
1275
+ const buildEnv = (tag, indent) => {
1276
+ if (!content)
1277
+ return undefined;
1278
+ const dir = join(content, "pages-build-environment");
1279
+ let versions;
1280
+ try {
1281
+ versions = readdirSync(dir)
1282
+ .filter((f) => /\.ya?ml$/.test(f))
1283
+ .sort()
1284
+ .map((f) => {
1285
+ const file = within(content, join(dir, f));
1286
+ if (!file)
1287
+ throw new Error("outside");
1288
+ return { id: f.replace(/\.ya?ml$/, ""), data: readYaml(file) };
1289
+ })
1290
+ .reverse();
1291
+ }
1292
+ catch {
1293
+ return undefined;
1294
+ }
1295
+ const cell = (v) => tableCell(v == null ? "" : String(v));
1296
+ const tab = (label, body) => [`<TabItem label="${label}">`, "", ...body, "", "</TabItem>"];
1297
+ const tabs = [];
1298
+ for (const { id, data } of versions) {
1299
+ if (tag === "PagesBuildEnvironment") {
1300
+ const env = data.build_environment;
1301
+ if (!env)
1302
+ continue; // upstream skips a version without one
1303
+ tabs.push(...tab(id, ["| | |", "| --- | --- |", `| **Build environment** | ${String(env.operating_system ?? "").replace(/\|/g, "\\|")} |`, `| **Architecture** | ${cell(env.architecture)} |`]));
1304
+ }
1305
+ else {
1306
+ const langs = tag === "PagesBuildEnvironmentLanguages";
1307
+ const rows = (langs ? data.languages : data.tools);
1308
+ if (!Array.isArray(rows))
1309
+ return undefined;
1310
+ tabs.push(...tab(id, [
1311
+ `| Tool | Default version | Supported versions | Environment variable |${langs ? " File |" : ""}`,
1312
+ `| --- | --- | --- | --- |${langs ? " --- |" : ""}`,
1313
+ ...rows.map((r) => `| **${cell(r.name)}** | ${cell(r.default)} | ${cell(r.supported)} | ${r.environment_variable ? codeCell(r.environment_variable) : ""} |` +
1314
+ (langs ? ` ${Array.isArray(r.file) ? r.file.map(String).map(tableCell).join(", ") : ""} |` : "")),
1315
+ ]));
1316
+ }
1317
+ tabs.push("");
1318
+ }
1319
+ if (!tabs.length)
1320
+ return undefined;
1321
+ return indented(["<Tabs>", "", ...tabs, "</Tabs>"].join("\n"), indent);
1322
+ };
1323
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => {
1324
+ text = text.replace(/^([ \t]*)<(PagesBuildEnvironment(?:Languages|Tools)?)\s*\/>[ \t]*$/gm, (whole, indent, tag) => {
1325
+ const md = buildEnv(tag, indent);
1326
+ return md === undefined ? whole : `\n${md}\n`;
1327
+ });
1328
+ text = text.replace(/<RSSButton\b([^>]*?)\/>/g, (whole, attrs) => {
1329
+ const a = (n) => new RegExp(`(?<![\\w-])${n}=["']([^"']*)["']`).exec(attrs)?.[1];
1330
+ // `changelog` wins over `href`, as upstream's schema reads them.
1331
+ const href = a("changelog") !== undefined ? `/changelog/rss/${a("changelog").toLowerCase().replaceAll(" ", "-")}.xml` : a("href");
1332
+ if (!href)
1333
+ return whole;
1334
+ return `[${(a("text") ?? "Subscribe to RSS").replace(/[[\]\\]/g, "\\$&")}](<${href}>)`;
1335
+ });
1336
+ return text.replace(/^([ \t]*)<(WARPReleases|CompatibilityFlags)\b([^>]*?)\/>[ \t]*$/gm, (whole, indent, tag, attrs) => {
1337
+ const md = tag === "WARPReleases"
1338
+ ? warp(/\btrack=["']([^"']*)["']/.exec(attrs)?.[1] ?? "", indent)
1339
+ : flags(/\bexperimental\b(?!\s*=\s*\{\s*false\s*\})/.test(attrs), indent);
1340
+ return md === undefined ? whole : `\n${md}\n`;
1341
+ });
1342
+ }));
1343
+ }
1344
+ /**
1345
+ * The value of `const NAME = [ … ]` / `{ … }` in a component or TS source,
1346
+ * read with the no-eval literal reader. Brackets are matched OUTSIDE strings
1347
+ * and comments (a `]` in a description must not end the array).
1348
+ */
1349
+ export function readConstLiteral(src, name) {
1350
+ const declRe = new RegExp(`const\\s+${name}\\b[^=]*=\\s*([[{])`, "g");
1351
+ const decls = [...src.matchAll(declRe)];
1352
+ // Two declarations (one commented out, say) is ambiguous: refuse rather
1353
+ // than read a stale one.
1354
+ if (decls.length !== 1)
1355
+ return undefined;
1356
+ const decl = decls[0];
1357
+ const start = decl.index + decl[0].length - 1;
1358
+ const open = decl[1];
1359
+ const close = open === "[" ? "]" : "}";
1360
+ let depth = 0;
1361
+ let end = start;
1362
+ let quote = null;
1363
+ for (; end < src.length; end++) {
1364
+ const ch = src[end];
1365
+ if (quote) {
1366
+ if (ch === "\\")
1367
+ end++;
1368
+ else if (ch === quote)
1369
+ quote = null;
1370
+ }
1371
+ else if (src.startsWith("//", end))
1372
+ end = src.indexOf("\n", end) < 0 ? src.length : src.indexOf("\n", end);
1373
+ else if (src.startsWith("/*", end))
1374
+ end = src.indexOf("*/", end) < 0 ? src.length : src.indexOf("*/", end) + 1;
1375
+ else if (ch === '"' || ch === "'" || ch === "`")
1376
+ quote = ch;
1377
+ else if (ch === open)
1378
+ depth++;
1379
+ else if (ch === close && --depth === 0)
1380
+ break;
1381
+ }
1382
+ // parseJsLiteral skips comments itself (prompts.ts has `// AI applications`).
1383
+ return parseJsLiteral(src.slice(start, end + 1));
1384
+ }
1385
+ /**
1386
+ * The agent-setup pages (8): `<AgentHeader slug>`, `<OtherAgents currentSlug>`,
1387
+ * `<ExamplePromptsList />`, `<RandomPrompt />` and `<PlatformAccess />`, from
1388
+ * `components/agent-setup/{agents,prompts,definitions}.ts` and the section's
1389
+ * own markup, read — never run. Upstream picks prompts at RANDOM per build;
1390
+ * an import picks deterministically (the first ones). The skills and MCP
1391
+ * server lists inside `<PlatformAccess>` are fetched at build time upstream,
1392
+ * so they become links to where those lists live (reported as degraded).
1393
+ */
1394
+ export function resolveAgentSetup(source, partialsDir, onDegraded) {
1395
+ if (!partialsDir || !/<(AgentHeader|OtherAgents|ExamplePromptsList|RandomPrompt|PlatformAccess)\b/.test(source))
1396
+ return source;
1397
+ const dir = join(dirname(dirname(partialsDir)), "components", "agent-setup");
1398
+ const read = (f) => {
1399
+ try {
1400
+ return readFileSync(join(dir, f), "utf8");
1401
+ }
1402
+ catch {
1403
+ return "";
1404
+ }
1405
+ };
1406
+ const agentsRaw = readConstLiteral(read("agents.ts"), "AGENTS");
1407
+ // Only well-formed entries: a missing field must not print "undefined".
1408
+ const isStr = (v) => typeof v === "string";
1409
+ const agents = (Array.isArray(agentsRaw) ? agentsRaw : []).filter((a) => a && isStr(a.name) && isStr(a.vendor) && isStr(a.slug) && isStr(a.description));
1410
+ const promptsRaw = readConstLiteral(read("prompts.ts"), "SHARED_PROMPTS");
1411
+ const prompts = Array.isArray(promptsRaw) && promptsRaw.every(isStr) ? promptsRaw : [];
1412
+ // Upstream picks at random per build. Deterministically: five spread over
1413
+ // the list (not five of one group), and a RandomPrompt that is not one of them.
1414
+ const examples = [...new Set(Array.from({ length: Math.min(5, prompts.length) }, (_, i) => Math.floor((i * prompts.length) / Math.min(5, prompts.length))))].map((i) => prompts[i]);
1415
+ const single = prompts.length ? (prompts.find((p) => !examples.includes(p)) ?? prompts[0]) : undefined;
1416
+ const labelsRaw = readConstLiteral(read("definitions.ts"), "capabilityLabels");
1417
+ const labels = labelsRaw && typeof labelsRaw === "object" ? labelsRaw : {};
1418
+ const txt = (p) => ["```txt", p, "```"].join("\n");
1419
+ const header = (slug) => {
1420
+ const a = agents.find((x) => x.slug === slug);
1421
+ if (!a)
1422
+ return undefined;
1423
+ const caps = Object.entries(a.capabilities ?? {}).filter(([, v]) => v).map(([k]) => labels[k] ?? k);
1424
+ const l = Object.fromEntries(Object.entries(a.links ?? {}).filter(([, v]) => isStr(v)));
1425
+ const links = [
1426
+ l.skills && `[Cloudflare Skills](${l.skills})`,
1427
+ l.mcp_server && `[Cloudflare Code Mode API MCP](${l.mcp_server})`,
1428
+ l.mcp_server_domain && `[Cloudflare Domain Specific MCPs](${l.mcp_server_domain})`,
1429
+ l.cli && `[CLI](${l.cli})`,
1430
+ l.docs && `[${a.name} Docs](${l.docs})`,
1431
+ ].filter(Boolean);
1432
+ return [
1433
+ "[← All agents](/agent-setup/)",
1434
+ "",
1435
+ `**${a.vendor}**`,
1436
+ "",
1437
+ a.description,
1438
+ ...(caps.length ? ["", caps.join(" · ")] : []),
1439
+ ...(links.length ? ["", links.join(" · ")] : []),
1440
+ ].join("\n");
1441
+ };
1442
+ const others = (slug) => {
1443
+ const rest = agents.filter((x) => x.slug !== slug);
1444
+ if (!rest.length)
1445
+ return undefined;
1446
+ return rest
1447
+ .map((a) => `<LinkCard ${jsxAttr("title", a.vendor && a.vendor !== a.name ? `${a.name} (${a.vendor})` : a.name)} ${jsxAttr("href", `/agent-setup/${a.slug}/`)} ${jsxAttr("description", a.description)} />`)
1448
+ .join("\n\n");
1449
+ };
1450
+ // A component of this folder rendered for its props (prop conditionals,
1451
+ // ternaries, and ./Local.astro sub-components, recursively) — read, never
1452
+ // run. The section's markup gained `{showCliSelector && …}` and a
1453
+ // `<PlatformCliDetails cli="wrangler" />` sub-component upstream; the
1454
+ // literal-markup reader refused it (10 pages reported).
1455
+ const KEEP_AS_TAGS = new Set(["PlatformAccessDetails", "SkillsList", "McpServerList"]);
1456
+ const renderLocal = (file, attrs, depth = 0) => {
1457
+ const src = read(file);
1458
+ if (!src)
1459
+ return undefined;
1460
+ const fmEnd = src.startsWith("---") ? src.indexOf("\n---", 3) : -1;
1461
+ const fm = fmEnd < 0 ? "" : src.slice(3, fmEnd);
1462
+ const defaults = componentPropDefaults(fm);
1463
+ if (!defaults)
1464
+ return undefined;
1465
+ // Same-folder `./X.astro` only: a nested path would resolve against this folder, not its own.
1466
+ const imports = new Map([...fm.matchAll(/import\s+(\w+)\s+from\s+["']\.\/([^/"']+\.astro)["']/g)].map((m) => [m[1], m[2]]));
1467
+ return renderComponentTemplate(src.slice(fmEnd < 0 ? 0 : fmEnd + 4), { ...defaults, ...attrs }, (tag, a) => {
1468
+ if (tag === "CornerMarks")
1469
+ return ""; // decorative
1470
+ if (KEEP_AS_TAGS.has(tag))
1471
+ return undefined;
1472
+ const f = imports.get(tag);
1473
+ return f && depth < 3 ? renderLocal(f, a, depth + 1) : undefined;
1474
+ });
1475
+ };
1476
+ const platformAccess = () => {
1477
+ const template = renderLocal("PlatformAccessSection.astro", {});
1478
+ if (template === undefined)
1479
+ return undefined;
1480
+ const sectionRe = /<PlatformAccessDetails\s+header="([^"]*)"\s*>([\s\S]*?)<\/PlatformAccessDetails>/g;
1481
+ const sections = [...template.matchAll(sectionRe)];
1482
+ // Everything must be accounted for: a section with another attribute
1483
+ // (`open`), or content between sections, would otherwise vanish.
1484
+ if (!sections.length || (template.match(/<PlatformAccessDetails\b/g) ?? []).length !== sections.length)
1485
+ return undefined;
1486
+ const before = template.slice(0, sections[0].index);
1487
+ const introP = /<p\b[^>]*>([\s\S]*?)<\/p>/.exec(before);
1488
+ const leftover = template
1489
+ .replace(sectionRe, "")
1490
+ .replace(introP?.[0] ?? "\u0000", "")
1491
+ .replace(/<\/?div\b[^>]*>|<CornerMarks\s*\/>/g, "")
1492
+ .trim();
1493
+ if (leftover)
1494
+ return undefined;
1495
+ const intro = introP?.[1].replace(/\s+/g, " ").trim();
1496
+ const degradedHere = [];
1497
+ const html = (body) => body
1498
+ // The lead line is emphasised upstream (by class): keep it as bold.
1499
+ .replace(/<p\s+class="[^"]*\bfont-medium\b[^"]*"\s*>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>")
1500
+ .replace(/\s+class=("[^"]*"|\{[^}]*\})/g, "")
1501
+ .replace(/\{" "\}/g, " ")
1502
+ .replace(/<SkillsList\s*\/>/g, () => {
1503
+ degradedHere.push("SkillsList");
1504
+ return '<p>The skills are listed in <a href="https://github.com/cloudflare/skills">cloudflare/skills</a>.</p>';
1505
+ })
1506
+ .replace(/<McpServerList\b[^>]*\/>/g, () => {
1507
+ degradedHere.push("McpServerList");
1508
+ return '<p>The servers are listed in <a href="/agents/model-context-protocol/cloudflare/servers-for-cloudflare/">MCP servers for Cloudflare</a>.</p>';
1509
+ })
1510
+ .replace(/\s+/g, " ")
1511
+ .replace(/\s*(<\/?(?:p|ul|li|aside|strong)\b[^>]*>)\s*/g, "$1")
1512
+ .replace(/<(\w+) >/g, "<$1>")
1513
+ .trim();
1514
+ // Once per section: html() records the degraded lists, reported only on success.
1515
+ const bodies = sections.map(([, title, body]) => [title, html(body)]);
1516
+ const unreadable = /[{}`]|<[A-Z]|<pre\b/;
1517
+ if (bodies.some(([, body]) => unreadable.test(body)) || (intro && unreadable.test(intro)))
1518
+ return undefined;
1519
+ for (const t of degradedHere)
1520
+ onDegraded?.(t, "a link to where the list lives: it is fetched at build time upstream");
1521
+ return [
1522
+ ...(intro ? [intro, ""] : []),
1523
+ ...bodies.flatMap(([title, body]) => [`<PlatformAccessDetails ${jsxAttr("header", title)}>`, "", body, "", "</PlatformAccessDetails>", ""]),
1524
+ ]
1525
+ .join("\n")
1526
+ .trimEnd();
1527
+ };
1528
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/^([ \t]*)<(AgentHeader|OtherAgents|ExamplePromptsList|RandomPrompt|PlatformAccess)\b([^>]*?)\/>[ \t]*$/gm, (whole, indent, tag, attrs) => {
1529
+ const attr = (n) => new RegExp(`\\b${n}=["']([^"']*)["']`).exec(attrs)?.[1];
1530
+ const md = tag === "AgentHeader"
1531
+ ? header(attr("slug") ?? "")
1532
+ : tag === "OtherAgents"
1533
+ ? others(attr("currentSlug") ?? "")
1534
+ : tag === "ExamplePromptsList"
1535
+ ? examples.length
1536
+ ? examples.map(txt).join("\n\n")
1537
+ : undefined
1538
+ : tag === "RandomPrompt"
1539
+ ? single !== undefined
1540
+ ? txt(single)
1541
+ : undefined
1542
+ : // Attributes on the page (`showCliSelector`) would change what renders: refused, not ignored.
1543
+ attrs.trim()
1544
+ ? undefined
1545
+ : platformAccess();
1546
+ return md === undefined ? whole : `\n${indented(md, indent)}\n`;
1547
+ })));
1548
+ }
1549
+ /**
1550
+ * `<BuildAgentsCallout />` → its intro sentence and its cards, read from the
1551
+ * component's own source (`const cards = [ … ]`, by the no-eval literal
1552
+ * reader; the sentence is its first `<p>`). A component that cannot be read
1553
+ * leaves the tag, which is reported.
1554
+ */
1555
+ export function resolveBuildAgentsCallout(source, partialsDir) {
1556
+ if (!source.includes("<BuildAgentsCallout") || !partialsDir)
1557
+ return source;
1558
+ const file = join(dirname(dirname(partialsDir)), "components", "agent-setup", "BuildAgentsCallout.astro");
1559
+ let md;
1560
+ try {
1561
+ const src = readFileSync(file, "utf8");
1562
+ const cards = readConstLiteral(src, "cards");
1563
+ // The template starts after the frontmatter's CLOSING fence.
1564
+ const fmEnd = src.startsWith("---") ? src.indexOf("\n---", 3) : -1;
1565
+ const intro = /<p\b[^>]*>([\s\S]*?)<\/p>/.exec(src.slice(fmEnd < 0 ? 0 : fmEnd + 4))?.[1].replace(/\s+/g, " ").trim();
1566
+ if (Array.isArray(cards) && cards.length) {
1567
+ md = [
1568
+ ...(intro && !intro.includes("{") ? [intro, ""] : []),
1569
+ ...cards.flatMap((c) => {
1570
+ const card = c;
1571
+ return [`<LinkCard ${jsxAttr("title", String(card.title ?? ""))} ${jsxAttr("href", String(card.href ?? ""))} ${jsxAttr("description", String(card.desc ?? ""))} />`, ""];
1572
+ }),
1573
+ ].join("\n").trimEnd();
1574
+ }
1575
+ }
1576
+ catch {
1577
+ md = undefined;
1578
+ }
1579
+ if (!md)
1580
+ return source;
1581
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/^([ \t]*)<BuildAgentsCallout\b[^>]*\/>/gm, (_w, indent) => `\n${indented(md, indent)}\n`)));
1582
+ }
1583
+ const changelogCache = new Map();
1584
+ /**
1585
+ * `<Image src={x} alt="…" />` → `![alt](path)`, where `x` is an image the
1586
+ * source IMPORTS (`import x from "~/assets/…png"`). A relative import is
1587
+ * resolved against `file` and, under an `assets/` folder, rewritten to the `~/assets`
1588
+ * alias (which becomes `/_assets` later). Unresolvable ones are left.
1589
+ */
1590
+ export function resolveImageImports(source, file) {
1591
+ if (!source.includes("<Image"))
1592
+ return source;
1593
+ const images = new Map();
1594
+ for (const m of source.matchAll(/^import\s+(\w+)\s+from\s+["']([^"']+\.(?:png|jpe?g|gif|svg|webp|avif))["'];?\s*$/gm)) {
1595
+ let path = m[2];
1596
+ if (path.startsWith(".") && file) {
1597
+ const abs = join(dirname(file), path);
1598
+ const at = abs.lastIndexOf("/assets/");
1599
+ path = at >= 0 ? `~/assets/${abs.slice(at + "/assets/".length)}` : path;
1600
+ }
1601
+ images.set(m[1], path);
1602
+ }
1603
+ if (!images.size)
1604
+ return source;
1605
+ return outsideFences(source, (text) => text.replace(/<Image\b([^>]*?)\/>/g, (whole, attrs) => {
1606
+ const name = /\bsrc\s*=\s*\{\s*(\w+)\s*\}/.exec(attrs)?.[1];
1607
+ const path = name ? images.get(name) : undefined;
1608
+ return path ? `![${(attr(attrs, "alt") ?? "").replace(/[[\]]/g, "")}](${path})` : whole;
1609
+ }));
1610
+ }
1611
+ /**
1612
+ * An entry's MDX body for splicing into a page: image imports resolved, then
1613
+ * its `import` statements removed OUTSIDE code (samples keep theirs: 154
1614
+ * import lines in 67 entries' fences were deleted by a plain regex), and a
1615
+ * fence it leaves open CLOSED — upstream compiles each entry alone, so an
1616
+ * unclosed fence ends with it instead of swallowing the entries after it.
1617
+ */
1618
+ function entryBody(content, file) {
1619
+ const withImages = resolveImageImports(content, file);
1620
+ const stripped = outsideFences(withImages, (text) => text.replace(/^import\s+(?:[\s\S]*?\s+from\s+)?["'][^"'\n]+["'];?[ \t]*$/gm, ""));
1621
+ let fence = null;
1622
+ for (const line of stripped.split("\n")) {
1623
+ const m = /^[ \t]*(`{3,}|~{3,})/.exec(line);
1624
+ if (!m)
1625
+ continue;
1626
+ if (fence) {
1627
+ if (m[1][0] === fence[0] && m[1].length >= fence.length && line.trim() === m[1])
1628
+ fence = null;
1629
+ }
1630
+ else if (!(m[1][0] === "`" && line.slice(line.indexOf(m[1]) + m[1].length).includes("`")))
1631
+ fence = m[1];
1632
+ }
1633
+ return (fence ? `${stripped.trimEnd()}\n${fence}` : stripped).trim();
1634
+ }
1635
+ /** `src/content/changelog/<product>/*.mdx`; each entry also belongs to its folder's product, as upstream adds. */
1636
+ function loadChangelog(dir) {
1637
+ const hit = changelogCache.get(dir);
1638
+ if (hit)
1639
+ return hit;
1640
+ const out = [];
1641
+ if (existsSync(dir)) {
1642
+ for (const folder of readdirSync(dir).sort()) {
1643
+ const sub = join(dir, folder);
1644
+ let files;
1645
+ try {
1646
+ files = readdirSync(sub).filter((f) => f.endsWith(".mdx") || f.endsWith(".md"));
1647
+ }
1648
+ catch {
1649
+ continue;
1650
+ }
1651
+ for (const file of files.sort()) {
1652
+ try {
1653
+ const parsed = matter(readFileSync(join(sub, file), "utf8"));
1654
+ const fm = parsed.data;
1655
+ const d = fm.date instanceof Date ? fm.date : new Date(String(fm.date ?? ""));
1656
+ if (Number.isNaN(d.getTime()))
1657
+ continue;
1658
+ const products = (Array.isArray(fm.products) ? fm.products : []).map((p) => String(p));
1659
+ if (!products.includes(folder))
1660
+ products.push(folder);
1661
+ out.push({
1662
+ slug: file.replace(/\.mdx?$/, ""),
1663
+ title: String(fm.title ?? ""),
1664
+ date: d.toISOString().slice(0, 10),
1665
+ time: d.getTime(),
1666
+ products,
1667
+ hidden: fm.hidden === true,
1668
+ futureOk: fm.publish_future_dated_entry === true,
1669
+ body: entryBody(parsed.content, join(sub, file)),
1670
+ });
1671
+ }
1672
+ catch {
1673
+ /* unreadable entry: skipped */
1674
+ }
1675
+ }
1676
+ }
1677
+ }
1678
+ changelogCache.set(dir, out);
1679
+ return out;
1680
+ }
1681
+ /**
1682
+ * `<ProductChangelog product="x" hideEntry publish_future_dated_entry numberOfEntries />`
1683
+ * (or `area="Group"`, via the product directory's `entry.group`) → the
1684
+ * product's changelog entries, newest first: `## date`, **title**, and the
1685
+ * entry's MDX body spliced in, so its components are resolved like the
1686
+ * page's own (this runs first in resolveIncludes). As upstream: hidden entries
1687
+ * and future-dated ones are left out, `hideEntry` names one to skip. Not
1688
+ * carried: the RSS button, per-entry product pills, WARP release entries.
1689
+ */
1690
+ export function resolveProductChangelog(source, partialsDir) {
1691
+ if (!source.includes("<ProductChangelog") || !partialsDir)
1692
+ return source;
1693
+ const content = dirname(partialsDir);
1694
+ const entries = loadChangelog(join(content, "changelog"));
1695
+ if (!entries.length)
1696
+ return source;
1697
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/^([ \t]*)<ProductChangelog\b([^>]*?)\/?>(?:\s*<\/ProductChangelog>)?/gm, (whole, indent, attrs) => {
1698
+ const product = attr(attrs, "product");
1699
+ const area = attr(attrs, "area");
1700
+ const hide = attr(attrs, "hideEntry");
1701
+ const future = /\bpublish_future_dated_entry\b(?!\s*=\s*\{\s*false\s*\})/.test(attrs);
1702
+ const limit = Number(/\bnumberOfEntries\s*=\s*\{\s*(\d+)\s*\}/.exec(attrs)?.[1] ?? NaN);
1703
+ let ids;
1704
+ if (product)
1705
+ ids = new Set([product]);
1706
+ else if (area) {
1707
+ ids = new Set();
1708
+ const dir = join(content, "directory");
1709
+ if (existsSync(dir)) {
1710
+ for (const f of readdirSync(dir).filter((x) => /\.ya?ml$/.test(x))) {
1711
+ const d = cachedYaml(join(dir, f));
1712
+ const id = f.replace(/\.ya?ml$/, "");
1713
+ if (d?.entry?.group === area && id !== hide)
1714
+ ids.add(id);
1715
+ }
1716
+ }
1717
+ }
1718
+ if (!ids)
1719
+ return whole;
1720
+ const now = Date.now();
1721
+ let list = entries.filter((e) => e.products.some((p) => ids.has(p)) &&
1722
+ (area || (!e.hidden && e.slug !== hide && e.futureOk === future)) &&
1723
+ (e.futureOk || e.time <= now));
1724
+ list = list.sort((a, b) => b.time - a.time);
1725
+ if (Number.isFinite(limit) && limit > 0)
1726
+ list = list.slice(0, limit);
1727
+ const out = list.length
1728
+ ? list.flatMap((e) => [anchorHeading(e.date, 2), "", ...(e.title ? [`**${e.title}**`, ""] : []), e.body, ""])
1729
+ : ["There are no scheduled entries at this time.", ""];
1730
+ return `\n${indented(out.join("\n").trimEnd(), indent)}\n`;
1731
+ })));
1732
+ }
1733
+ /**
1734
+ * `<ProductReleaseNotes />` → the page's release notes (its front matter
1735
+ * `release_notes_file_name`, from `release-notes/<name>.yaml`), newest date
1736
+ * first, as ProductReleaseNotes.astro renders. An entry with its own page
1737
+ * links to it: upstream EMBEDS that page's body, which would duplicate a page
1738
+ * the site already has. `api-deprecations` is its own file, as upstream.
1739
+ */
1740
+ export function resolveReleaseNotes(source, partialsDir, frontmatter) {
1741
+ if (!source.includes("<ProductReleaseNotes"))
1742
+ return source;
1743
+ const names = frontmatter?.release_notes_file_name;
1744
+ const name = Array.isArray(names) && names.length === 1 ? String(names[0]) : undefined;
1745
+ if (!name || !partialsDir || /[\\/]|\.\./.test(name))
1746
+ return source;
1747
+ const data = cachedYaml(join(dirname(partialsDir), "release-notes", `${name}.yaml`));
1748
+ const entries = data?.entries;
1749
+ if (!Array.isArray(entries))
1750
+ return source;
1751
+ const byDate = new Map();
1752
+ for (const e of entries) {
1753
+ const d = String(e.publish_date ?? "");
1754
+ if (!byDate.has(d))
1755
+ byDate.set(d, []);
1756
+ byDate.get(d).push(e);
1757
+ }
1758
+ const out = [];
1759
+ for (const [date, list] of [...byDate.entries()].sort((a, b) => b[0].localeCompare(a[0]))) {
1760
+ out.push(anchorHeading(date, 2), "");
1761
+ for (const e of list) {
1762
+ if (e.individual_page && e.link) {
1763
+ out.push(`**[${e.title ?? e.link}](${e.link})**`, "");
1764
+ }
1765
+ else {
1766
+ if (e.title)
1767
+ out.push(`**${e.title}**`, "");
1768
+ if (e.description)
1769
+ out.push(String(e.description).trim(), "");
1770
+ }
1771
+ }
1772
+ }
1773
+ const md = out.join("\n").trimEnd();
1774
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text.replace(/^([ \t]*)<ProductReleaseNotes\b[^>]*?\/>/gm, (_whole, indent) => indented(md, indent))));
1775
+ }
1776
+ const glossaryCache = new Map();
1777
+ /**
1778
+ * `src/content/glossary/<product>.yaml` (a sibling of the partials folder):
1779
+ * `{ productName, entries: [{ term, general_definition }] }`, keyed by the
1780
+ * file's name, which is what `<Glossary product=…>` names.
1781
+ */
1782
+ function loadGlossary(partialsDir) {
1783
+ const dir = partialsDir ? join(dirname(partialsDir), "glossary") : "";
1784
+ const hit = glossaryCache.get(dir);
1785
+ if (hit)
1786
+ return hit;
1787
+ const out = new Map();
1788
+ if (dir && existsSync(dir)) {
1789
+ for (const file of readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).sort()) {
1790
+ try {
1791
+ // gray-matter's YAML engine, rather than a new dependency.
1792
+ // The files open with a YAML `---` document marker of their own; left
1793
+ // in, the wrapper read as EMPTY front matter and every term was lost.
1794
+ const data = readYaml(join(dir, file));
1795
+ const product = String(data.productName ?? file.replace(/\.ya?ml$/, ""));
1796
+ out.set(file.replace(/\.ya?ml$/, ""), (data.entries ?? [])
1797
+ .filter((e) => typeof e.term === "string" && typeof e.general_definition === "string")
1798
+ .map((e) => ({ term: e.term, definition: e.general_definition.trim(), product })));
1799
+ }
1800
+ catch {
1801
+ /* an unreadable file contributes nothing; its terms stay unresolved and reported */
1802
+ }
1803
+ }
1804
+ }
1805
+ glossaryCache.set(dir, out);
1806
+ return out;
1807
+ }
1808
+ /** As the component does: the prepend, then the definition, first letter upper-cased. */
1809
+ function glossaryText(entry, prepend) {
1810
+ const text = (prepend ?? "") + entry.definition;
1811
+ return text.charAt(0).toUpperCase() + text.slice(1);
1812
+ }
1813
+ const attr = (attrs, name) => new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attrs)?.slice(1).find((v) => v !== undefined);
1814
+ /**
1815
+ * Cloudflare's glossary components, from the glossary data at import:
1816
+ * - `<GlossaryDefinition term prepend>` → the definition itself;
1817
+ * - `<Glossary product>` → a table of terms (a Product column when unfiltered);
1818
+ * - `<GlossaryTooltip term link>text</…>` → the text, linked when `link` is a
1819
+ * string. The tooltip itself cannot travel to a static page.
1820
+ * An unknown term leaves the tag as written, so it is REPORTED as unmapped
1821
+ * rather than silently becoming nothing. Code is left alone.
1822
+ */
1823
+ export function resolveGlossary(source, partialsDir) {
1824
+ if (!/<Glossary/.test(source))
1825
+ return source;
1826
+ const glossary = loadGlossary(partialsDir);
1827
+ const byTerm = new Map();
1828
+ for (const entries of glossary.values())
1829
+ for (const e of entries)
1830
+ if (!byTerm.has(e.term))
1831
+ byTerm.set(e.term, e);
1832
+ return outsideFences(source, (block) => outsideInlineCode(block, (text) => text
1833
+ // The line's own prefix — indent, and `> ` for a blockquote — repeats on
1834
+ // every line of a multi-line definition, or it falls out of the quote.
1835
+ .replace(/^([ \t]*(?:>[ \t]?)*)<GlossaryDefinition\b([^>]*?)\/>/gm, (whole, prefix, attrs) => {
1836
+ const entry = byTerm.get(attr(attrs, "term") ?? "");
1837
+ if (!entry)
1838
+ return whole;
1839
+ return prefix + glossaryText(entry, attr(attrs, "prepend")).replace(/\n/g, `\n${prefix}`);
1840
+ })
1841
+ .replace(/<GlossaryDefinition\b([^>]*?)\/>/g, (whole, attrs) => {
1842
+ const entry = byTerm.get(attr(attrs, "term") ?? "");
1843
+ return entry ? glossaryText(entry, attr(attrs, "prepend")).replace(/\s*\n\s*/g, " ") : whole;
1844
+ })
1845
+ .replace(/^([ \t]*)<Glossary\b([^>]*?)\/>/gm, (whole, indent, attrs) => {
1846
+ const product = attr(attrs, "product");
1847
+ const rows = product
1848
+ ? (glossary.get(product) ?? []).map((e) => ({ ...e, product: "" }))
1849
+ : [...glossary.values()].flat();
1850
+ if (!rows.length)
1851
+ return whole;
1852
+ // A table cell is one line: paragraphs and list items keep apart with
1853
+ // <br> (upstream renders real <p>/<ul> there; collapsing them to spaces
1854
+ // ran "…in time. - Bookmarks are…" together).
1855
+ const cell = tableCell;
1856
+ rows.sort((a, b) => a.term.localeCompare(b.term));
1857
+ const head = product ? ["| Term | Definition |", "| --- | --- |"] : ["| Term | Definition | Product |", "| --- | --- | --- |"];
1858
+ const body = rows.map((e) => product ? `| ${cell(e.term)} | ${cell(glossaryText(e))} |` : `| ${cell(e.term)} | ${cell(glossaryText(e))} | ${cell(e.product)} |`);
1859
+ return `\n${[...head, ...body].map((l) => indent + l).join("\n")}\n`;
1860
+ })
1861
+ // `</ GlossaryTooltip>` (a space after the slash) is valid JSX; 2 corpus uses.
1862
+ // An opener with a `term=`, and a body holding no other opener: a bare
1863
+ // tag name in prose is not the start of a tooltip.
1864
+ .replace(/<GlossaryTooltip\b(?=[^>]*\bterm\s*=)([^>]*)>((?:(?!<GlossaryTooltip\b)[\s\S])*?)<\/\s*GlossaryTooltip\s*>/g, (whole, attrs, inner) => {
1865
+ // An unknown term stays as written, so it is reported (upstream throws).
1866
+ if (!byTerm.has(attr(attrs, "term") ?? ""))
1867
+ return whole;
1868
+ const link = attr(attrs, "link");
1869
+ return link ? `[${inner}](${link})` : inner;
1870
+ })));
1871
+ }
1872
+ // ── APIRequest ──────────────────────────────────────────
1873
+ /**
1874
+ * `<APIRequest path method parameters json form />` → the curl command
1875
+ * Cloudflare's component renders (APIRequest.astro + CURL.astro), built
1876
+ * from the tag's own props. The component also fetches the OpenAPI schema
1877
+ * over the network at build time, for two things we therefore do not
1878
+ * produce: the "Required API token permissions" panel, and the block title
1879
+ * (the operation summary). An import does not reach the network.
1880
+ *
1881
+ * Without the schema, a parameter is a PATH parameter when the path names it
1882
+ * (`{zone_id}`) and a query parameter otherwise; unfilled path parameters
1883
+ * become `$ZONE_ID`, as upstream writes them; auth is the API token (what
1884
+ * nearly every operation takes). A tag whose props cannot be READ (a
1885
+ * variable, a spread) is left as written and reported, never guessed.
1886
+ *
1887
+ * With the schema (`--api-schema <openapi.json>`, a local copy — the import
1888
+ * still does not fetch): the operation summary titles the block, the auth
1889
+ * headers follow the operation's security (email + key where it takes no
1890
+ * token), and `roles` adds the "Required API token permissions" panel,
1891
+ * filtered by a `roles="…"` string as upstream filters it.
1892
+ */
1893
+ export function resolveApiRequest(source, onApiRequest, schema, onMissing) {
1894
+ if (!source.includes("<APIRequest"))
1895
+ return source;
1896
+ return outsideFences(source, (text) => {
1897
+ const tags = findSelfClosingTags(text, "APIRequest");
1898
+ if (!tags.length)
1899
+ return text;
1900
+ let out = "";
1901
+ let last = 0;
1902
+ for (const tag of tags) {
1903
+ const pathAttr = typeof tag.attrs.path === "string" && !tag.attrs.path.startsWith("{") ? tag.attrs.path : undefined;
1904
+ const methodAttr = typeof tag.attrs.method === "string" && !tag.attrs.method.startsWith("{") ? tag.attrs.method : undefined;
1905
+ const key = pathAttr && methodAttr ? `${methodAttr.toUpperCase()} /${pathAttr.replace(/^\//, "")}` : undefined;
1906
+ const op = schema && key ? schema.get(key) : undefined;
1907
+ if (schema && key && !op)
1908
+ onMissing?.(key);
1909
+ const curl = apiRequestCurl(tag.attrs, op);
1910
+ const lineStart = text.lastIndexOf("\n", tag.start - 1) + 1;
1911
+ const indent = text.slice(lineStart, tag.start);
1912
+ const lineEnd = text.indexOf("\n", tag.end);
1913
+ const rest = text.slice(tag.end, lineEnd < 0 ? text.length : lineEnd);
1914
+ // Alone on its line: text after `/>` would sit on the closing fence,
1915
+ // which then does not close, and the block swallows the page.
1916
+ if (curl === undefined || !/^[ \t]*$/.test(indent) || !/^[ \t]*$/.test(rest)) {
1917
+ onApiRequest?.(false);
1918
+ continue;
1919
+ }
1920
+ onApiRequest?.(true);
1921
+ // The fence info is read raw (`title="…"`, no unescaping): the
1922
+ // characters that would break it are dropped, never escaped.
1923
+ const title = op?.summary?.replace(/["`{}\\\n\r\t]/g, "").trim();
1924
+ const fence = title ? `\`\`\`bash title="${title}"` : "```bash";
1925
+ // Inside raw HTML (`<details><summary>API example</summary>` around the
1926
+ // tag), a `<Details>` component is not parsed: it leaked as a tag. There
1927
+ // the panel is raw HTML itself, on ONE line (indentation cannot break it).
1928
+ const before = text.slice(0, tag.start).replace(/\s+$/, "");
1929
+ const inHtml = /<\/?[a-z][\w-]*\b[^<>]*>$/.test(before.slice(before.lastIndexOf("\n") + 1).trim());
1930
+ const panel = op ? permissionsPanel(op, tag.attrs.roles, inHtml) : undefined;
1931
+ const block = [...(panel ? [...panel.split("\n"), ""] : []), fence, ...curl.split("\n"), "```"].map((l, k) => (k === 0 || !l ? l : indent + l)).join("\n");
1932
+ out += text.slice(last, tag.start) + block;
1933
+ last = tag.end;
1934
+ }
1935
+ return out + text.slice(last);
1936
+ });
1937
+ }
1938
+ /**
1939
+ * Upstream: `roles` defaults to TRUE — the panel shows unless `roles={false}`
1940
+ * (or `""`); a string filters the groups. (It was read the other way round:
1941
+ * 430 corpus tags lost their panel and 54 `roles={false}` tags gained one.)
1942
+ */
1943
+ function permissionsPanel(op, roles, html = false) {
1944
+ if (!op.tokenGroups?.length)
1945
+ return undefined;
1946
+ const r = typeof roles === "string" ? roles.trim() : roles;
1947
+ if (r === "" || (typeof r === "string" && /^\{\s*(false|null|undefined|""|'')\s*\}$/.test(r)))
1948
+ return undefined;
1949
+ const filter = typeof r === "string" && !r.startsWith("{") ? r.toLowerCase() : undefined;
1950
+ const groups = filter ? op.tokenGroups.filter((g) => g.toLowerCase().includes(filter)) : op.tokenGroups;
1951
+ if (html) {
1952
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
1953
+ return `<details><summary>Required API token permissions</summary><p>At least one of the following <a href="/fundamentals/api/reference/permissions/">token permissions</a> is required:</p><ul>${groups.map((g) => `<li><code>${esc(g)}</code></li>`).join("")}</ul></details>`;
1954
+ }
1955
+ return [
1956
+ '<Details header="Required API token permissions">',
1957
+ "",
1958
+ "At least one of the following [token permissions](/fundamentals/api/reference/permissions/) is required:",
1959
+ "",
1960
+ ...groups.map((g) => `- \`${g.replace(/`/g, "")}\``),
1961
+ "",
1962
+ "</Details>",
1963
+ ].join("\n");
1964
+ }
1965
+ function apiRequestCurl(attrs, op) {
1966
+ const read = (name) => {
1967
+ const raw = attrs[name];
1968
+ if (raw === undefined)
1969
+ return undefined;
1970
+ if (raw === true)
1971
+ return true;
1972
+ return raw.startsWith("{") ? parseJsLiteral(raw.slice(1, -1)) ?? REFUSED : raw;
1973
+ };
1974
+ const path = read("path");
1975
+ const method = read("method");
1976
+ const parameters = read("parameters");
1977
+ const json = read("json");
1978
+ const form = read("form");
1979
+ if (typeof path !== "string" || typeof method !== "string")
1980
+ return undefined;
1981
+ if ([parameters, json, form].includes(REFUSED))
1982
+ return undefined;
1983
+ if (parameters !== undefined && (typeof parameters !== "object" || parameters === null || Array.isArray(parameters)))
1984
+ return undefined;
1985
+ let p = path.replace(/^\//, "");
1986
+ const query = new URLSearchParams();
1987
+ for (const [name, value] of Object.entries((parameters ?? {}))) {
1988
+ if (!value)
1989
+ continue; // upstream: `if (value)` — 0 and false are dropped too
1990
+ if (p.includes(`{${name}}`))
1991
+ p = p.replace(`{${name}}`, String(value));
1992
+ else
1993
+ for (const v of Array.isArray(value) ? value : [value])
1994
+ query.append(name, String(v));
1995
+ }
1996
+ p = p.replace(/\{([^}]+)\}/g, (_m, n) => `$${n.toUpperCase()}`);
1997
+ const qs = query.toString();
1998
+ const lines = [`curl "https://api.cloudflare.com/client/v4/${p}${qs ? `?${qs}` : ""}"`, `\t--request ${method.toUpperCase()}`];
1999
+ // Upstream reads the OPERATION's security: the token when it takes one,
2000
+ // else email + key, else no auth header. Without the schema: the token.
2001
+ if (!op || op.security?.includes("api_token"))
2002
+ lines.push(`\t--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"`);
2003
+ else if (op.security?.includes("api_key"))
2004
+ lines.push(`\t--header "X-Auth-Email: $CLOUDFLARE_EMAIL"`, `\t--header "X-Auth-Key: $CLOUDFLARE_API_KEY"`);
2005
+ if (json !== undefined && typeof json === "object" && json !== null) {
2006
+ const jsonLines = JSON.stringify(json, null, "\t\t").split("\n");
2007
+ jsonLines[jsonLines.length - 1] = "\t" + jsonLines[jsonLines.length - 1];
2008
+ lines.push(`\t--json '${jsonLines.join("\n").replaceAll("'", "'\\''")}'`);
2009
+ }
2010
+ if (form !== undefined && typeof form === "object" && form !== null) {
2011
+ for (const [key, value] of Object.entries(form))
2012
+ lines.push(`\t--form "${key}=${String(value).replaceAll('"', '\\"')}"`);
2013
+ }
2014
+ return lines.join(" \\\n");
2015
+ }
2016
+ const REFUSED = Symbol("refused");
2017
+ /** RealtimeKit SDK ids → tab labels, in the order the selector lists them. */
2018
+ const RTK_SDKS = {
2019
+ "web-react": "React",
2020
+ "web-web-components": "Web Components",
2021
+ "web-angular": "Angular",
2022
+ "mobile-android": "Android",
2023
+ "mobile-ios": "iOS",
2024
+ "mobile-flutter": "Flutter",
2025
+ "mobile-react-native": "React Native",
2026
+ web: "Web",
2027
+ mobile: "Mobile",
2028
+ };
2029
+ /**
2030
+ * Each run of consecutive `rtk-snippet` siblings → one `tabs` group, synced
2031
+ * on `realtimekit-sdk`, one tab per SDK. A snippet for several SDKs appears
2032
+ * in each of their tabs, so picking an SDK in any group shows its code in
2033
+ * every group. Recursive: snippets also sit in list items and steps.
2034
+ */
2035
+ export function groupRtkSnippets(nodes) {
2036
+ const out = [];
2037
+ let run = [];
2038
+ const flush = () => {
2039
+ if (!run.length)
2040
+ return;
2041
+ const tabs = new Map();
2042
+ for (const snippet of run) {
2043
+ const ids = (snippet.props?.ids ?? []).filter(Boolean);
2044
+ for (const id of ids.length ? ids : ["other"]) {
2045
+ if (!tabs.has(id))
2046
+ tabs.set(id, []);
2047
+ tabs.get(id).push(...structuredClone(snippet.children ?? []));
2048
+ }
2049
+ }
2050
+ const order = Object.keys(RTK_SDKS);
2051
+ const ids = [...tabs.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
2052
+ out.push({
2053
+ type: "tabs",
2054
+ props: { syncKey: "realtimekit-sdk" },
2055
+ children: ids.map((id) => ({ type: "tab", props: { title: RTK_SDKS[id] ?? id }, children: tabs.get(id) })),
2056
+ });
2057
+ run = [];
2058
+ };
2059
+ for (const node of nodes) {
2060
+ if (node.type === "rtk-snippet") {
2061
+ run.push({ ...node, children: groupRtkSnippets(node.children ?? []) });
2062
+ continue;
2063
+ }
2064
+ flush();
2065
+ out.push(node.children ? { ...node, children: groupRtkSnippets(node.children) } : node);
2066
+ }
2067
+ flush();
2068
+ return out;
2069
+ }
323
2070
  export const cloudflareAdapter = {
324
2071
  name: "cloudflare",
2072
+ transformTree: (tree) => linkButtonsInHtml(groupRtkSnippets(tree)),
325
2073
  components: {
326
2074
  // ── Mapped components ─────────────────────────────────
327
2075
  Feature: {
@@ -338,6 +2086,14 @@ export const cloudflareAdapter = {
338
2086
  // passing it as an attribute. Unmapped, it leaked its opening tag onto the
339
2087
  // page as visible text (`<LinkTitleCard title="Get started" href=…`) and
340
2088
  // its body rendered as a code block. 300 uses across the corpus.
2089
+ // `/realtime`'s guide cards: a grid of linked cards. The icon is a
2090
+ // Phosphor name the Dogsbay icon set does not carry, and is dropped.
2091
+ GuideCardGrid: { type: "cards", container: true },
2092
+ GuideCard: {
2093
+ type: "card",
2094
+ container: false,
2095
+ propsFromAttrs: (attrs) => ({ title: attrs.title || "", description: attrs.description, href: attrs.href }),
2096
+ },
341
2097
  LinkTitleCard: {
342
2098
  type: "card",
343
2099
  container: true,
@@ -366,6 +2122,14 @@ export const cloudflareAdapter = {
366
2122
  type: "description",
367
2123
  container: true,
368
2124
  },
2125
+ // A bordered box around a demonstration ("to showcase something without
2126
+ // it being confused by the surrounding text"). The example callout is the
2127
+ // same idea; with no title it is labelled "Example". 338 uses.
2128
+ Example: {
2129
+ type: "callout",
2130
+ container: true,
2131
+ propsFromAttrs: (attrs) => ({ variant: "example", ...(attrs.title ? { title: attrs.title } : {}) }),
2132
+ },
369
2133
  Details: {
370
2134
  type: "details",
371
2135
  container: true,
@@ -381,13 +2145,48 @@ export const cloudflareAdapter = {
381
2145
  container: false,
382
2146
  propsFromAttrs: (attrs) => ({ id: attrs.id || "" }),
383
2147
  },
2148
+ // RealtimeKit's per-SDK code: each <RTKCodeSnippet id="web-react"> is
2149
+ // shown only while that SDK is picked in the page's <RTKSDKSelector>.
2150
+ // Consecutive snippets become ONE tab group per run, synced page-wide on
2151
+ // the SDK — the same switch, without the widget (transformTree below).
2152
+ // 771 snippets on 33 pages leaked as raw tags.
2153
+ RTKCodeSnippet: {
2154
+ type: "rtk-snippet",
2155
+ container: true,
2156
+ propsFromAttrs: (attrs) => ({ ids: rtkIds(String(attrs.id ?? "")) }),
2157
+ },
2158
+ // The selector itself: the synced tab groups ARE the selector now.
2159
+ RTKSDKSelector: { type: "", container: false, strip: true, silent: true },
2160
+ // Starlight's <FileTree> styles a nested Markdown list of paths as a
2161
+ // tree. The list IS the content, so the wrapper goes and the list stays
2162
+ // (44 uses; they leaked as raw tags around it).
2163
+ FileTree: { type: "", container: true, strip: true },
2164
+ // Agent-setup pages: the lists wrap content written on the page, and each
2165
+ // FAQ / troubleshooting item is an accordion with a title.
2166
+ TipsList: { type: "", container: true, strip: true },
2167
+ FAQList: { type: "", container: true, strip: true },
2168
+ TroubleshootingList: { type: "", container: true, strip: true },
2169
+ FAQItem: { type: "details", container: true, propsFromAttrs: (attrs) => ({ title: attrs.question || "" }) },
2170
+ TroubleshootingItem: { type: "details", container: true, propsFromAttrs: (attrs) => ({ title: attrs.issue || "" }) },
2171
+ // The sections of <PlatformAccess> (resolveAgentSetup).
2172
+ PlatformAccessDetails: { type: "details", container: true, propsFromAttrs: (attrs) => ({ title: attrs.header || "" }) },
2173
+ // A width constraint around content: the content is what matters.
2174
+ Width: { type: "", container: true, strip: true },
2175
+ // A flex-row layout wrapper (`<div class="flex …"><slot /></div>`): lossless.
2176
+ Flex: { type: "", container: true, strip: true, silent: true },
2177
+ // Known ids are replaced by their figure (inline transforms); any other is reported.
2178
+ PublicStats: { type: "", container: false, strip: true },
384
2179
  // ── Stripped components (data-driven, can't resolve) ──
2180
+ // FeatureTable / ProductFeatures: resolvePlans, from plans/index.json. A
2181
+ // tag left (unknown id) is stripped and reported.
385
2182
  FeatureTable: { type: "", container: false, strip: true },
2183
+ ProductFeatures: { type: "", container: false, strip: true },
386
2184
  // PackageManagers resolved by resolveIncludes → Tabs with code blocks
387
2185
  // WranglerConfig resolved by resolveIncludes → Tabs with JSONC + TOML
388
2186
  WranglerCommand: { type: "", container: true, strip: true },
389
2187
  TypeScriptExample: { type: "", container: true, strip: true },
390
- APIRequest: { type: "", container: true, strip: true },
2188
+ // APIRequest: resolveApiRequest → a curl block. NOT stripped: one whose
2189
+ // props cannot be read is left as a tag, so it is reported as unmapped.
391
2190
  AvailableNotifications: { type: "", container: false, strip: true },
392
2191
  CompatibilityFlags: { type: "", container: false, strip: true },
393
2192
  RSSButton: { type: "", container: false, strip: true },
@@ -395,21 +2194,78 @@ export const cloudflareAdapter = {
395
2194
  // ── Workers/Pages-specific (strip) ───────────────────
396
2195
  AutoconfigDiagram: { type: "", container: false, strip: true },
397
2196
  GitHubCode: { type: "", container: false, strip: true },
398
- ResourcesBySelector: { type: "", container: false, strip: true },
399
- MetaInfo: { type: "", container: false, strip: true },
400
- DirectoryListing: { type: "", container: false, strip: true },
2197
+ // A query over the docs: pages whose `pcx_content_type` is one of
2198
+ // `types`, under `directory`, for `products`. The component's filter UI
2199
+ // and its video / learning-path sources do not travel; the docs pages do,
2200
+ // as a `child-links` match (plans/child-links.md). 36 uses.
2201
+ ResourcesBySelector: {
2202
+ type: "child-links",
2203
+ container: false,
2204
+ propsFromAttrs: (attrs) => {
2205
+ const list = (raw) => [...String(raw ?? "").matchAll(/"([^"]+)"|'([^']+)'/g)].map((m) => m[1] ?? m[2]);
2206
+ const types = list(attrs.types);
2207
+ const products = list(attrs.products);
2208
+ const match = [types.length ? `pcx_content_type:${types.join(",")}` : "", products.length ? `products:${products.join(",")}` : ""]
2209
+ .filter(Boolean)
2210
+ .join(";");
2211
+ return {
2212
+ folder: attrs.directory ? `/${attrs.directory.replace(/^\/+/, "")}` : "/",
2213
+ ...(match ? { match } : {}),
2214
+ ...(attrs.showDescriptions === "{false}" ? {} : { descriptions: true }),
2215
+ style: "cards",
2216
+ };
2217
+ },
2218
+ },
2219
+ // `<MetaInfo text="…">` becomes its text (inline transform); a form with
2220
+ // no text renders nothing.
2221
+ MetaInfo: { type: "", container: false, strip: true, silent: true },
2222
+ // Lists the page's (or `folder`'s) child pages. It was STRIPPED — 594
2223
+ // uses, and silently: a strip is not an unmapped component, so nothing
2224
+ // reported it. Now a `child-links` node, resolved from the nav at build
2225
+ // time (plans/child-links.md). `path=` is not a prop the component reads,
2226
+ // so it lists the current page like the bare form; `tag=` has no use in
2227
+ // the corpus and is carried, not applied.
2228
+ DirectoryListing: {
2229
+ type: "child-links",
2230
+ container: false,
2231
+ propsFromAttrs: (attrs) => {
2232
+ const depth = Number(String(attrs.maxDepth ?? "").replace(/[{}]/g, ""));
2233
+ return {
2234
+ ...(attrs.folder ? { folder: attrs.folder } : {}),
2235
+ ...(depth > 1 ? { depth } : {}),
2236
+ ...(attrs.descriptions === "true" ? { descriptions: true } : {}),
2237
+ ...(attrs.button === "true" ? { style: "cards" } : {}),
2238
+ ...(attrs.tag ? { tag: attrs.tag } : {}),
2239
+ };
2240
+ },
2241
+ },
401
2242
  ExternalResources: { type: "", container: false, strip: true },
402
2243
  YouTubeVideos: { type: "", container: false, strip: true },
403
- ListTutorials: { type: "", container: false, strip: true },
2244
+ // Every tutorial in the page's top-level section (or `directory`).
2245
+ // Upstream sorts by `reviewed` date and shows a table with difficulty;
2246
+ // the list is by title. 17 uses.
2247
+ ListTutorials: {
2248
+ type: "child-links",
2249
+ container: false,
2250
+ propsFromAttrs: (attrs) => ({
2251
+ ...(attrs.directory ? { folder: `/${attrs.directory.replace(/^\/+/, "")}` } : { scope: "section" }),
2252
+ match: "pcx_content_type:tutorial",
2253
+ descriptions: true,
2254
+ }),
2255
+ },
404
2256
  WorkersTemplates: { type: "", container: false, strip: true },
405
2257
  // ── Heading variant (map to heading) ─────────────────
406
2258
  AnchorHeading: {
407
2259
  type: "heading",
408
2260
  container: true,
409
2261
  propsFromAttrs: (attrs) => ({
410
- level: parseInt(attrs.level || "2", 10),
2262
+ // Upstream's prop is `depth` (every AnchorHeading came out as an H2),
2263
+ // and its id is `GithubSlug(slug ?? title)`, not deduped with the page.
2264
+ // `depth={3}` arrives with its braces.
2265
+ level: Math.min(6, Math.max(1, parseInt(String(attrs.depth || attrs.level || "2").replace(/[{}\s"']/g, ""), 10) || 2)),
411
2266
  text: attrs.title || "",
412
- slug: (attrs.title || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
2267
+ slug: githubSlug(String(attrs.slug || attrs.title || "")),
2268
+ [FIXED_SLUG]: true,
413
2269
  }),
414
2270
  },
415
2271
  },
@@ -419,7 +2275,6 @@ export const cloudflareAdapter = {
419
2275
  "Stream",
420
2276
  "WranglerCommand",
421
2277
  "TypeScriptExample",
422
- "APIRequest",
423
2278
  "AvailableNotifications",
424
2279
  "RSSButton",
425
2280
  "ProductReleaseNotes",
@@ -436,14 +2291,10 @@ export const cloudflareAdapter = {
436
2291
  ],
437
2292
  // Inline components — transformed in source text before block parsing
438
2293
  inlineTransforms: [
439
- // <GlossaryTooltip term="..." link="...">text</GlossaryTooltip> → [text](link) or just text
440
- [
441
- /<GlossaryTooltip\s+term="([^"]*)"(?:\s+link="([^"]*)")?[^>]*>([\s\S]*?)<\/GlossaryTooltip>/g,
442
- "$3",
443
- ],
444
- // <Plan type="all" /> → **Available on all plans**
445
- [/<Plan\s+type="all"\s*\/?>/g, "**Available on all plans**"],
446
- [/<Plan\s+type="([^"]*)"\s*\/?>/g, "**$1 plan**"],
2294
+ // <GlossaryTooltip>, <GlossaryDefinition>, <Glossary>: resolveGlossary.
2295
+ // <Plan type|id>: resolvePlanBadges. These entries serve the legacy
2296
+ // parser, which does not run resolveIncludes (known types only).
2297
+ ...Object.entries(PLAN_TYPES).map(([type, label]) => [new RegExp(`<Plan\\s+type="${type}"\\s*\\/?>`, "g"), `**${label}**`]),
447
2298
  // <Type text="string" /> → `string`
448
2299
  //
449
2300
  // An inline type-annotation badge next to a parameter or method — the
@@ -451,7 +2302,20 @@ export const cloudflareAdapter = {
451
2302
  // mapping, not an approximation. 249 uses in the AI Search partials alone
452
2303
  // (`string` ×93, `boolean` ×38, `object` ×28), all inside API reference
453
2304
  // parameter lists where the type IS the content.
454
- [/<Type\s+text="([^"]*)"\s*\/>/g, "`$1`"],
2305
+ [/<Type\s+text\s*=\s*"([^"]*)"\s*\/>/g, "`$1`"],
2306
+ // Single-quoted when the type itself holds double quotes (a union of
2307
+ // string literals: `"asc" | "desc"`). 99 uses leaked as raw tags.
2308
+ [/<Type\s+text\s*=\s*'([^']*)'\s*\/>/g, "`$1`"],
2309
+ // <RuleID id="…" /> → `…`: a copy-to-clipboard pill of a WAF / DDoS rule
2310
+ // id. The pill shows only the last 8 characters, but the value copied is
2311
+ // the whole id, and the whole id is what a reader needs. 158 uses.
2312
+ [/<RuleID\s+id\s*=\s*"([^"`]*)"\s*\/>/g, "`$1`"],
2313
+ // <RTKPill>Beta</RTKPill>: RealtimeKit's status pill — a badge.
2314
+ [/<RTKPill>([^<"]*)<\/RTKPill>/g, '<Badge text="$1" />'],
2315
+ // <PublicStats id="…" />: three fixed figures, as the component has them.
2316
+ [/<PublicStats\s+id\s*=\s*"data_center_cities"\s*\/>/g, "over 330 cities"],
2317
+ [/<PublicStats\s+id\s*=\s*"total_bandwidth"\s*\/>/g, "over 405 Tbps network capacity"],
2318
+ [/<PublicStats\s+id\s*=\s*"network_peers"\s*\/>/g, "over 13,000 network peers"],
455
2319
  // <MetaInfo text="optional" /> → optional
456
2320
  //
457
2321
  // The companion annotation: "optional", "default: 10", a units hint. It
@@ -460,25 +2324,52 @@ export const cloudflareAdapter = {
460
2324
  [/<MetaInfo\s+text="([^"]*)"\s*\/>/g, "$1"],
461
2325
  // <InlineBadge ... /> → strip
462
2326
  [/<InlineBadge\s[^>]*\/>/g, ""],
463
- // Fallback strip for self-closing components that may appear deeply indented
464
- // where the block rule can't reach (inside Steps > list items etc.)
465
- // PackageManagers resolved by resolveIncludes (not stripped)
466
- [/<AutoconfigDiagram[\s\S]*?\/>/g, ""],
467
- [/<GitHubCode\s[^>]*\/>/g, ""],
468
- [/<ResourcesBySelector[\s\S]*?\/>/g, ""],
2327
+ // The regex strips that stood here (AutoconfigDiagram, GitHubCode,
2328
+ // ResourcesBySelector, ExternalResources, YouTubeVideos) deleted the tag
2329
+ // before anything could report it. Those now reach the component map,
2330
+ // whose strips are reported. Only lossless forms stay.
469
2331
  [/<MetaInfo\s[^>]*\/>/g, ""], // fallback: forms with no `text` attribute
470
- [/<DirectoryListing[\s\S]*?\/>/g, ""],
471
- [/<ExternalResources\s[^>]*\/>/g, ""],
472
- [/<YouTubeVideos\s[^>]*\/>/g, ""],
473
2332
  [/<SUBCOMMAND\s[^>]*\/>/g, ""],
474
2333
  ],
475
2334
  // Resolve <Render> partials and <DashButton> links
476
- resolveIncludes(source, partialsDir) {
477
- let result = resolveRawImports(source, partialsDir);
2335
+ resolveIncludes(source, partialsDir, options, frontmatter) {
2336
+ // Changelog entries first: their bodies are MDX, and every resolver below
2337
+ // (Render, glossary, tables, …) must see them as it sees the page.
2338
+ let result = resolveProductChangelog(source, partialsDir);
2339
+ // Upstream renders a GENERATED JavaScript tab beside every example's
2340
+ // TypeScript (ts-blank-space + prettier at build); the import keeps the
2341
+ // TypeScript only — a loss, so it is counted, not silent.
2342
+ if (options?.onDegraded && result.includes("TypeScriptExample")) {
2343
+ outsideFences(result, (t) => {
2344
+ for (const _ of t.matchAll(/^[ \t]*<TypeScriptExample\b(?:\s[^>]*)?(?<!\/)>[ \t]*$/gm))
2345
+ options.onDegraded("TypeScriptExample", "the TypeScript only: upstream also shows a JavaScript tab generated from it at build time");
2346
+ return t;
2347
+ });
2348
+ }
2349
+ result = resolveOmitTabsTypeScript(result);
2350
+ // Also first: compatibility-flag bodies are MDX (one holds a <Render>).
2351
+ result = resolveLocalDataComponents(result, partialsDir);
2352
+ result = resolveRawImports(result, partialsDir, options?.pageDir);
2353
+ result = resolvePageConstants(result);
2354
+ if (options?.mode === "template")
2355
+ result = renderTagsToTemplates(result, partialsDir, options);
478
2356
  result = resolveRenderTags(result, partialsDir);
479
2357
  result = resolveDashButton(result, partialsDir);
480
2358
  result = resolvePackageManagers(result);
481
2359
  result = resolveWranglerConfig(result);
2360
+ result = resolveGlossary(result, partialsDir);
2361
+ result = resolvePlans(result, partialsDir);
2362
+ result = resolvePlanBadges(result, partialsDir);
2363
+ result = resolveNotifications(result, partialsDir);
2364
+ result = resolvePagesPresets(result, partialsDir);
2365
+ result = resolveReleaseNotes(result, partialsDir, frontmatter);
2366
+ // `<Image src={x}>` whose `x` the page imports from `~/assets` (13 uses).
2367
+ result = resolveImageImports(result);
2368
+ result = resolveBuildAgentsCallout(result, partialsDir);
2369
+ result = resolveAgentSetup(result, partialsDir, options?.onDegraded);
2370
+ result = resolveDiagrams(result);
2371
+ result = resolveGitHubCode(result, options?.onDegraded);
2372
+ result = resolveApiRequest(result, options?.onApiRequest, options?.apiSchema, options?.onApiMissing);
482
2373
  return result;
483
2374
  },
484
2375
  // Redirect pages use external_link in frontmatter