@dogsbay/format-starlight 0.2.0-beta.115 → 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.
@@ -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, `&lt;`.
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
- 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,11 +126,14 @@ 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
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
- const children = [];
339
- i++;
340
- while (i < tokens.length && tokens[i].type !== "blockquote_close") {
341
- const nested = tokensToTree(tokens.slice(i, i + 1), componentMap, onDrop, describeComponent);
342
- 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;
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 (`![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");
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
- 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 });
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 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
+ }
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, `&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
+ }
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 { body } = extractFrontmatter(source);
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
- cleaned = cleaned.replace(/<div[^>]*>\s*\n?/g, "");
567
- 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, ""));
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 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
+ }
578
972
  // Convert tokens to tree
579
- const tree = tokensToTree(tokens, componentMap, onDrop, describeComponent);
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
- 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;
582
988
  }