@dogsbay/format-starlight 0.2.0-beta.114 → 0.2.0-beta.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/parse-mdx.d.ts
CHANGED
|
@@ -13,11 +13,48 @@
|
|
|
13
13
|
* - The adapter pattern for site-specific component mappings
|
|
14
14
|
*/
|
|
15
15
|
import type { TreeNode } from "@dogsbay/types";
|
|
16
|
-
import type { SiteAdapter } from "./adapter.js";
|
|
16
|
+
import type { IncludeOptions, SiteAdapter } from "./adapter.js";
|
|
17
|
+
/**
|
|
18
|
+
* MDX reads the TEXT inside JSX/HTML elements as Markdown; a raw HTML block
|
|
19
|
+
* here is emitted as it is. Two Markdown forms in that text then broke the
|
|
20
|
+
* page: a code span (`` `Array<String>` `` in a `<td>` — the browser read
|
|
21
|
+
* `<String>` as a tag and the type vanished; 10 on the Cloudflare corpus) and
|
|
22
|
+
* a backslash escape (`Promise\<InstanceStatus>` inside `<code>` showed the
|
|
23
|
+
* backslash). Both become their HTML: `<code>` with escaped text, `<`.
|
|
24
|
+
* Only TEXT between tags is touched — never inside a tag (attribute values)
|
|
25
|
+
* or inside `<pre>`, `<script>`, `<style>`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function mdxHtmlText(html: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* MDX has no HTML blocks: every tag line is its own JSX element. Markdown's
|
|
30
|
+
* HTML block runs to the next BLANK line, so a component written straight
|
|
31
|
+
* after a `<p>…</p>` line was swallowed into it and leaked as a tag
|
|
32
|
+
* (`<AnchorHeading>`, `<Aside>` in Cloudflare's JSX branches). A blank line
|
|
33
|
+
* goes between them — only after a line that is one COMPLETE element, so an
|
|
34
|
+
* open `<table>` or `<ul>` that the component sits inside is left whole.
|
|
35
|
+
*/
|
|
36
|
+
export declare function endHtmlBlocksAtComponents(source: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* `<Feature header="…"> Paid add-on` — text after a CONTAINER's opener on
|
|
39
|
+
* its line — is the start of the body in MDX. The block rule took the line
|
|
40
|
+
* as the opener and the text was lost with no report (web3: two plan badges).
|
|
41
|
+
* It moves to its own line, at the opener's indent. A component after the
|
|
42
|
+
* opener is `normalizeJsxLines`' job; a closer on the same line is an inline
|
|
43
|
+
* use and is left alone.
|
|
44
|
+
*/
|
|
45
|
+
export declare function splitContainerOpenerText(source: string, isContainer: (tag: string) => boolean): string;
|
|
17
46
|
export interface ParseOptions {
|
|
18
47
|
adapter?: SiteAdapter;
|
|
48
|
+
/**
|
|
49
|
+
* The page's front matter, when the caller has already split it off (the
|
|
50
|
+
* CLI parses the BODY). Components that read it (`<ProductReleaseNotes>`)
|
|
51
|
+
* otherwise saw nothing.
|
|
52
|
+
*/
|
|
53
|
+
frontmatter?: Record<string, unknown>;
|
|
19
54
|
/** Directory containing partial MDX files for <Render> resolution */
|
|
20
55
|
partialsDir?: string;
|
|
56
|
+
/** How partials are resolved (inline, or template-page directives). */
|
|
57
|
+
includeOptions?: IncludeOptions;
|
|
21
58
|
/**
|
|
22
59
|
* Called with the tag name of every component DROPPED from the output —
|
|
23
60
|
* one that no builtin and no adapter mapping claimed.
|
package/dist/parse-mdx.js
CHANGED
|
@@ -12,13 +12,19 @@
|
|
|
12
12
|
* - Card grouping (consecutive cards → cards container)
|
|
13
13
|
* - The adapter pattern for site-specific component mappings
|
|
14
14
|
*/
|
|
15
|
+
import { assignHeadingSlugs } from "./github-slug.js";
|
|
15
16
|
import MarkdownIt from "markdown-it";
|
|
17
|
+
import mdFootnote from "markdown-it-footnote";
|
|
16
18
|
import mdxJsx, { normalizeJsxLines } from "@dogsbay/markdown-it-mdx-jsx";
|
|
17
19
|
import { extractFrontmatter } from "./parse.js";
|
|
20
|
+
import { outsideFences } from "./fences.js";
|
|
21
|
+
import { parseJsLiteral } from "./js-literal.js";
|
|
18
22
|
// ── Component mapping ───────────────────────────────────
|
|
19
23
|
// Starlight built-in components (always available)
|
|
20
24
|
const STARLIGHT_COMPONENTS = {
|
|
21
|
-
|
|
25
|
+
// `syncKey` switches every group sharing it together (db-tabs honours it);
|
|
26
|
+
// dropping it made each group independent.
|
|
27
|
+
Tabs: { type: "tabs", container: true, propsFromAttrs: (attrs) => (attrs.syncKey ? { syncKey: attrs.syncKey } : {}) },
|
|
22
28
|
TabItem: {
|
|
23
29
|
type: "tab",
|
|
24
30
|
container: true,
|
|
@@ -72,13 +78,35 @@ const STARLIGHT_COMPONENTS = {
|
|
|
72
78
|
type: "code",
|
|
73
79
|
container: false,
|
|
74
80
|
propsFromAttrs: (attrs) => ({
|
|
75
|
-
code: attrs.code
|
|
81
|
+
code: codeAttr(attrs.code),
|
|
76
82
|
lang: attrs.lang || "plaintext",
|
|
77
83
|
title: attrs.title,
|
|
78
84
|
}),
|
|
79
85
|
},
|
|
80
86
|
};
|
|
81
87
|
const CORE_SELF_CLOSING = ["LinkCard", "Badge", "Code"];
|
|
88
|
+
/**
|
|
89
|
+
* `<Code code={`…`} />`: the attribute arrives RAW, braces and backticks
|
|
90
|
+
* included, and the block showed them (`` `} `` as its last line — which also
|
|
91
|
+
* unbalanced the fences around it). A literal is read (never run) and
|
|
92
|
+
* dedented as authored; anything else (a variable) stays as written.
|
|
93
|
+
*/
|
|
94
|
+
function codeAttr(raw) {
|
|
95
|
+
if (!raw)
|
|
96
|
+
return "";
|
|
97
|
+
if (!raw.startsWith("{"))
|
|
98
|
+
return raw;
|
|
99
|
+
const v = parseJsLiteral(raw.slice(1, -1));
|
|
100
|
+
if (typeof v !== "string")
|
|
101
|
+
return raw;
|
|
102
|
+
const opensOnLine = !/^[ \t]*\r?\n/.test(v); // `{`const a = 1;` — its first line has no indent to count
|
|
103
|
+
const lines = v.replace(/^[ \t]*\r?\n/, "").replace(/\s+$/, "").split("\n");
|
|
104
|
+
const counted = (opensOnLine ? lines.slice(1) : lines).filter((l) => l.trim());
|
|
105
|
+
const indent = Math.min(...counted.map((l) => /^[ \t]*/.exec(l)[0].length));
|
|
106
|
+
if (!Number.isFinite(indent) || indent <= 0)
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
return lines.map((l, k) => (opensOnLine && k === 0 ? l : l.slice(Math.min(indent, /^[ \t]*/.exec(l)[0].length)))).join("\n");
|
|
109
|
+
}
|
|
82
110
|
function buildComponentMap(adapter) {
|
|
83
111
|
const map = { ...STARLIGHT_COMPONENTS };
|
|
84
112
|
const selfClosing = [...CORE_SELF_CLOSING];
|
|
@@ -98,11 +126,14 @@ function buildComponentMap(adapter) {
|
|
|
98
126
|
function applyInlineTransforms(source, adapter) {
|
|
99
127
|
if (!adapter?.inlineTransforms)
|
|
100
128
|
return source;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
129
|
+
// Outside code: a style guide SHOWS `<Type text="string" />` in a fence,
|
|
130
|
+
// and rewriting it there changed the example into its output.
|
|
131
|
+
return outsideFences(source, (text) => {
|
|
132
|
+
let result = text;
|
|
133
|
+
for (const [regex, replacement] of adapter.inlineTransforms)
|
|
134
|
+
result = result.replace(regex, replacement);
|
|
135
|
+
return result;
|
|
136
|
+
});
|
|
106
137
|
}
|
|
107
138
|
/**
|
|
108
139
|
* Rewrite the `~/assets/` Vite alias to the `/_assets/` mount convention.
|
|
@@ -174,6 +205,11 @@ function tokensToTree(tokens, componentMap, onDrop, describeComponent) {
|
|
|
174
205
|
const props = mapping.propsFromAttrs?.(attrs) || {};
|
|
175
206
|
tree.push({ type: mapping.type, props, children: [] });
|
|
176
207
|
}
|
|
208
|
+
else if (!mapping.container && !mapping.silent) {
|
|
209
|
+
// Stripped, and what it rendered is gone: say so. Silent strips hid
|
|
210
|
+
// 190+ pages of lost listings from the report.
|
|
211
|
+
onDrop?.(token.tag);
|
|
212
|
+
}
|
|
177
213
|
}
|
|
178
214
|
i++;
|
|
179
215
|
continue;
|
|
@@ -203,20 +239,23 @@ function tokensToTree(tokens, componentMap, onDrop, describeComponent) {
|
|
|
203
239
|
if (mapping.strip) {
|
|
204
240
|
// Strip but keep inner content parsed
|
|
205
241
|
if (mapping.container && children.length > 0) {
|
|
206
|
-
const innerNodes = tokensToTree(children, componentMap);
|
|
242
|
+
const innerNodes = tokensToTree(children, componentMap, onDrop, describeComponent);
|
|
207
243
|
tree.push(...innerNodes);
|
|
208
244
|
}
|
|
245
|
+
else if (!mapping.container && !mapping.silent) {
|
|
246
|
+
onDrop?.(tag);
|
|
247
|
+
}
|
|
209
248
|
}
|
|
210
249
|
else {
|
|
211
250
|
const attrs = token.meta?.attrs || {};
|
|
212
251
|
const props = mapping.propsFromAttrs?.(attrs) || {};
|
|
213
|
-
const childNodes = tokensToTree(children, componentMap);
|
|
252
|
+
const childNodes = tokensToTree(children, componentMap, onDrop, describeComponent);
|
|
214
253
|
tree.push({ type: mapping.type, props, children: childNodes });
|
|
215
254
|
}
|
|
216
255
|
}
|
|
217
256
|
else {
|
|
218
257
|
// Unknown component — parse inner content as markdown
|
|
219
|
-
const childNodes = tokensToTree(children, componentMap);
|
|
258
|
+
const childNodes = tokensToTree(children, componentMap, onDrop, describeComponent);
|
|
220
259
|
tree.push(...childNodes);
|
|
221
260
|
}
|
|
222
261
|
continue;
|
|
@@ -310,7 +349,7 @@ function tokensToTree(tokens, componentMap, onDrop, describeComponent) {
|
|
|
310
349
|
}
|
|
311
350
|
// Recursively parse the list item content using tokensToTree
|
|
312
351
|
// This handles nested lists, JSX components, paragraphs, etc.
|
|
313
|
-
const itemChildren = tokensToTree(itemTokens, componentMap);
|
|
352
|
+
const itemChildren = tokensToTree(itemTokens, componentMap, onDrop, describeComponent);
|
|
314
353
|
items.push({ type: "list-item", children: itemChildren });
|
|
315
354
|
i++; // skip list_item_close
|
|
316
355
|
}
|
|
@@ -335,17 +374,50 @@ function tokensToTree(tokens, componentMap, onDrop, describeComponent) {
|
|
|
335
374
|
continue;
|
|
336
375
|
}
|
|
337
376
|
if (token.type === "blockquote_open") {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
377
|
+
// The whole body at once, to the MATCHING close. It used to be parsed
|
|
378
|
+
// one token at a time, so a paragraph never saw its inline token and
|
|
379
|
+
// every blockquote came out empty.
|
|
380
|
+
const start = ++i;
|
|
381
|
+
let depth = 1;
|
|
382
|
+
while (i < tokens.length) {
|
|
383
|
+
if (tokens[i].type === "blockquote_open")
|
|
384
|
+
depth++;
|
|
385
|
+
if (tokens[i].type === "blockquote_close" && --depth === 0)
|
|
386
|
+
break;
|
|
343
387
|
i++;
|
|
344
388
|
}
|
|
389
|
+
const children = tokensToTree(tokens.slice(start, i), componentMap, onDrop, describeComponent);
|
|
345
390
|
i++; // skip close
|
|
346
391
|
tree.push({ type: "blockquote", children });
|
|
347
392
|
continue;
|
|
348
393
|
}
|
|
394
|
+
if (token.type === "footnote_block_open") {
|
|
395
|
+
const end = tokens.findIndex((t, k) => k > i && t.type === "footnote_block_close");
|
|
396
|
+
const close = end === -1 ? tokens.length : end;
|
|
397
|
+
const defs = [];
|
|
398
|
+
for (let k = i + 1; k < close; k++) {
|
|
399
|
+
if (tokens[k].type !== "footnote_open")
|
|
400
|
+
continue;
|
|
401
|
+
const meta = (tokens[k].meta ?? {});
|
|
402
|
+
const label = meta.label ?? String((meta.id ?? 0) + 1);
|
|
403
|
+
let defEnd = k + 1;
|
|
404
|
+
while (defEnd < close && tokens[defEnd].type !== "footnote_close")
|
|
405
|
+
defEnd++;
|
|
406
|
+
const body = tokens.slice(k + 1, defEnd).filter((t) => t.type !== "footnote_anchor");
|
|
407
|
+
for (const t of body)
|
|
408
|
+
if (t.children)
|
|
409
|
+
t.children = t.children.filter((c) => c.type !== "footnote_anchor");
|
|
410
|
+
defs.push({
|
|
411
|
+
type: "footnote-def",
|
|
412
|
+
props: { id: `fn-${label}`, backrefId: `fnref-${label}`, label },
|
|
413
|
+
children: tokensToTree(body, componentMap, onDrop, describeComponent),
|
|
414
|
+
});
|
|
415
|
+
k = defEnd;
|
|
416
|
+
}
|
|
417
|
+
tree.push({ type: "footnote-list", children: defs });
|
|
418
|
+
i = close + 1;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
349
421
|
if (token.type === "html_block") {
|
|
350
422
|
// Check if this is a leftover JSX component that wasn't caught
|
|
351
423
|
const trimmed = token.content.trim();
|
|
@@ -369,7 +441,7 @@ function tokensToTree(tokens, componentMap, onDrop, describeComponent) {
|
|
|
369
441
|
i++;
|
|
370
442
|
continue;
|
|
371
443
|
}
|
|
372
|
-
tree.push({ type: "html", html: token.content });
|
|
444
|
+
tree.push({ type: "html", html: mdxHtmlText(token.content) });
|
|
373
445
|
i++;
|
|
374
446
|
continue;
|
|
375
447
|
}
|
|
@@ -430,13 +502,23 @@ function parseTable(tokens, start) {
|
|
|
430
502
|
};
|
|
431
503
|
}
|
|
432
504
|
// ── Inline token conversion ─────────────────────────────
|
|
505
|
+
/**
|
|
506
|
+
* The parse in progress, for inline positions (paragraphs, table cells): a
|
|
507
|
+
* stripped component there loses its content as surely as a block one, but
|
|
508
|
+
* `inlineTokensToInline` has no reporter of its own. Set for the duration of
|
|
509
|
+
* one `starlightToTree` call (parsing is synchronous).
|
|
510
|
+
*/
|
|
511
|
+
let inlineContext;
|
|
433
512
|
function inlineTokensToInline(tokens) {
|
|
434
513
|
const result = [];
|
|
435
514
|
let bold = false;
|
|
436
515
|
let italic = false;
|
|
437
516
|
let inLink = false;
|
|
438
517
|
let linkHref = "";
|
|
518
|
+
let linkTitle;
|
|
439
519
|
let linkChildren = [];
|
|
520
|
+
/** The inline component that opened the current link (`<LinkButton>`), if any. */
|
|
521
|
+
let inlineButton;
|
|
440
522
|
const push = (node) => {
|
|
441
523
|
if (inLink)
|
|
442
524
|
linkChildren.push(node);
|
|
@@ -467,14 +549,17 @@ function inlineTokensToInline(tokens) {
|
|
|
467
549
|
}
|
|
468
550
|
else if (token.type === "link_open") {
|
|
469
551
|
linkHref = token.attrGet("href") || "";
|
|
552
|
+
linkTitle = token.attrGet("title") || undefined;
|
|
470
553
|
linkChildren = [];
|
|
471
554
|
inLink = true;
|
|
472
555
|
}
|
|
473
556
|
else if (token.type === "link_close") {
|
|
474
557
|
if (inLink) {
|
|
558
|
+
// The Markdown title (`[x](href "Title")`) was dropped with the rest.
|
|
475
559
|
result.push({
|
|
476
560
|
type: "link",
|
|
477
561
|
href: linkHref,
|
|
562
|
+
...(linkTitle ? { title: linkTitle } : {}),
|
|
478
563
|
children: linkChildren,
|
|
479
564
|
});
|
|
480
565
|
inLink = false;
|
|
@@ -483,21 +568,67 @@ function inlineTokensToInline(tokens) {
|
|
|
483
568
|
}
|
|
484
569
|
}
|
|
485
570
|
else if (token.type === "image") {
|
|
571
|
+
// The title (``) is kept: Cloudflare's pipeline
|
|
572
|
+
// renders a titled image as a figure with the title as its caption
|
|
573
|
+
// (256 on the corpus, "Figure 1: …"); dropped, the caption was lost.
|
|
574
|
+
const title = token.attrGet("title");
|
|
486
575
|
push({
|
|
487
576
|
type: "image",
|
|
488
577
|
src: token.attrGet("src") || "",
|
|
489
578
|
alt: token.content || token.attrGet("alt") || "",
|
|
579
|
+
...(title ? { title } : {}),
|
|
490
580
|
});
|
|
491
581
|
}
|
|
582
|
+
else if (token.type === "footnote_ref") {
|
|
583
|
+
const meta = (token.meta ?? {});
|
|
584
|
+
push({ type: "footnote-ref", label: meta.label ?? String((meta.id ?? 0) + 1) });
|
|
585
|
+
}
|
|
492
586
|
else if (token.type === "html_inline") {
|
|
493
|
-
|
|
587
|
+
// Raw inline HTML (`<br/>`, `<code>`, `<a href>`), not text: as a text
|
|
588
|
+
// node every serializer escaped it, and 749 Cloudflare pages showed
|
|
589
|
+
// literal `<br/>` and `<code>` to readers.
|
|
590
|
+
push({ type: "html-inline", html: token.content });
|
|
494
591
|
}
|
|
495
592
|
else if (token.type === "jsx_inline_self_closing" ||
|
|
496
593
|
token.type === "jsx_inline_open" ||
|
|
497
594
|
token.type === "jsx_inline_close") {
|
|
498
|
-
// Inline JSX
|
|
595
|
+
// Inline JSX. A badge or a link button mid-sentence becomes its inline
|
|
596
|
+
// form (they were skipped: the badge's text and the button's href were
|
|
597
|
+
// simply gone). Other components are skipped, and a strip that loses
|
|
598
|
+
// content is REPORTED here too (once, at its opener).
|
|
599
|
+
const mapping = inlineContext?.componentMap[token.tag];
|
|
600
|
+
const attrs = (token.meta?.attrs ?? {});
|
|
601
|
+
if (mapping?.type === "badge" && token.type === "jsx_inline_self_closing") {
|
|
602
|
+
const props = mapping.propsFromAttrs?.(attrs) ?? {};
|
|
603
|
+
push({ type: "badge", text: String(props.text ?? ""), ...(props.variant ? { variant: String(props.variant) } : {}) });
|
|
604
|
+
}
|
|
605
|
+
else if (mapping?.type === "link-button" && token.type === "jsx_inline_open" && !inLink) {
|
|
606
|
+
linkHref = String(mapping.propsFromAttrs?.(attrs)?.href ?? "");
|
|
607
|
+
linkChildren = [];
|
|
608
|
+
inLink = true;
|
|
609
|
+
inlineButton = token.tag;
|
|
610
|
+
}
|
|
611
|
+
else if (token.type === "jsx_inline_close" && inLink && inlineButton === token.tag) {
|
|
612
|
+
result.push({ type: "link", href: linkHref, children: linkChildren });
|
|
613
|
+
inLink = false;
|
|
614
|
+
inlineButton = undefined;
|
|
615
|
+
linkHref = "";
|
|
616
|
+
linkChildren = [];
|
|
617
|
+
}
|
|
618
|
+
else if (token.type !== "jsx_inline_close" && mapping?.strip && !mapping.container && !mapping.silent) {
|
|
619
|
+
inlineContext?.onDrop?.(token.tag);
|
|
620
|
+
}
|
|
621
|
+
else if (token.type === "jsx_inline_self_closing" && mapping && !mapping.strip) {
|
|
622
|
+
// A block component (<YouTube>, <LinkCard>) written mid-line has no
|
|
623
|
+
// inline form: lost here, so say so.
|
|
624
|
+
inlineContext?.onDrop?.(token.tag);
|
|
625
|
+
}
|
|
499
626
|
}
|
|
500
627
|
}
|
|
628
|
+
// A <LinkButton> whose closer never came (another paragraph): its label
|
|
629
|
+
// and everything after it were buffered as link text and dropped.
|
|
630
|
+
if (inlineButton)
|
|
631
|
+
result.push(...linkChildren);
|
|
501
632
|
return result;
|
|
502
633
|
}
|
|
503
634
|
// ── Post-processing ─────────────────────────────────────
|
|
@@ -542,6 +673,240 @@ function groupConsecutiveCards(tree) {
|
|
|
542
673
|
flushCards();
|
|
543
674
|
return result;
|
|
544
675
|
}
|
|
676
|
+
// ── Main entry point ────────────────────────────────────
|
|
677
|
+
/**
|
|
678
|
+
* MDX reads the TEXT inside JSX/HTML elements as Markdown; a raw HTML block
|
|
679
|
+
* here is emitted as it is. Two Markdown forms in that text then broke the
|
|
680
|
+
* page: a code span (`` `Array<String>` `` in a `<td>` — the browser read
|
|
681
|
+
* `<String>` as a tag and the type vanished; 10 on the Cloudflare corpus) and
|
|
682
|
+
* a backslash escape (`Promise\<InstanceStatus>` inside `<code>` showed the
|
|
683
|
+
* backslash). Both become their HTML: `<code>` with escaped text, `<`.
|
|
684
|
+
* Only TEXT between tags is touched — never inside a tag (attribute values)
|
|
685
|
+
* or inside `<pre>`, `<script>`, `<style>`.
|
|
686
|
+
*/
|
|
687
|
+
export function mdxHtmlText(html) {
|
|
688
|
+
if (!html.includes("`") && !html.includes("\\<"))
|
|
689
|
+
return html;
|
|
690
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
691
|
+
let out = "";
|
|
692
|
+
let i = 0;
|
|
693
|
+
let raw = null; // inside <pre>/<script>/<style>: verbatim
|
|
694
|
+
while (i < html.length) {
|
|
695
|
+
const c = html[i];
|
|
696
|
+
// A code span first, as Markdown reads it: `Array<String>` holds a `<`
|
|
697
|
+
// that is NOT a tag.
|
|
698
|
+
if (c === "`" && raw === null) {
|
|
699
|
+
const run = /^`+/.exec(html.slice(i))[0];
|
|
700
|
+
const close = html.indexOf(run, i + run.length);
|
|
701
|
+
if (close > i && html[close + run.length] !== "`" && !html.slice(i + run.length, close).includes("\n\n")) {
|
|
702
|
+
let code = html.slice(i + run.length, close);
|
|
703
|
+
if (/^ .* $/s.test(code) && code.trim())
|
|
704
|
+
code = code.slice(1, -1);
|
|
705
|
+
out += `<code>${esc(code)}</code>`;
|
|
706
|
+
i = close + run.length;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
out += run;
|
|
710
|
+
i += run.length;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (c === "\\" && html[i + 1] === "<" && raw === null) {
|
|
714
|
+
out += "<";
|
|
715
|
+
i += 2;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (c === "<") {
|
|
719
|
+
const m = /^<(\/?)([A-Za-z][\w-]*)\b(?:[^<>"']|"[^"]*"|'[^']*')*>/.exec(html.slice(i));
|
|
720
|
+
if (m) {
|
|
721
|
+
const name = m[2].toLowerCase();
|
|
722
|
+
if (raw === null && !m[1] && ["pre", "script", "style"].includes(name))
|
|
723
|
+
raw = name;
|
|
724
|
+
else if (raw === name && m[1])
|
|
725
|
+
raw = null;
|
|
726
|
+
out += m[0];
|
|
727
|
+
i += m[0].length;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
out += c;
|
|
732
|
+
i++;
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* MDX has no HTML blocks: every tag line is its own JSX element. Markdown's
|
|
738
|
+
* HTML block runs to the next BLANK line, so a component written straight
|
|
739
|
+
* after a `<p>…</p>` line was swallowed into it and leaked as a tag
|
|
740
|
+
* (`<AnchorHeading>`, `<Aside>` in Cloudflare's JSX branches). A blank line
|
|
741
|
+
* goes between them — only after a line that is one COMPLETE element, so an
|
|
742
|
+
* open `<table>` or `<ul>` that the component sits inside is left whole.
|
|
743
|
+
*/
|
|
744
|
+
export function endHtmlBlocksAtComponents(source) {
|
|
745
|
+
const lines = source.split("\n");
|
|
746
|
+
let fence = null;
|
|
747
|
+
let prevComplete = false;
|
|
748
|
+
// Inside a multi-line template literal (`code={`…`}`) a blank line would
|
|
749
|
+
// change the value: an odd count of backticks on a line toggles it.
|
|
750
|
+
let inTemplate = false;
|
|
751
|
+
for (let i = 0; i < lines.length; i++) {
|
|
752
|
+
const line = lines[i];
|
|
753
|
+
if (!fence && ((line.match(/(?<!\\)`/g)?.length ?? 0) % 2 === 1) && !/^[ \t]*(`{3,}|~{3,})/.test(line)) {
|
|
754
|
+
inTemplate = !inTemplate;
|
|
755
|
+
prevComplete = false;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (inTemplate)
|
|
759
|
+
continue;
|
|
760
|
+
const m = /^[ \t]*(`{3,}|~{3,})/.exec(line);
|
|
761
|
+
if (fence) {
|
|
762
|
+
if (m && m[1][0] === fence[0] && m[1].length >= fence.length && line.trim() === m[1])
|
|
763
|
+
fence = null;
|
|
764
|
+
prevComplete = false;
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (m) {
|
|
768
|
+
fence = m[1];
|
|
769
|
+
prevComplete = false;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const t = line.trim();
|
|
773
|
+
if (prevComplete && /^<\/?[A-Z]/.test(t))
|
|
774
|
+
lines[i] = `\n${line}`;
|
|
775
|
+
// CommonMark type-6 names only: `<code>…</code>` on its own line is a
|
|
776
|
+
// paragraph line, not an HTML block, and must stay joined to its text.
|
|
777
|
+
const el = /^<(p|div|table|ul|ol|dl|pre|blockquote|h[1-6]|section|details|figure|hr|img)\b[^>]*>/.exec(t);
|
|
778
|
+
// A lone CLOSER of a block element (`</table>`) ends the element too: a
|
|
779
|
+
// component on the next line was swallowed by the HTML block
|
|
780
|
+
// (`<Aside>` after a raw table on the rules-language operators page).
|
|
781
|
+
// Whole blocks only: `</tr>` between rows would split a table.
|
|
782
|
+
const closer = /^<\/(p|div|table|ul|ol|dl|pre|blockquote|section|details|figure)\s*>$/.test(t);
|
|
783
|
+
prevComplete = closer || (!!el && (/\/>$/.test(el[0]) ? t === el[0] : new RegExp(`</${el[1]}\\s*>$`).test(t)));
|
|
784
|
+
}
|
|
785
|
+
return lines.join("\n");
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* `<Feature header="…"> Paid add-on` — text after a CONTAINER's opener on
|
|
789
|
+
* its line — is the start of the body in MDX. The block rule took the line
|
|
790
|
+
* as the opener and the text was lost with no report (web3: two plan badges).
|
|
791
|
+
* It moves to its own line, at the opener's indent. A component after the
|
|
792
|
+
* opener is `normalizeJsxLines`' job; a closer on the same line is an inline
|
|
793
|
+
* use and is left alone.
|
|
794
|
+
*/
|
|
795
|
+
export function splitContainerOpenerText(source, isContainer) {
|
|
796
|
+
let fence = null;
|
|
797
|
+
return source
|
|
798
|
+
.split("\n")
|
|
799
|
+
.map((line) => {
|
|
800
|
+
const f = /^[ \t]*(`{3,}|~{3,})/.exec(line);
|
|
801
|
+
if (fence) {
|
|
802
|
+
if (f && f[1][0] === fence[0] && f[1].length >= fence.length && line.trim() === f[1])
|
|
803
|
+
fence = null;
|
|
804
|
+
return line;
|
|
805
|
+
}
|
|
806
|
+
if (f) {
|
|
807
|
+
fence = f[1];
|
|
808
|
+
return line;
|
|
809
|
+
}
|
|
810
|
+
// `<Tabs syncKey='x'><TabItem label='CLI'>` and `</TabItem><TabItem …>`:
|
|
811
|
+
// ADJACENT container tags with no space between them are one line to
|
|
812
|
+
// the block rule, which took the first and lost the second (kv
|
|
813
|
+
// get-started lost five tab groups). Split them, containers only — an
|
|
814
|
+
// inline `<Badge>a</Badge><Badge>` must stay on its line.
|
|
815
|
+
// Only a line that STARTS with the tag (not prose, not a list marker),
|
|
816
|
+
// and never a table row or a line with a code span.
|
|
817
|
+
const tag = /<\/?([A-Z]\w*)\b(?:[^<>"'{]|"[^"]*"|'[^']*'|\{[^{}]*\})*>/g;
|
|
818
|
+
const parts = [];
|
|
819
|
+
let last = 0;
|
|
820
|
+
const splittable = /^[ \t]*<\/?[A-Z]/.test(line) && !/^[ \t]*\|/.test(line) && !line.includes("`");
|
|
821
|
+
for (let t = splittable ? tag.exec(line) : null; t; t = tag.exec(line)) {
|
|
822
|
+
const next = /^<\/?([A-Z]\w*)/.exec(line.slice(tag.lastIndex));
|
|
823
|
+
if (next && isContainer(t[1]) && isContainer(next[1]) && !/\/>$/.test(t[0])) {
|
|
824
|
+
parts.push(line.slice(last, tag.lastIndex));
|
|
825
|
+
last = tag.lastIndex;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (parts.length) {
|
|
829
|
+
const indent = /^[ \t]*/.exec(line)[0];
|
|
830
|
+
// The last part may be an opener with text after it: that rule too.
|
|
831
|
+
return [...parts, openerText(indent + line.slice(last))].map((p, n) => (n ? (n === parts.length ? p : indent + p) : p)).join("\n");
|
|
832
|
+
}
|
|
833
|
+
return openerText(line);
|
|
834
|
+
})
|
|
835
|
+
.join("\n");
|
|
836
|
+
function openerText(line) {
|
|
837
|
+
const m = /^([ \t]*)<([A-Z]\w*)\b((?:[^<>"'{]|"[^"]*"|'[^']*'|\{[^{}]*\})*)>[ \t]*(\S.*)$/.exec(line);
|
|
838
|
+
if (!m || m[3].trimEnd().endsWith("/") || !isContainer(m[2]) || /^<\/?[A-Z]/.test(m[4]) || line.includes(`</${m[2]}>`))
|
|
839
|
+
return line;
|
|
840
|
+
return `${m[1]}<${m[2]}${m[3]}>\n${m[1]}${m[4]}`;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* MDX (micromark with indented code off) opens AND closes a fence at any
|
|
845
|
+
* indentation; markdown-it refuses either one indented four or more past its
|
|
846
|
+
* container. Under a tab-indented list item that made every fence a
|
|
847
|
+
* paragraph — its code an inline span, its structure gone.
|
|
848
|
+
*
|
|
849
|
+
* This is markdown-it's fence rule with only those two refusals removed. It
|
|
850
|
+
* still ends at a line indented less than the CONTAINER (not the fence), and
|
|
851
|
+
* strips at most the opener's indent from each line, as micromark does. (An
|
|
852
|
+
* earlier version raised `blkIndent` to the opener's indent instead, which
|
|
853
|
+
* cut a fence short at any body line or closer indented less than its opener
|
|
854
|
+
* — and the orphaned closer then opened a fence that swallowed the item.)
|
|
855
|
+
*/
|
|
856
|
+
function acceptIndentedFences(md) {
|
|
857
|
+
const ruler = md.block.ruler;
|
|
858
|
+
const alt = ruler.__rules__.find((r) => r.name === "fence")?.alt ?? [];
|
|
859
|
+
md.block.ruler.at("fence", mdxFence, { alt });
|
|
860
|
+
}
|
|
861
|
+
function mdxFence(state, startLine, endLine, silent) {
|
|
862
|
+
let pos = state.bMarks[startLine] + state.tShift[startLine];
|
|
863
|
+
let max = state.eMarks[startLine];
|
|
864
|
+
if (pos + 3 > max)
|
|
865
|
+
return false;
|
|
866
|
+
const marker = state.src.charCodeAt(pos);
|
|
867
|
+
if (marker !== 0x7e /* ~ */ && marker !== 0x60 /* ` */)
|
|
868
|
+
return false;
|
|
869
|
+
let mem = pos;
|
|
870
|
+
pos = state.skipChars(pos, marker);
|
|
871
|
+
const len = pos - mem;
|
|
872
|
+
if (len < 3)
|
|
873
|
+
return false;
|
|
874
|
+
const markup = state.src.slice(mem, pos);
|
|
875
|
+
const params = state.src.slice(pos, max);
|
|
876
|
+
if (marker === 0x60 && params.includes("`"))
|
|
877
|
+
return false;
|
|
878
|
+
if (silent)
|
|
879
|
+
return true;
|
|
880
|
+
let nextLine = startLine;
|
|
881
|
+
let haveEndMarker = false;
|
|
882
|
+
for (;;) {
|
|
883
|
+
nextLine++;
|
|
884
|
+
if (nextLine >= endLine)
|
|
885
|
+
break;
|
|
886
|
+
pos = mem = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
887
|
+
max = state.eMarks[nextLine];
|
|
888
|
+
// A non-empty line outdented past the container ends it (and the fence).
|
|
889
|
+
if (pos < max && state.sCount[nextLine] < state.blkIndent)
|
|
890
|
+
break;
|
|
891
|
+
if (state.src.charCodeAt(pos) !== marker)
|
|
892
|
+
continue;
|
|
893
|
+
pos = state.skipChars(pos, marker);
|
|
894
|
+
if (pos - mem < len)
|
|
895
|
+
continue;
|
|
896
|
+
pos = state.skipSpaces(pos);
|
|
897
|
+
if (pos < max)
|
|
898
|
+
continue;
|
|
899
|
+
haveEndMarker = true;
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
state.line = nextLine + (haveEndMarker ? 1 : 0);
|
|
903
|
+
const token = state.push("fence", "code", 0);
|
|
904
|
+
token.info = params;
|
|
905
|
+
token.content = state.getLines(startLine + 1, nextLine, state.sCount[startLine], true);
|
|
906
|
+
token.markup = markup;
|
|
907
|
+
token.map = [startLine, state.line];
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
545
910
|
export function starlightToTree(source, options) {
|
|
546
911
|
const adapter = options?.adapter;
|
|
547
912
|
const onDrop = options?.onDrop;
|
|
@@ -551,32 +916,73 @@ export function starlightToTree(source, options) {
|
|
|
551
916
|
// `---...---` as thematic-break + paragraph + thematic-break, which leaks the
|
|
552
917
|
// YAML into the rendered body. Frontmatter is extracted separately at the
|
|
553
918
|
// CLI level; here we only need to remove it from the content stream.
|
|
554
|
-
const
|
|
919
|
+
const extracted = extractFrontmatter(source);
|
|
920
|
+
const body = extracted.body;
|
|
921
|
+
const frontmatter = options?.frontmatter ?? extracted.raw;
|
|
555
922
|
// Pre-process source
|
|
556
923
|
// Normalize tabs to spaces — MDX source often mixes tabs and spaces
|
|
557
924
|
let cleaned = body.replace(/\t/g, " ");
|
|
558
925
|
// Resolve include/partial tags via adapter (e.g. Cloudflare's <Render>)
|
|
559
926
|
if (adapter?.resolveIncludes) {
|
|
560
|
-
cleaned = adapter.resolveIncludes(cleaned, options?.partialsDir);
|
|
927
|
+
cleaned = adapter.resolveIncludes(cleaned, options?.partialsDir, options?.includeOptions, frontmatter);
|
|
561
928
|
}
|
|
562
929
|
cleaned = applyInlineTransforms(cleaned, adapter);
|
|
563
930
|
cleaned = rewriteAssetPaths(cleaned);
|
|
564
931
|
cleaned = convertDirectives(cleaned);
|
|
565
|
-
// Strip raw HTML div wrappers
|
|
566
|
-
|
|
567
|
-
|
|
932
|
+
// Strip raw HTML div wrappers — outside code. A `<div>{value}</div>` in an
|
|
933
|
+
// Astro example lost its `</div>` AND the newline after it, which glued the
|
|
934
|
+
// closing fence to the code line, so the block swallowed the rest of the page.
|
|
935
|
+
cleaned = outsideFences(cleaned, (text) => text.replace(/<div[^>]*>\s*\n?/g, "").replace(/<\/div>\s*\n?/g, ""));
|
|
568
936
|
// Normalize JSX lines — split multi-tag lines for the line-based parser
|
|
569
937
|
cleaned = normalizeJsxLines(cleaned);
|
|
938
|
+
cleaned = endHtmlBlocksAtComponents(cleaned);
|
|
939
|
+
cleaned = splitContainerOpenerText(cleaned, (tag) => componentMap[tag]?.container === true);
|
|
570
940
|
// Create markdown-it instance with JSX plugin
|
|
571
941
|
// The plugin handles import stripping, JSX comments, and component tokenization
|
|
572
942
|
const md = new MarkdownIt({ html: true, linkify: true }).use(mdxJsx, {
|
|
573
943
|
components,
|
|
574
944
|
selfClosing,
|
|
945
|
+
// `<Badge text="Beta" /> Connected meetings…` is a paragraph, as in MDX
|
|
946
|
+
// (the rest of such a line was dropped); inline components are handled or
|
|
947
|
+
// reported in inlineTokensToInline.
|
|
948
|
+
inlineWhenFollowedByText: true,
|
|
575
949
|
});
|
|
950
|
+
// MDX has no indented code blocks (only fences). With the rule on, prose
|
|
951
|
+
// indented under a list item — common in Cloudflare's tab-indented sources —
|
|
952
|
+
// became a `plaintext` code block, and anything in it (a partial's include)
|
|
953
|
+
// showed literally.
|
|
954
|
+
md.disable("code");
|
|
955
|
+
acceptIndentedFences(md);
|
|
956
|
+
// GFM footnotes, as MDX (remark-gfm) reads them: `[^1]` + `[^1]: …`.
|
|
957
|
+
// Without the plugin a definition was a link REFERENCE definition, so
|
|
958
|
+
// `[^1]` became a link whose URL was the footnote's text. The `^[…]`
|
|
959
|
+
// inline sugar is not GFM, so it stays off.
|
|
960
|
+
md.use(mdFootnote);
|
|
961
|
+
md.inline.ruler.disable("footnote_inline");
|
|
576
962
|
// Parse into tokens (including jsx_* tokens)
|
|
577
|
-
const
|
|
963
|
+
const env = {};
|
|
964
|
+
const tokens = md.parse(cleaned, env);
|
|
965
|
+
// GFM drops a definition nothing references, and so does the plugin. Say
|
|
966
|
+
// so: a reference inside raw HTML (`<td>…[^1]</td>`, which MDX reads as
|
|
967
|
+
// Markdown) is not seen here, and its note silently vanished.
|
|
968
|
+
for (const [key, id] of Object.entries(env.footnotes?.refs ?? {})) {
|
|
969
|
+
if (id === -1)
|
|
970
|
+
onDrop?.(`footnote [^${key.slice(1)}] with no reference`);
|
|
971
|
+
}
|
|
578
972
|
// Convert tokens to tree
|
|
579
|
-
const
|
|
973
|
+
const outer = inlineContext;
|
|
974
|
+
inlineContext = { componentMap, onDrop };
|
|
975
|
+
let tree;
|
|
976
|
+
try {
|
|
977
|
+
tree = tokensToTree(tokens, componentMap, onDrop, describeComponent);
|
|
978
|
+
}
|
|
979
|
+
finally {
|
|
980
|
+
inlineContext = outer;
|
|
981
|
+
}
|
|
580
982
|
const unwrapped = unwrapStepsOrderedList(tree);
|
|
581
|
-
|
|
983
|
+
const grouped = groupConsecutiveCards(unwrapped);
|
|
984
|
+
const final = adapter?.transformTree ? adapter.transformTree(grouped) : grouped;
|
|
985
|
+
// Starlight's ids (github-slugger, per page): imported links target them.
|
|
986
|
+
assignHeadingSlugs(final);
|
|
987
|
+
return final;
|
|
582
988
|
}
|