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