@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
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,17 +126,31 @@ 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
|
-
* Rewrite
|
|
139
|
+
* Rewrite the `~/assets/` Vite alias to the `/_assets/` mount convention.
|
|
140
|
+
*
|
|
141
|
+
* It used to produce a bare relative `assets/…`, which resolves to nothing:
|
|
142
|
+
* the page lives at `/ai-search/get-started/workers`, so a relative
|
|
143
|
+
* `assets/images/x.png` is looked for under that directory, and no exporter
|
|
144
|
+
* ever put a file there. Every local image on a converted Starlight site was a
|
|
145
|
+
* 404 — the reference survived the conversion and the file did not.
|
|
146
|
+
*
|
|
147
|
+
* `/_assets/…` is the repo-wide convention (see `assetMounts` in
|
|
148
|
+
* format-astro's project emitter): a mounted directory's CONTENTS land in
|
|
149
|
+
* `public/_assets/`, and importers rewrite refs to match — the same thing the
|
|
150
|
+
* Docusaurus importer does with a `static/` dir.
|
|
109
151
|
*/
|
|
110
152
|
function rewriteAssetPaths(source) {
|
|
111
|
-
return source.replace(/~\/assets\//g, "
|
|
153
|
+
return source.replace(/~\/assets\//g, "/_assets/");
|
|
112
154
|
}
|
|
113
155
|
/**
|
|
114
156
|
* Convert Starlight :::note / :::caution / :::tip / :::danger directives
|
|
@@ -149,7 +191,7 @@ function convertDirectives(source) {
|
|
|
149
191
|
* Convert markdown-it tokens (including jsx_* tokens) to TreeNode[].
|
|
150
192
|
* This replaces both segmentsToTree and tokensToTree from the old parser.
|
|
151
193
|
*/
|
|
152
|
-
function tokensToTree(tokens, componentMap) {
|
|
194
|
+
function tokensToTree(tokens, componentMap, onDrop, describeComponent) {
|
|
153
195
|
const tree = [];
|
|
154
196
|
let i = 0;
|
|
155
197
|
while (i < tokens.length) {
|
|
@@ -163,6 +205,11 @@ function tokensToTree(tokens, componentMap) {
|
|
|
163
205
|
const props = mapping.propsFromAttrs?.(attrs) || {};
|
|
164
206
|
tree.push({ type: mapping.type, props, children: [] });
|
|
165
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
|
+
}
|
|
166
213
|
}
|
|
167
214
|
i++;
|
|
168
215
|
continue;
|
|
@@ -192,20 +239,23 @@ function tokensToTree(tokens, componentMap) {
|
|
|
192
239
|
if (mapping.strip) {
|
|
193
240
|
// Strip but keep inner content parsed
|
|
194
241
|
if (mapping.container && children.length > 0) {
|
|
195
|
-
const innerNodes = tokensToTree(children, componentMap);
|
|
242
|
+
const innerNodes = tokensToTree(children, componentMap, onDrop, describeComponent);
|
|
196
243
|
tree.push(...innerNodes);
|
|
197
244
|
}
|
|
245
|
+
else if (!mapping.container && !mapping.silent) {
|
|
246
|
+
onDrop?.(tag);
|
|
247
|
+
}
|
|
198
248
|
}
|
|
199
249
|
else {
|
|
200
250
|
const attrs = token.meta?.attrs || {};
|
|
201
251
|
const props = mapping.propsFromAttrs?.(attrs) || {};
|
|
202
|
-
const childNodes = tokensToTree(children, componentMap);
|
|
252
|
+
const childNodes = tokensToTree(children, componentMap, onDrop, describeComponent);
|
|
203
253
|
tree.push({ type: mapping.type, props, children: childNodes });
|
|
204
254
|
}
|
|
205
255
|
}
|
|
206
256
|
else {
|
|
207
257
|
// Unknown component — parse inner content as markdown
|
|
208
|
-
const childNodes = tokensToTree(children, componentMap);
|
|
258
|
+
const childNodes = tokensToTree(children, componentMap, onDrop, describeComponent);
|
|
209
259
|
tree.push(...childNodes);
|
|
210
260
|
}
|
|
211
261
|
continue;
|
|
@@ -299,7 +349,7 @@ function tokensToTree(tokens, componentMap) {
|
|
|
299
349
|
}
|
|
300
350
|
// Recursively parse the list item content using tokensToTree
|
|
301
351
|
// This handles nested lists, JSX components, paragraphs, etc.
|
|
302
|
-
const itemChildren = tokensToTree(itemTokens, componentMap);
|
|
352
|
+
const itemChildren = tokensToTree(itemTokens, componentMap, onDrop, describeComponent);
|
|
303
353
|
items.push({ type: "list-item", children: itemChildren });
|
|
304
354
|
i++; // skip list_item_close
|
|
305
355
|
}
|
|
@@ -324,26 +374,74 @@ function tokensToTree(tokens, componentMap) {
|
|
|
324
374
|
continue;
|
|
325
375
|
}
|
|
326
376
|
if (token.type === "blockquote_open") {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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;
|
|
332
387
|
i++;
|
|
333
388
|
}
|
|
389
|
+
const children = tokensToTree(tokens.slice(start, i), componentMap, onDrop, describeComponent);
|
|
334
390
|
i++; // skip close
|
|
335
391
|
tree.push({ type: "blockquote", children });
|
|
336
392
|
continue;
|
|
337
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
|
+
}
|
|
338
421
|
if (token.type === "html_block") {
|
|
339
422
|
// Check if this is a leftover JSX component that wasn't caught
|
|
340
423
|
const trimmed = token.content.trim();
|
|
341
424
|
if (trimmed.match(/^<[A-Z]/)) {
|
|
342
|
-
//
|
|
425
|
+
// A JSX component nothing claimed. Dropping it is the existing
|
|
426
|
+
// behaviour; reporting it is new — silence here is what let five
|
|
427
|
+
// diagrams disappear from a converted section unnoticed.
|
|
428
|
+
const tag = trimmed.match(/^<([A-Za-z0-9]+)/)?.[1] ?? "unknown";
|
|
429
|
+
const description = describeComponent?.(tag);
|
|
430
|
+
if (description) {
|
|
431
|
+
// Keep the MEANING even though the markup cannot travel.
|
|
432
|
+
tree.push({
|
|
433
|
+
type: "callout",
|
|
434
|
+
props: { variant: "note", title: "Diagram" },
|
|
435
|
+
children: [{ type: "paragraph", inline: [{ type: "text", text: description }] }],
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
onDrop?.(tag);
|
|
440
|
+
}
|
|
343
441
|
i++;
|
|
344
442
|
continue;
|
|
345
443
|
}
|
|
346
|
-
tree.push({ type: "html", html: token.content });
|
|
444
|
+
tree.push({ type: "html", html: mdxHtmlText(token.content) });
|
|
347
445
|
i++;
|
|
348
446
|
continue;
|
|
349
447
|
}
|
|
@@ -404,13 +502,23 @@ function parseTable(tokens, start) {
|
|
|
404
502
|
};
|
|
405
503
|
}
|
|
406
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;
|
|
407
512
|
function inlineTokensToInline(tokens) {
|
|
408
513
|
const result = [];
|
|
409
514
|
let bold = false;
|
|
410
515
|
let italic = false;
|
|
411
516
|
let inLink = false;
|
|
412
517
|
let linkHref = "";
|
|
518
|
+
let linkTitle;
|
|
413
519
|
let linkChildren = [];
|
|
520
|
+
/** The inline component that opened the current link (`<LinkButton>`), if any. */
|
|
521
|
+
let inlineButton;
|
|
414
522
|
const push = (node) => {
|
|
415
523
|
if (inLink)
|
|
416
524
|
linkChildren.push(node);
|
|
@@ -441,14 +549,17 @@ function inlineTokensToInline(tokens) {
|
|
|
441
549
|
}
|
|
442
550
|
else if (token.type === "link_open") {
|
|
443
551
|
linkHref = token.attrGet("href") || "";
|
|
552
|
+
linkTitle = token.attrGet("title") || undefined;
|
|
444
553
|
linkChildren = [];
|
|
445
554
|
inLink = true;
|
|
446
555
|
}
|
|
447
556
|
else if (token.type === "link_close") {
|
|
448
557
|
if (inLink) {
|
|
558
|
+
// The Markdown title (`[x](href "Title")`) was dropped with the rest.
|
|
449
559
|
result.push({
|
|
450
560
|
type: "link",
|
|
451
561
|
href: linkHref,
|
|
562
|
+
...(linkTitle ? { title: linkTitle } : {}),
|
|
452
563
|
children: linkChildren,
|
|
453
564
|
});
|
|
454
565
|
inLink = false;
|
|
@@ -457,21 +568,67 @@ function inlineTokensToInline(tokens) {
|
|
|
457
568
|
}
|
|
458
569
|
}
|
|
459
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");
|
|
460
575
|
push({
|
|
461
576
|
type: "image",
|
|
462
577
|
src: token.attrGet("src") || "",
|
|
463
578
|
alt: token.content || token.attrGet("alt") || "",
|
|
579
|
+
...(title ? { title } : {}),
|
|
464
580
|
});
|
|
465
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
|
+
}
|
|
466
586
|
else if (token.type === "html_inline") {
|
|
467
|
-
|
|
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 });
|
|
468
591
|
}
|
|
469
592
|
else if (token.type === "jsx_inline_self_closing" ||
|
|
470
593
|
token.type === "jsx_inline_open" ||
|
|
471
594
|
token.type === "jsx_inline_close") {
|
|
472
|
-
// 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
|
+
}
|
|
473
626
|
}
|
|
474
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);
|
|
475
632
|
return result;
|
|
476
633
|
}
|
|
477
634
|
// ── Post-processing ─────────────────────────────────────
|
|
@@ -516,39 +673,316 @@ function groupConsecutiveCards(tree) {
|
|
|
516
673
|
flushCards();
|
|
517
674
|
return result;
|
|
518
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
|
+
}
|
|
519
910
|
export function starlightToTree(source, options) {
|
|
520
911
|
const adapter = options?.adapter;
|
|
912
|
+
const onDrop = options?.onDrop;
|
|
913
|
+
const describeComponent = options?.describeComponent;
|
|
521
914
|
const { components, selfClosing, map: componentMap } = buildComponentMap(adapter);
|
|
522
915
|
// Strip frontmatter block before parsing. Without this, markdown-it sees
|
|
523
916
|
// `---...---` as thematic-break + paragraph + thematic-break, which leaks the
|
|
524
917
|
// YAML into the rendered body. Frontmatter is extracted separately at the
|
|
525
918
|
// CLI level; here we only need to remove it from the content stream.
|
|
526
|
-
const
|
|
919
|
+
const extracted = extractFrontmatter(source);
|
|
920
|
+
const body = extracted.body;
|
|
921
|
+
const frontmatter = options?.frontmatter ?? extracted.raw;
|
|
527
922
|
// Pre-process source
|
|
528
923
|
// Normalize tabs to spaces — MDX source often mixes tabs and spaces
|
|
529
924
|
let cleaned = body.replace(/\t/g, " ");
|
|
530
925
|
// Resolve include/partial tags via adapter (e.g. Cloudflare's <Render>)
|
|
531
926
|
if (adapter?.resolveIncludes) {
|
|
532
|
-
cleaned = adapter.resolveIncludes(cleaned, options?.partialsDir);
|
|
927
|
+
cleaned = adapter.resolveIncludes(cleaned, options?.partialsDir, options?.includeOptions, frontmatter);
|
|
533
928
|
}
|
|
534
929
|
cleaned = applyInlineTransforms(cleaned, adapter);
|
|
535
930
|
cleaned = rewriteAssetPaths(cleaned);
|
|
536
931
|
cleaned = convertDirectives(cleaned);
|
|
537
|
-
// Strip raw HTML div wrappers
|
|
538
|
-
|
|
539
|
-
|
|
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, ""));
|
|
540
936
|
// Normalize JSX lines — split multi-tag lines for the line-based parser
|
|
541
937
|
cleaned = normalizeJsxLines(cleaned);
|
|
938
|
+
cleaned = endHtmlBlocksAtComponents(cleaned);
|
|
939
|
+
cleaned = splitContainerOpenerText(cleaned, (tag) => componentMap[tag]?.container === true);
|
|
542
940
|
// Create markdown-it instance with JSX plugin
|
|
543
941
|
// The plugin handles import stripping, JSX comments, and component tokenization
|
|
544
942
|
const md = new MarkdownIt({ html: true, linkify: true }).use(mdxJsx, {
|
|
545
943
|
components,
|
|
546
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,
|
|
547
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");
|
|
548
962
|
// Parse into tokens (including jsx_* tokens)
|
|
549
|
-
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
|
+
}
|
|
550
972
|
// Convert tokens to tree
|
|
551
|
-
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
|
+
}
|
|
552
982
|
const unwrapped = unwrapStepsOrderedList(tree);
|
|
553
|
-
|
|
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;
|
|
554
988
|
}
|