@dogsbay/format-starlight 0.2.0-beta.12 → 0.2.0-beta.120

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/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
- Tabs: { type: "tabs", container: true },
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
- let result = source;
102
- for (const [regex, replacement] of adapter.inlineTransforms) {
103
- result = result.replace(regex, replacement);
104
- }
105
- return result;
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 ~/assets/ Vite alias to relative path.
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, "assets/");
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
- const children = [];
328
- i++;
329
- while (i < tokens.length && tokens[i].type !== "blockquote_close") {
330
- const nested = tokensToTree(tokens.slice(i, i + 1), componentMap);
331
- children.push(...nested);
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
- // Likely a JSX component — skip it (already handled or unrecognized)
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 (`![alt](src "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
- push({ type: "text", text: token.content });
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 tokens — skip (stripped components like <Render />)
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, `&lt;`.
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 += "&lt;";
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 { body } = extractFrontmatter(source);
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
- cleaned = cleaned.replace(/<div[^>]*>\s*\n?/g, "");
539
- cleaned = cleaned.replace(/<\/div>\s*\n?/g, "");
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 tokens = md.parse(cleaned, {});
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 tree = tokensToTree(tokens, componentMap);
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
- return groupConsecutiveCards(unwrapped);
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
  }