@dogsbay/format-starlight 0.2.0-beta.12 → 0.2.0-beta.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +69 -1
- package/dist/adapters/cloudflare.d.ts +183 -1
- package/dist/adapters/cloudflare.js +1995 -60
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +671 -6
- package/dist/fences.d.ts +16 -0
- package/dist/fences.js +49 -0
- package/dist/github-slug.d.ts +19 -0
- package/dist/github-slug.js +112 -0
- package/dist/js-literal.d.ts +12 -0
- package/dist/js-literal.js +249 -0
- package/dist/jsx-to-jinja.d.ts +62 -0
- package/dist/jsx-to-jinja.js +869 -0
- package/dist/parse-mdx.d.ts +61 -1
- package/dist/parse-mdx.js +465 -31
- package/dist/parse.js +11 -4
- package/dist/template-tokens.d.ts +63 -0
- package/dist/template-tokens.js +206 -0
- package/package.json +9 -7
|
@@ -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
|
-
|
|
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 ───────────────────────────
|
|
@@ -143,22 +168,31 @@ function resolveWranglerConfig(source) {
|
|
|
143
168
|
toml = toml.replace(/^(\s*)(.*compatibility_date.*)/m, "$1# Set this to today's date\n$1$2");
|
|
144
169
|
jsonc = jsonc.replace(/^(\s*)(.*compatibility_date.*)/m, "$1// Set this to today's date\n$1$2");
|
|
145
170
|
}
|
|
171
|
+
// A closing fence MUST be on its own line. It wasn't for the TOML
|
|
172
|
+
// half — `TOML.stringify` returns no trailing newline, so the fence
|
|
173
|
+
// was glued to the last config line (`remote = true```). The block
|
|
174
|
+
// then never closed and SWALLOWED THE REST OF THE PAGE: on
|
|
175
|
+
// ai-search/get-started/workers, everything after the binding config
|
|
176
|
+
// — prose, headings and three more components — became one giant code
|
|
177
|
+
// block. Normalising both halves means neither depends on what its
|
|
178
|
+
// serializer happens to append.
|
|
146
179
|
const result = `<Tabs>
|
|
147
180
|
<TabItem label="wrangler.jsonc">
|
|
148
181
|
|
|
149
182
|
\`\`\`jsonc
|
|
150
|
-
${jsonc}
|
|
183
|
+
${jsonc.replace(/\s*$/, "")}
|
|
151
184
|
\`\`\`
|
|
152
185
|
|
|
153
186
|
</TabItem>
|
|
154
187
|
<TabItem label="wrangler.toml">
|
|
155
188
|
|
|
156
189
|
\`\`\`toml
|
|
157
|
-
${toml}
|
|
190
|
+
${toml.replace(/\s*$/, "")}
|
|
191
|
+
\`\`\`
|
|
158
192
|
|
|
159
193
|
</TabItem>
|
|
160
194
|
</Tabs>`;
|
|
161
|
-
return indentContent(result, indent);
|
|
195
|
+
return "\n" + indentContent(result, indent); // a blank line first, as for PackageManagers
|
|
162
196
|
}
|
|
163
197
|
catch {
|
|
164
198
|
// If conversion fails, output the original code block
|
|
@@ -202,45 +236,175 @@ function resolveJsxSwitch(content) {
|
|
|
202
236
|
* substituting `{X}` with the file content in the source.
|
|
203
237
|
* Vite/Astro's ?raw imports file contents as strings.
|
|
204
238
|
*/
|
|
205
|
-
function resolveRawImports(source, partialsDir) {
|
|
239
|
+
function resolveRawImports(source, partialsDir, pageDir) {
|
|
206
240
|
if (!partialsDir)
|
|
207
241
|
return source;
|
|
208
242
|
// src/content/partials/ → src/ (go up two levels to repo src/)
|
|
209
243
|
const srcDir = dirname(dirname(partialsDir));
|
|
210
|
-
|
|
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;
|
|
211
248
|
let result = source;
|
|
212
249
|
let match;
|
|
213
250
|
while ((match = importRegex.exec(source)) !== null) {
|
|
214
251
|
const varName = match[1];
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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);
|
|
237
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);
|
|
238
285
|
}
|
|
239
|
-
catch { /* ignore read errors */ }
|
|
240
286
|
}
|
|
287
|
+
catch { /* ignore read errors */ }
|
|
241
288
|
}
|
|
242
289
|
return result;
|
|
243
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
|
+
}
|
|
244
408
|
// ── Render partial resolution ──────────────────────────
|
|
245
409
|
/**
|
|
246
410
|
* Resolve `<Render>` tags by inlining partial MDX content.
|
|
@@ -296,6 +460,211 @@ function resolveRenderTags(source, partialsDir, depth = 0) {
|
|
|
296
460
|
}
|
|
297
461
|
});
|
|
298
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
|
+
}
|
|
299
668
|
function parseRenderAttrs(str) {
|
|
300
669
|
const file = str.match(/file\s*=\s*"([^"]*)"/)?.[1];
|
|
301
670
|
const product = str.match(/product\s*=\s*"([^"]*)"/)?.[1];
|
|
@@ -311,8 +680,1396 @@ function parseRenderAttrs(str) {
|
|
|
311
680
|
return { file, product, params };
|
|
312
681
|
}
|
|
313
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 `"`, 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="…" />` → ``, 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, "&").replace(/</g, "<");
|
|
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
|
+
}
|
|
314
2070
|
export const cloudflareAdapter = {
|
|
315
2071
|
name: "cloudflare",
|
|
2072
|
+
transformTree: (tree) => linkButtonsInHtml(groupRtkSnippets(tree)),
|
|
316
2073
|
components: {
|
|
317
2074
|
// ── Mapped components ─────────────────────────────────
|
|
318
2075
|
Feature: {
|
|
@@ -323,19 +2080,56 @@ export const cloudflareAdapter = {
|
|
|
323
2080
|
href: attrs.href,
|
|
324
2081
|
}),
|
|
325
2082
|
},
|
|
2083
|
+
// Cloudflare's own alias: `export { default as LinkTitleCard } from
|
|
2084
|
+
// "./components/cf/LinkCard.astro"` — the SAME component under a second
|
|
2085
|
+
// name, used when the card wraps its description as children rather than
|
|
2086
|
+
// passing it as an attribute. Unmapped, it leaked its opening tag onto the
|
|
2087
|
+
// page as visible text (`<LinkTitleCard title="Get started" href=…`) and
|
|
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
|
+
},
|
|
2097
|
+
LinkTitleCard: {
|
|
2098
|
+
type: "card",
|
|
2099
|
+
container: true,
|
|
2100
|
+
propsFromAttrs: (attrs) => ({
|
|
2101
|
+
title: attrs.title || "",
|
|
2102
|
+
href: attrs.href,
|
|
2103
|
+
icon: attrs.icon,
|
|
2104
|
+
}),
|
|
2105
|
+
},
|
|
326
2106
|
RelatedProduct: {
|
|
327
2107
|
type: "card",
|
|
328
2108
|
container: true,
|
|
329
2109
|
propsFromAttrs: (attrs) => ({
|
|
330
2110
|
title: attrs.header || "",
|
|
331
2111
|
href: attrs.href,
|
|
332
|
-
|
|
2112
|
+
// `/_assets/icons/…` — the mount convention, so the file the importer
|
|
2113
|
+
// copies is the file this reference names. It used to be a bare
|
|
2114
|
+
// `icons/x.svg`, which existed in neither output: Astro's <Icon> takes
|
|
2115
|
+
// a registry NAME and silently rendered nothing, and Obsidian built an
|
|
2116
|
+
// <img> against a vault path with no file behind it, so every related
|
|
2117
|
+
// product showed a broken image with its title stretched down the page.
|
|
2118
|
+
icon: attrs.product ? `/_assets/icons/${attrs.product}.svg` : undefined,
|
|
333
2119
|
}),
|
|
334
2120
|
},
|
|
335
2121
|
Description: {
|
|
336
2122
|
type: "description",
|
|
337
2123
|
container: true,
|
|
338
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
|
+
},
|
|
339
2133
|
Details: {
|
|
340
2134
|
type: "details",
|
|
341
2135
|
container: true,
|
|
@@ -351,13 +2145,48 @@ export const cloudflareAdapter = {
|
|
|
351
2145
|
container: false,
|
|
352
2146
|
propsFromAttrs: (attrs) => ({ id: attrs.id || "" }),
|
|
353
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 },
|
|
354
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.
|
|
355
2182
|
FeatureTable: { type: "", container: false, strip: true },
|
|
2183
|
+
ProductFeatures: { type: "", container: false, strip: true },
|
|
356
2184
|
// PackageManagers resolved by resolveIncludes → Tabs with code blocks
|
|
357
2185
|
// WranglerConfig resolved by resolveIncludes → Tabs with JSONC + TOML
|
|
358
2186
|
WranglerCommand: { type: "", container: true, strip: true },
|
|
359
2187
|
TypeScriptExample: { type: "", container: true, strip: true },
|
|
360
|
-
APIRequest:
|
|
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.
|
|
361
2190
|
AvailableNotifications: { type: "", container: false, strip: true },
|
|
362
2191
|
CompatibilityFlags: { type: "", container: false, strip: true },
|
|
363
2192
|
RSSButton: { type: "", container: false, strip: true },
|
|
@@ -365,21 +2194,78 @@ export const cloudflareAdapter = {
|
|
|
365
2194
|
// ── Workers/Pages-specific (strip) ───────────────────
|
|
366
2195
|
AutoconfigDiagram: { type: "", container: false, strip: true },
|
|
367
2196
|
GitHubCode: { type: "", container: false, strip: true },
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
},
|
|
371
2242
|
ExternalResources: { type: "", container: false, strip: true },
|
|
372
2243
|
YouTubeVideos: { type: "", container: false, strip: true },
|
|
373
|
-
|
|
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
|
+
},
|
|
374
2256
|
WorkersTemplates: { type: "", container: false, strip: true },
|
|
375
2257
|
// ── Heading variant (map to heading) ─────────────────
|
|
376
2258
|
AnchorHeading: {
|
|
377
2259
|
type: "heading",
|
|
378
2260
|
container: true,
|
|
379
2261
|
propsFromAttrs: (attrs) => ({
|
|
380
|
-
|
|
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)),
|
|
381
2266
|
text: attrs.title || "",
|
|
382
|
-
slug: (attrs.
|
|
2267
|
+
slug: githubSlug(String(attrs.slug || attrs.title || "")),
|
|
2268
|
+
[FIXED_SLUG]: true,
|
|
383
2269
|
}),
|
|
384
2270
|
},
|
|
385
2271
|
},
|
|
@@ -389,7 +2275,6 @@ export const cloudflareAdapter = {
|
|
|
389
2275
|
"Stream",
|
|
390
2276
|
"WranglerCommand",
|
|
391
2277
|
"TypeScriptExample",
|
|
392
|
-
"APIRequest",
|
|
393
2278
|
"AvailableNotifications",
|
|
394
2279
|
"RSSButton",
|
|
395
2280
|
"ProductReleaseNotes",
|
|
@@ -406,35 +2291,85 @@ export const cloudflareAdapter = {
|
|
|
406
2291
|
],
|
|
407
2292
|
// Inline components — transformed in source text before block parsing
|
|
408
2293
|
inlineTransforms: [
|
|
409
|
-
// <GlossaryTooltip
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
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}**`]),
|
|
2298
|
+
// <Type text="string" /> → `string`
|
|
2299
|
+
//
|
|
2300
|
+
// An inline type-annotation badge next to a parameter or method — the
|
|
2301
|
+
// component renders it as a styled <code>, so inline code is the faithful
|
|
2302
|
+
// mapping, not an approximation. 249 uses in the AI Search partials alone
|
|
2303
|
+
// (`string` ×93, `boolean` ×38, `object` ×28), all inside API reference
|
|
2304
|
+
// parameter lists where the type IS the content.
|
|
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"],
|
|
2319
|
+
// <MetaInfo text="optional" /> → optional
|
|
2320
|
+
//
|
|
2321
|
+
// The companion annotation: "optional", "default: 10", a units hint. It
|
|
2322
|
+
// was being DELETED, which reads as though every parameter is required.
|
|
2323
|
+
// The component is a muted inline span, so plain text is the mapping.
|
|
2324
|
+
[/<MetaInfo\s+text="([^"]*)"\s*\/>/g, "$1"],
|
|
417
2325
|
// <InlineBadge ... /> → strip
|
|
418
2326
|
[/<InlineBadge\s[^>]*\/>/g, ""],
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
[/<
|
|
424
|
-
[/<ResourcesBySelector[\s\S]*?\/>/g, ""],
|
|
425
|
-
[/<MetaInfo\s[^>]*\/>/g, ""],
|
|
426
|
-
[/<DirectoryListing[\s\S]*?\/>/g, ""],
|
|
427
|
-
[/<ExternalResources\s[^>]*\/>/g, ""],
|
|
428
|
-
[/<YouTubeVideos\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.
|
|
2331
|
+
[/<MetaInfo\s[^>]*\/>/g, ""], // fallback: forms with no `text` attribute
|
|
429
2332
|
[/<SUBCOMMAND\s[^>]*\/>/g, ""],
|
|
430
2333
|
],
|
|
431
2334
|
// Resolve <Render> partials and <DashButton> links
|
|
432
|
-
resolveIncludes(source, partialsDir) {
|
|
433
|
-
|
|
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);
|
|
434
2356
|
result = resolveRenderTags(result, partialsDir);
|
|
435
2357
|
result = resolveDashButton(result, partialsDir);
|
|
436
2358
|
result = resolvePackageManagers(result);
|
|
437
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);
|
|
438
2373
|
return result;
|
|
439
2374
|
},
|
|
440
2375
|
// Redirect pages use external_link in frontmatter
|