@dogsbay/format-astro 0.2.0-beta.15 → 0.2.0-beta.150

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.
Files changed (67) hide show
  1. package/dist/api-markdown.d.ts +7 -0
  2. package/dist/api-markdown.js +49 -0
  3. package/dist/base-path.d.ts +93 -8
  4. package/dist/base-path.js +122 -9
  5. package/dist/blog.d.ts +133 -0
  6. package/dist/blog.js +325 -0
  7. package/dist/browser.d.ts +13 -0
  8. package/dist/browser.js +13 -0
  9. package/dist/canonical.d.ts +35 -0
  10. package/dist/canonical.js +110 -0
  11. package/dist/capabilities.d.ts +28 -0
  12. package/dist/capabilities.js +94 -0
  13. package/dist/cli.d.ts +0 -1
  14. package/dist/cli.js +3 -1
  15. package/dist/diff-decoration.d.ts +35 -0
  16. package/dist/diff-decoration.js +101 -0
  17. package/dist/granularity.d.ts +82 -0
  18. package/dist/granularity.js +246 -0
  19. package/dist/head-metadata.d.ts +25 -0
  20. package/dist/head-metadata.js +55 -0
  21. package/dist/index.d.ts +26 -6
  22. package/dist/index.js +34 -5
  23. package/dist/lead.d.ts +1 -39
  24. package/dist/lead.js +3 -38
  25. package/dist/llms-txt.d.ts +41 -11
  26. package/dist/llms-txt.js +82 -274
  27. package/dist/plugins.d.ts +0 -1
  28. package/dist/plugins.js +4 -3
  29. package/dist/project.d.ts +461 -13
  30. package/dist/project.js +2343 -649
  31. package/dist/redirects.d.ts +117 -0
  32. package/dist/redirects.js +223 -0
  33. package/dist/serialize.d.ts +14 -1
  34. package/dist/serialize.js +857 -255
  35. package/dist/site-chrome.d.ts +60 -0
  36. package/dist/site-chrome.js +56 -0
  37. package/dist/sitemap.d.ts +62 -4
  38. package/dist/sitemap.js +63 -56
  39. package/dist/taxonomy.d.ts +48 -2
  40. package/dist/taxonomy.js +50 -12
  41. package/dist/theme-css.d.ts +33 -0
  42. package/dist/theme-css.js +683 -0
  43. package/dist/tone-classes.d.ts +52 -0
  44. package/dist/tone-classes.js +84 -0
  45. package/dist/write-if-changed.d.ts +20 -0
  46. package/dist/write-if-changed.js +57 -0
  47. package/package.json +17 -7
  48. package/dist/base-path.d.ts.map +0 -1
  49. package/dist/base-path.js.map +0 -1
  50. package/dist/cli.d.ts.map +0 -1
  51. package/dist/cli.js.map +0 -1
  52. package/dist/index.d.ts.map +0 -1
  53. package/dist/index.js.map +0 -1
  54. package/dist/lead.d.ts.map +0 -1
  55. package/dist/lead.js.map +0 -1
  56. package/dist/llms-txt.d.ts.map +0 -1
  57. package/dist/llms-txt.js.map +0 -1
  58. package/dist/plugins.d.ts.map +0 -1
  59. package/dist/plugins.js.map +0 -1
  60. package/dist/project.d.ts.map +0 -1
  61. package/dist/project.js.map +0 -1
  62. package/dist/serialize.d.ts.map +0 -1
  63. package/dist/serialize.js.map +0 -1
  64. package/dist/sitemap.d.ts.map +0 -1
  65. package/dist/sitemap.js.map +0 -1
  66. package/dist/taxonomy.d.ts.map +0 -1
  67. package/dist/taxonomy.js.map +0 -1
package/dist/serialize.js CHANGED
@@ -1,25 +1,10 @@
1
- /**
2
- * Tone palette for `:::grid-item{label="..." tone="..."}` cells.
3
- * Each tone is a `bg + text` Tailwind class pair. Used to make grid demos
4
- * declarative — authors specify a tone instead of writing the styling.
5
- */
6
- const TONE_CLASSES = {
7
- // Primary scale (intensity)
8
- "primary": "bg-primary text-primary-foreground",
9
- "primary-strong": "bg-primary/80 text-primary-foreground",
10
- "primary-medium": "bg-primary/60 text-primary-foreground",
11
- "primary-half": "bg-primary/50 text-primary-foreground",
12
- "primary-light": "bg-primary/20 text-primary",
13
- "primary-soft": "bg-primary/10 text-primary",
14
- // Neutral
15
- "muted": "bg-muted text-muted-foreground",
16
- // Named colors (for layout demos with distinct cells)
17
- "blue": "bg-blue-500/15 text-blue-700 dark:text-blue-300",
18
- "amber": "bg-amber-500/15 text-amber-700 dark:text-amber-300",
19
- "green": "bg-green-500/15 text-green-700 dark:text-green-300",
20
- "purple": "bg-purple-500/15 text-purple-700 dark:text-purple-300",
21
- "red": "bg-red-500/15 text-red-700 dark:text-red-300",
22
- };
1
+ import { walkInline, applyTextFlags, renderLeaf, indent, isSafeHref, apiPageLayout, apiSymbolLevels, renderAroundGroup, isDetailsSheet, stepLead, stepTitle, } from "@dogsbay/serialize-core";
2
+ import { parseInline } from "@dogsbay/format-dogsbay-md";
3
+ import { decorateDiff, hasDiffMarks } from "./diff-decoration.js";
4
+ import { TAB_GLYPH, tabDiffSignal } from "@dogsbay/render-html/diff";
5
+ import { TONE_CLASSES, CARD_TONE_CLASSES, TAB_VARIANTS, THEME_SCOPES, lookup } from "./tone-classes.js";
6
+ import { apiBlockHtml, apiPropertiesHtml } from "./api-markdown.js";
7
+ export { TONE_CLASSES };
23
8
  const COMPONENT_IMPORTS = {
24
9
  "markdown-example": [
25
10
  'import MarkdownExample from "@ui/markdown-example/MarkdownExample.astro";',
@@ -32,6 +17,9 @@ const COMPONENT_IMPORTS = {
32
17
  details: [
33
18
  'import CollapsibleAlert from "@ui/alert/CollapsibleAlert.astro";',
34
19
  ],
20
+ "details-sheet": [
21
+ 'import DetailsSheet from "@ui/details-sheet/DetailsSheet.astro";',
22
+ ],
35
23
  code: [
36
24
  'import CodeBlock from "@ui/code-block/CodeBlock.astro";',
37
25
  ],
@@ -55,6 +43,9 @@ const COMPONENT_IMPORTS = {
55
43
  ],
56
44
  diagram: ['import Diagram from "@ui/diagram/Diagram.astro";'],
57
45
  youtube: ['import YouTube from "@ui/youtube/YouTube.astro";'],
46
+ badge: ['import Badge from "@ui/badge/Badge.astro";'],
47
+ kbd: ['import Kbd from "@ui/kbd/Kbd.astro";'],
48
+ files: ['import FileTree from "@ui/files/FileTree.astro";'],
58
49
  "math-block": ['import MathKatex from "@ui/math/MathKatex.astro";'],
59
50
  image: ['import BlockImage from "@ui/image/Image.astro";'],
60
51
  "api-class": ['import ApiSymbol from "@ui/api-symbol/ApiSymbol.astro";'],
@@ -96,6 +87,9 @@ const COMPONENT_IMPORTS = {
96
87
  "link-card": [
97
88
  'import LinkCard from "@ui/link-card/LinkCard.astro";',
98
89
  ],
90
+ "link-button": [
91
+ 'import Button from "@ui/button/Button.astro";',
92
+ ],
99
93
  avatar: [
100
94
  'import Avatar from "@ui/avatar/Avatar.astro";',
101
95
  ],
@@ -109,12 +103,50 @@ export function escapeExpr(s) {
109
103
  // JSON.stringify handles all special characters (quotes, backslashes, newlines, etc.)
110
104
  // and produces a valid JS string literal. This avoids template literal edge cases
111
105
  // where backtick + brace combinations create false expression boundaries.
112
- return JSON.stringify(s);
106
+ //
107
+ // `<` becomes \u003c: Astro parses the template as HTML before the expression is
108
+ // JavaScript, so a `<script>` inside a string (a code block showing one, say) would
109
+ // be taken for a real script tag and its text compiled as JavaScript. Vite's
110
+ // dependency scan then fails to parse it and a production build breaks.
111
+ return JSON.stringify(s).replace(/</g, "\\u003c");
113
112
  }
114
113
  /** Escape for use in an HTML attribute value (double-quoted). */
115
114
  export function escapeAttr(s) {
116
115
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
117
116
  }
117
+ export function componentAttr(name, value) {
118
+ return /[&"<>]/.test(value)
119
+ ? ` ${name}={${escapeExpr(value)}}`
120
+ : ` ${name}="${value}"`;
121
+ }
122
+ /**
123
+ * Render extra attributes from an inline element's `attrs` field
124
+ * (links and images today; spans in a future phase) as ` key="val"`
125
+ * pairs prefixed with a leading space, suitable for splicing into an
126
+ * open tag immediately after the element's first-class attrs (href,
127
+ * src, etc.). `id`, `class`, and `style` are first-class; any other
128
+ * key passes through verbatim.
129
+ */
130
+ /** `{…}` attributes on a COMPONENT (Badge, Button): `componentAttr`, which Astro does not entity-decode. */
131
+ function renderInlineComponentAttrs(attrs) {
132
+ if (!attrs)
133
+ return "";
134
+ return Object.entries(attrs)
135
+ .filter(([, value]) => value !== undefined)
136
+ .map(([key, value]) => componentAttr(key, String(value)))
137
+ .join("");
138
+ }
139
+ function renderInlineElementAttrs(attrs) {
140
+ if (!attrs)
141
+ return "";
142
+ const parts = [];
143
+ for (const [key, value] of Object.entries(attrs)) {
144
+ if (value === undefined)
145
+ continue;
146
+ parts.push(` ${key}="${escapeAttr(String(value))}"`);
147
+ }
148
+ return parts.join("");
149
+ }
118
150
  /** Escape text for use directly in an Astro template (not inside an expression). */
119
151
  export function escapeTemplate(s) {
120
152
  let result = "";
@@ -144,11 +176,19 @@ export function escapeTemplate(s) {
144
176
  export function treeToAstro(nodes, options) {
145
177
  const ctx = {
146
178
  imports: new Set(),
179
+ diagramIds: new Map(),
147
180
  indent: 0,
148
181
  scripts: new Set(),
149
182
  mode: options?.mode ?? "template",
150
183
  imageOptimization: options?.imageOptimization ?? false,
151
184
  codeBlockTitle: options?.codeBlockTitle ?? true,
185
+ imageCaptions: options?.imageCaptions ?? false,
186
+ combinedPrefix: options?.combinedPrefix ?? "",
187
+ // Mixed prose+endpoint pages render endpoints as embedded blocks;
188
+ // pages that are nothing but endpoints/headings keep the
189
+ // full-viewport operation layout (the OpenAPI page shape).
190
+ embeddedEndpoints: apiPageLayout(nodes).embeddedEndpoints,
191
+ apiSymbolLevels: apiSymbolLevels(nodes),
152
192
  };
153
193
  // Pre-scan for inline `icon` nodes anywhere in the tree.
154
194
  // `inlineNodeToTemplate` is intentionally pure (no ctx) — it
@@ -157,9 +197,9 @@ export function treeToAstro(nodes, options) {
157
197
  // (card.props.icon) registers `icon` directly at the call
158
198
  // site; this pre-scan picks up the inline `:icon[name]`
159
199
  // directives the writer can drop into prose.
160
- if (treeContainsInlineIcon(nodes)) {
161
- ctx.imports.add("icon");
162
- }
200
+ // Badges and inline buttons are components too, registered the same way.
201
+ for (const key of inlineComponentsIn(nodes))
202
+ ctx.imports.add(key);
163
203
  const body = nodes.map((n) => nodeToAstro(n, ctx)).filter(Boolean).join("\n");
164
204
  const imports = [];
165
205
  const seen = new Set();
@@ -181,7 +221,18 @@ export function treeToAstro(nodes, options) {
181
221
  };
182
222
  }
183
223
  // ─── Node dispatcher ──────────────────────────────────────────────
224
+ /**
225
+ * Dispatch + diff decoration: nodes carrying DiffTree marks (from
226
+ * @dogsbay/tree-diff, read structurally) decorate the rendered
227
+ * snippet; unmarked nodes render byte-identically to before.
228
+ */
184
229
  function nodeToAstro(node, ctx) {
230
+ const rendered = nodeToAstroBase(node, ctx);
231
+ if (!rendered || !hasDiffMarks(node))
232
+ return rendered;
233
+ return decorateDiff(node, rendered, (peer) => nodeToAstroBase(peer, ctx));
234
+ }
235
+ function nodeToAstroBase(node, ctx) {
185
236
  switch (node.type) {
186
237
  case "prose":
187
238
  return proseToAstro(node, ctx);
@@ -193,10 +244,27 @@ function nodeToAstro(node, ctx) {
193
244
  return codeToAstro(node, ctx);
194
245
  case "callout":
195
246
  return calloutToAstro(node, ctx);
247
+ case "files":
248
+ return filesToAstro(node, ctx);
249
+ case "container":
250
+ return genericContainerToAstro(node, ctx);
196
251
  case "details":
197
252
  return detailsToAstro(node, ctx);
253
+ case "theme": {
254
+ // A scoped theme: `.dark` re-declares the tokens for its subtree, so a
255
+ // plain wrapper is enough (THEME_SCOPES); an unknown name keeps the content
256
+ const name = node.props?.name;
257
+ const scope = lookup(THEME_SCOPES, name);
258
+ const inner = childrenToAstro(node, ctx);
259
+ if (!scope)
260
+ return inner;
261
+ const passthrough = renderPassthroughAttrs(node.props, ["name", "class", "data-theme"]);
262
+ // The default (expression) form: an entity-escaped `[&amp;>…]` is not a
263
+ // class Tailwind's scanner recognises, so the rule was never generated
264
+ return `<div${mergeClassAttr(scope, node.props?.class)} data-theme="${escapeAttr(name)}"${passthrough}>\n${indentStr(inner, 2)}\n</div>`;
265
+ }
198
266
  case "tabs":
199
- return tabsToAstro(node, ctx);
267
+ return renderAroundGroup(node, "tab", (n) => tabsToAstro(n, ctx), (c) => nodeToAstro(c, ctx), "\n");
200
268
  case "ordered-list":
201
269
  return wrapBlock("ol", "", node, childrenToAstro(node, ctx), ["start"])
202
270
  .replace(/^<ol(\s[^>]*)?>/, (m) => {
@@ -212,14 +280,23 @@ function nodeToAstro(node, ctx) {
212
280
  return wrapBlock("li", "", node, leafContent(node, ctx));
213
281
  case "blockquote":
214
282
  return wrapBlock("blockquote", "border-l-4 border-border pl-4 italic text-muted-foreground", node, childrenToAstro(node, ctx));
283
+ // A transparent grouping wrapper: renders its children inside a plain
284
+ // <div> and adds no semantics of its own. Exists so a caller can carry
285
+ // an attribute (a class, a data-* flag) for a whole run of blocks —
286
+ // the release comparison marks a wholly added/removed PAGE once, at
287
+ // the page level, instead of putting a bar on every block inside it.
288
+ case "group":
289
+ return wrapBlock("div", "", node, childrenToAstro(node, ctx));
215
290
  case "hr": {
216
291
  const classAttr = mergeClassAttr("my-8 border-border", node.props?.class);
217
292
  const passthrough = renderPassthroughAttrs(node.props, ["class"]);
218
293
  return `<hr${classAttr}${passthrough} />`;
219
294
  }
220
295
  case "html":
221
- // Raw HTML always uses set:html — it may contain anything
222
- return `<Fragment set:html={${escapeExpr(node.html ?? "")}} />`;
296
+ // Raw HTML always uses set:html — it may contain anything. Callouts inside it
297
+ // (dogsbay-md's HTML table fallback writes `<div role="note" data-callout>`)
298
+ // render as real callouts
299
+ return htmlToAstro(node.html ?? "", ctx);
223
300
  case "table":
224
301
  return tableToAstro(node, ctx);
225
302
  case "thead":
@@ -270,18 +347,36 @@ function nodeToAstro(node, ctx) {
270
347
  return exampleToAstro(node, ctx);
271
348
  case "figure": {
272
349
  const caption = node.props?.caption;
350
+ // A figure of one code block: the caption goes in the code block's header bar,
351
+ // which then is the figure's caption
352
+ const only = node.children?.length === 1 ? node.children[0] : undefined;
353
+ if (caption && only?.type === "code") {
354
+ return codeToAstro(only, ctx, {
355
+ caption: captionToAstro(caption, ctx),
356
+ // The figure's attributes, except those the code block has itself (an id twice is invalid)
357
+ attrs: renderPassthroughAttrs(node.props, ["caption", "class", ...Object.keys(only.props ?? {})]),
358
+ classes: ["my-4", node.props?.class].filter(Boolean).join(" "),
359
+ });
360
+ }
273
361
  const inner = childrenToAstro(node, ctx);
274
362
  const body = caption
275
- ? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${escapeTemplate(caption)}</figcaption>`
363
+ ? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${captionToAstro(caption, ctx)}</figcaption>`
276
364
  : inner;
277
365
  return wrapBlock("figure", "my-4", node, body, ["caption"]);
278
366
  }
279
367
  case "steps":
280
368
  ctx.imports.add("steps");
281
369
  return `<Steps>\n${childrenToAstro(node, ctx)}\n</Steps>`;
282
- case "step":
370
+ case "step": {
283
371
  ctx.imports.add("steps");
284
- return `<Step>\n${leafContent(node, ctx)}\n</Step>`;
372
+ // A `title` prop (Mintlify `<Step title>`) leads the step in bold, as render-html draws it
373
+ // The title on its own line (Steps.astro: `.step-title` is a block): from the prop, or the
374
+ // step's leading bold run (stepLead), which Markdown otherwise runs into the detail
375
+ const title = stepTitle(node);
376
+ const split = title ? undefined : stepLead(node.inline);
377
+ const lead = title ? `<strong class="step-title">${escapeTemplate(title)}</strong>\n` : split ? `<strong class="step-title">${renderInline(split.title, ctx)}</strong>\n` : "";
378
+ return `<Step>\n${lead}${leafContent(split ? { ...node, inline: split.rest } : node, ctx)}\n</Step>`;
379
+ }
285
380
  case "substeps":
286
381
  ctx.imports.add("substeps");
287
382
  ctx.imports.add("steps");
@@ -297,8 +392,10 @@ function nodeToAstro(node, ctx) {
297
392
  return standaloneCardToAstro(node, ctx);
298
393
  case "link-card":
299
394
  return linkCardToAstro(node, ctx);
395
+ case "link-button":
396
+ return linkButtonToAstro(node, ctx);
300
397
  case "accordion":
301
- return accordionToAstro(node, ctx);
398
+ return renderAroundGroup(node, "accordion-item", (n) => accordionToAstro(n, ctx), (c) => nodeToAstro(c, ctx), "\n");
302
399
  case "accordion-item":
303
400
  // Bare accordion-item outside an :::accordion wrapper. Wrap it in an
304
401
  // Accordion shell so the output is still a valid widget.
@@ -342,7 +439,7 @@ ${childrenToAstro(node, ctx)}
342
439
  // controls the bg/text combo (see TONE_CLASSES). Authors can also pass
343
440
  // their own `class` to override.
344
441
  if (label !== undefined) {
345
- const toneClasses = TONE_CLASSES[tone] ?? TONE_CLASSES["primary-light"];
442
+ const toneClasses = lookup(TONE_CLASSES, tone) ?? TONE_CLASSES["primary-light"];
346
443
  return `<GridItem ${attrParts.join(" ")}${classAttr}${passthrough}>
347
444
  <div class="flex h-full min-h-16 items-center justify-center rounded-lg font-mono text-sm font-medium ${toneClasses}">${escapeTemplate(label)}</div>
348
445
  </GridItem>`;
@@ -351,6 +448,28 @@ ${childrenToAstro(node, ctx)}
351
448
  ${childrenToAstro(node, ctx)}
352
449
  </GridItem>`;
353
450
  }
451
+ case "related": {
452
+ // #384 R3: AsciiDoc [role="_additional-resources"] + .Title + list folds
453
+ // to this semantic node (maps to DITA <related-links>). Render a titled
454
+ // section, not a TOC heading. The title is a caption (block title), so a
455
+ // styled <p>, not an <h2>.
456
+ const title = node.props?.title;
457
+ const classAttr = mergeClassAttr("related-resources", node.props?.class);
458
+ const passthrough = renderPassthroughAttrs(node.props, ["title", "class"]);
459
+ const titleHtml = title
460
+ ? `<p class="related-resources-title">${escapeTemplate(title)}</p>\n`
461
+ : "";
462
+ return `<section${classAttr}${passthrough}>\n${titleHtml}${childrenToAstro(node, ctx)}\n</section>`;
463
+ }
464
+ case "mdx-raw": {
465
+ // Preserved-but-unmapped MDX component (format-mdx never-drop; see
466
+ // docs-dev/format-mdx-adapters.md). Rendered as a visible source
467
+ // block — the content must not silently disappear from the HTML
468
+ // output. Map the component (adapter / mdx.components config) to
469
+ // upgrade it to a real widget.
470
+ const source = String(node.props?.source ?? "");
471
+ return `<pre class="mdx-raw my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code>{${escapeExpr(source)}}</code></pre>`;
472
+ }
354
473
  default:
355
474
  if (node.html)
356
475
  return `<Fragment set:html={${escapeExpr(node.html)}} />`;
@@ -360,37 +479,77 @@ ${childrenToAstro(node, ctx)}
360
479
  }
361
480
  }
362
481
  // ─── Inline rendering ─────────────────────────────────────────────
363
- /** Render inline nodes as Astro template markup (clean, editable). */
364
- function inlineToTemplate(nodes) {
365
- return nodes.map(inlineNodeToTemplate).join("");
366
- }
367
- function inlineNodeToTemplate(node) {
368
- switch (node.type) {
369
- case "text": {
370
- let text = escapeTemplate(node.text);
371
- if (node.bold)
372
- text = `<strong>${text}</strong>`;
373
- if (node.italic)
374
- text = `<em>${text}</em>`;
375
- if (node.strikethrough)
376
- text = `<s>${text}</s>`;
377
- return text;
378
- }
379
- case "link": {
380
- const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
381
- return `<a href="${escapeAttr(node.href)}"${titleAttr}>${inlineToTemplate(node.children)}</a>`;
382
- }
383
- case "image":
384
- return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy" />`;
385
- case "code":
386
- return `<code>${escapeTemplate(node.text)}</code>`;
387
- case "footnote-ref":
388
- return `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${escapeTemplate(node.label)}]</a></sup>`;
389
- case "kbd":
390
- return node.keys.map((k) => `<kbd>${escapeTemplate(k)}</kbd>`).join("+");
391
- case "math":
392
- return `<code class="math-inline">${escapeTemplate(node.latex)}</code>`;
393
- case "icon": {
482
+ /**
483
+ * Astro's two inline dialects, as emitters over serialize-core's shared walk.
484
+ *
485
+ * These were the THIRD and FOURTH copies of the 11-variant switch (obsidian and
486
+ * dogsbay-md's html fallback were the other two), and the duplication is
487
+ * precisely why `highlight` was missing from BOTH of them: adding an arm meant
488
+ * remembering four places, so `:highlight[…]` rendered as nothing at all on
489
+ * every Astro page. The walk is now shared, so a variant is handled in one
490
+ * place per format instead of being silently skippable.
491
+ *
492
+ * Only the leaf syntax differs between the two maps: template mode escapes for
493
+ * Astro's `{}` and emits real components (`<Icon>`, `<Fragment set:html>`),
494
+ * hybrid mode escapes for plain HTML and emits inert markup.
495
+ */
496
+ /** Canonical highlight styling — keep in sync with InlineRenderer.astro. */
497
+ const HIGHLIGHT_CLASS = "rounded-sm bg-yellow-200/60 px-0.5 dark:bg-yellow-500/30";
498
+ function templateInlineEmitters() {
499
+ return {
500
+ text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
501
+ bold: (s) => `<strong>${s}</strong>`,
502
+ italic: (s) => `<em>${s}</em>`,
503
+ strike: (s) => `<s>${s}</s>`,
504
+ }),
505
+ link: (node, ctx) => {
506
+ // Inside a whole-card link, a link is its text (wholeCardLink)
507
+ if (cardLinkDepth > 0)
508
+ return ctx.recurse(node.children);
509
+ // A link node an importer built directly never passed markdown-it's
510
+ // scheme check: a `javascript:` href keeps its text and loses the link.
511
+ if (!isSafeHref(node.href)) {
512
+ ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
513
+ return ctx.recurse(node.children);
514
+ }
515
+ const titleAttr = node.title ? componentAttr("title", node.title) : "";
516
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
517
+ // An inline `:button`: the Button component, small, keeping its variant ("primary" is the filled default).
518
+ if (node.button) {
519
+ const variant = buttonVariant(node.button.variant);
520
+ return `<Button${componentAttr("href", node.href)}${componentAttr("variant", variant)} size="sm"${titleAttr}${renderInlineComponentAttrs(node.attrs)}>${ctx.recurse(node.children)}</Button>`;
521
+ }
522
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
523
+ },
524
+ badge: (node, ctx) => `<Badge${componentAttr("variant", node.variant ?? "default")}${renderInlineComponentAttrs(node.attrs)}>${ctx.escape(node.text)}</Badge>`,
525
+ image: (node, ctx) => {
526
+ if (!isSafeHref(node.src, "image")) {
527
+ ctx.report("image", `unsafe image source dropped (${node.src.slice(0, 40)})`);
528
+ return ctx.escape(node.alt ?? "");
529
+ }
530
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
531
+ return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy"${extraAttrs} />`;
532
+ },
533
+ // Code inherits the emphasis around it (`*using the \`dita\` command*`): the parser
534
+ // flags the code span, and dropping the flags split the italics around the code
535
+ code: (node, ctx) => applyTextFlags(`<code>${ctx.escape(node.text)}</code>`, node, {
536
+ bold: (s) => `<strong>${s}</strong>`,
537
+ italic: (s) => `<em>${s}</em>`,
538
+ strike: (s) => `<s>${s}</s>`,
539
+ }),
540
+ // The arm that did not exist — `:highlight[…]` / `:mark[…]` rendered as
541
+ // nothing at all. The class list mirrors the canonical rendering in
542
+ // `packages/ui/src/content-renderer/InlineRenderer.astro`, so a highlight
543
+ // looks the same however a page is produced. A BARE `<mark>` would have
544
+ // been "fixed" but unstyled: the generated `.docs-prose` stylesheet had no
545
+ // `mark` rule, so browsers paint their default yellow — including on dark
546
+ // pages, where nothing declares `color-scheme`.
547
+ highlight: (node, ctx) => `<mark class="${HIGHLIGHT_CLASS}">${ctx.recurse(node.children)}</mark>`,
548
+ "footnote-ref": (node, ctx) => `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${ctx.escape(node.label)}]</a></sup>`,
549
+ // The Kbd component (keys styled as keycaps); plain <kbd> had no style beyond a monospace font.
550
+ kbd: (node, ctx) => node.keys.map((k) => `<Kbd>${ctx.escape(k)}</Kbd>`).join("+"),
551
+ math: (node, ctx) => `<code class="math-inline">${ctx.escape(node.latex)}</code>`,
552
+ icon: (node) => {
394
553
  // Inline icon — emits the platform `<Icon>` component which
395
554
  // resolves at build time against Lucide (default) plus any
396
555
  // other Iconify pack via the `pack:name` shorthand. Caller
@@ -404,47 +563,39 @@ function inlineNodeToTemplate(node) {
404
563
  // to Lucide for everything.
405
564
  const fullName = node.library ? `${node.library}:${node.name}` : node.name;
406
565
  return `<Icon name="${escapeAttr(fullName)}" class="inline-block size-[1em] align-[-0.125em]" />`;
407
- }
408
- case "html-inline":
409
- // Raw HTML inline — must use set:html to avoid brace issues
410
- return `<Fragment set:html={${escapeExpr(node.html)}} />`;
411
- case "break":
412
- return "<br />";
413
- default:
414
- return "";
415
- }
416
- }
417
- /** Render inline nodes as HTML string (for hybrid mode set:html). */
418
- function inlineToHtml(nodes) {
419
- return nodes.map(inlineNodeToHtml).join("");
566
+ },
567
+ // Raw HTML inline — must use set:html to avoid brace issues
568
+ "html-inline": (node) => `<Fragment set:html={${escapeExpr(node.html)}} />`,
569
+ break: () => "<br />",
570
+ };
420
571
  }
421
- function inlineNodeToHtml(node) {
422
- switch (node.type) {
423
- case "text": {
424
- let text = escapeHtml(node.text);
425
- if (node.bold)
426
- text = `<strong>${text}</strong>`;
427
- if (node.italic)
428
- text = `<em>${text}</em>`;
429
- if (node.strikethrough)
430
- text = `<s>${text}</s>`;
431
- return text;
432
- }
433
- case "link": {
572
+ function htmlModeInlineEmitters() {
573
+ return {
574
+ ...templateInlineEmitters(),
575
+ // Inherited from the template map, this emitted `title={"…"}` — an
576
+ // EXPRESSION, inside a string that becomes a `set:html` value. Astro never
577
+ // parses it as markup, so the reader saw the braces and quotes.
578
+ link: (node, ctx) => {
579
+ if (cardLinkDepth > 0)
580
+ return ctx.recurse(node.children);
581
+ if (!isSafeHref(node.href)) {
582
+ ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
583
+ return ctx.recurse(node.children);
584
+ }
434
585
  const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
435
- return `<a href="${escapeAttr(node.href)}"${titleAttr}>${inlineToHtml(node.children)}</a>`;
436
- }
437
- case "image":
438
- return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy" />`;
439
- case "code":
440
- return `<code>${escapeHtml(node.text)}</code>`;
441
- case "footnote-ref":
442
- return `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${escapeHtml(node.label)}]</a></sup>`;
443
- case "kbd":
444
- return node.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join("+");
445
- case "math":
446
- return `<code class="math-inline">${escapeHtml(node.latex)}</code>`;
447
- case "icon": {
586
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
587
+ // No components in HTML mode: a marked link (and a marked span for a badge below).
588
+ const button = node.button ? ` data-button data-variant="${escapeAttr(buttonVariant(node.button.variant))}"` : "";
589
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}${button}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
590
+ },
591
+ kbd: (node, ctx) => node.keys.map((k) => `<kbd>${ctx.escape(k)}</kbd>`).join("+"),
592
+ badge: (node, ctx) => `<span data-badge data-variant="${escapeAttr(node.variant ?? "default")}"${renderInlineElementAttrs(node.attrs)}>${ctx.escape(node.text)}</span>`,
593
+ text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
594
+ bold: (s) => `<strong>${s}</strong>`,
595
+ italic: (s) => `<em>${s}</em>`,
596
+ strike: (s) => `<s>${s}</s>`,
597
+ }),
598
+ icon: (node) => {
448
599
  // Hybrid-mode HTML rendering — fall back to a textual
449
600
  // shortcode reference when we can't inline the SVG. Real
450
601
  // resolution happens in template mode via the <Icon>
@@ -452,14 +603,19 @@ function inlineNodeToHtml(node) {
452
603
  // production output.
453
604
  const fullName = node.library ? `${node.library}:${node.name}` : node.name;
454
605
  return `<span class="dogsbay-icon" data-icon="${escapeAttr(fullName)}"></span>`;
455
- }
456
- case "html-inline":
457
- return node.html;
458
- case "break":
459
- return "<br />";
460
- default:
461
- return "";
462
- }
606
+ },
607
+ "html-inline": (node) => node.html,
608
+ };
609
+ }
610
+ const TEMPLATE_INLINE = templateInlineEmitters();
611
+ const HTML_INLINE = htmlModeInlineEmitters();
612
+ /** Render inline nodes as Astro template markup (clean, editable). */
613
+ function inlineToTemplate(nodes) {
614
+ return walkInline(nodes, { emitters: TEMPLATE_INLINE, escape: escapeTemplate });
615
+ }
616
+ /** Render inline nodes as HTML string (for hybrid mode set:html). */
617
+ function inlineToHtml(nodes) {
618
+ return walkInline(nodes, { emitters: HTML_INLINE, escape: escapeHtml });
463
619
  }
464
620
  /** Render inline content in the current mode. */
465
621
  function renderInline(nodes, ctx) {
@@ -467,6 +623,8 @@ function renderInline(nodes, ctx) {
467
623
  const html = inlineToHtml(nodes);
468
624
  return `<Fragment set:html={${escapeExpr(html)}} />`;
469
625
  }
626
+ // Inline parsed from a prop (a caption) is not in the page-level pre-scan; its components are imported here.
627
+ inlineComponentsIn([{ type: "paragraph", inline: nodes }], ctx.imports);
470
628
  return inlineToTemplate(nodes);
471
629
  }
472
630
  /** Render an HTML string in the current mode. */
@@ -487,18 +645,24 @@ function headingToAstro(node, ctx) {
487
645
  // `id` takes precedence over `slug` (slug is auto-generated, id is explicit user override)
488
646
  const id = node.props?.id ?? node.props?.slug ?? "";
489
647
  const anchor = id
490
- ? `<a href="#${escapeAttr(id)}" class="ml-2 text-muted-foreground opacity-0 group-hover:opacity-100 no-underline" aria-hidden="true" tabindex="-1">&para;</a>`
648
+ ? `<a href="#${escapeAttr(id)}" class="ml-2 text-muted-foreground opacity-0 group-hover:opacity-100 no-underline" aria-hidden="true" tabindex="-1" data-pagefind-ignore>&para;</a>`
491
649
  : "";
492
650
  const idAttr = id ? ` id="${escapeAttr(id)}"` : "";
493
- const classAttr = mergeClassAttr("group scroll-mt-20", node.props?.class);
494
- const passthrough = renderPassthroughAttrs(node.props, [
495
- "level", "slug", "id", "class", "text",
496
- ]);
651
+ const consumed = ["level", "slug", "id", "class", "text"];
652
+ const userClass = node.props?.class;
497
653
  if (ctx.mode === "template" && node.inline) {
654
+ // Real Astro markup: expressions are parsed.
655
+ const classAttr = mergeClassAttr("group scroll-mt-20", userClass);
656
+ const passthrough = renderPassthroughAttrs(node.props, consumed);
498
657
  const text = inlineToTemplate(node.inline);
499
658
  return `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
500
659
  }
501
- // Hybrid or no inline — use set:html for the whole heading
660
+ // Hybrid or no inline — the whole heading becomes a set:html STRING, where an
661
+ // expression would be literal text. Every MDX/Mintlify heading takes this
662
+ // branch (that importer emits `props.text` + children, never `inline`), so
663
+ // this is the common path, not an edge case.
664
+ const classAttr = mergeClassAttr("group scroll-mt-20", userClass, "html");
665
+ const passthrough = renderPassthroughAttrs(node.props, consumed, "html");
502
666
  const text = node.inline ? inlineToHtml(node.inline) : node.props?.text ?? "";
503
667
  const html = `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
504
668
  return `<Fragment set:html={${escapeExpr(html)}} />`;
@@ -515,15 +679,28 @@ function paragraphToAstro(node, ctx) {
515
679
  if (meaningful.length === 1 && meaningful[0].type === "image") {
516
680
  const img = meaningful[0];
517
681
  ctx.imports.add("image");
682
+ // imageMap is keyed by the unprefixed publicPath built from
683
+ // `import.meta.glob("/src/assets/**", ...)` at runtime, so
684
+ // strip the URL prefix back off if rewriteTreeImageSrcs has
685
+ // already applied it. Keeps the optimized-image lookup working
686
+ // alongside the prefix-on-emit fix.
687
+ const lookupKey = ctx.combinedPrefix && img.src.startsWith(`${ctx.combinedPrefix}/`)
688
+ ? img.src.slice(ctx.combinedPrefix.length)
689
+ : img.src;
518
690
  const imgDataAttr = ctx.imageOptimization
519
- ? ` imageData={imageMap[${escapeExpr(img.src)}]}`
691
+ ? ` imageData={imageMap[${escapeExpr(lookupKey)}]}`
520
692
  : "";
521
693
  // Forward arbitrary `data-*` / `aria-*` attrs that plugins
522
694
  // may have attached to the wrapping paragraph (e.g.
523
695
  // @dogsbay/plugin-image-zoom tags `data-zoomable="true"` so
524
696
  // the runtime can attach handlers without scanning the DOM).
525
697
  const passthrough = renderPassthroughAttrs(node.props, ["class"]);
526
- return `<BlockImage src="${escapeAttr(img.src)}" alt="${escapeAttr(img.alt ?? "")}"${passthrough}${imgDataAttr} />`;
698
+ // Author-supplied {.class #id ...} on the image itself
699
+ // (Phase 1.5 of plans/inline-attrs.md).
700
+ const imageAttrs = renderInlineElementAttrs(img.attrs);
701
+ // `content.imageCaptions: title`: the figure form of BlockImage, captioned by the title (as dogsbay-ssg).
702
+ const figure = ctx.imageCaptions && img.title ? ` block${componentAttr("title", img.title)}` : "";
703
+ return `<BlockImage${componentAttr("src", img.src)}${componentAttr("alt", img.alt ?? "")}${figure}${imageAttrs}${passthrough}${imgDataAttr} />`;
527
704
  }
528
705
  }
529
706
  const classAttr = mergeClassAttr("", node.props?.class);
@@ -550,7 +727,7 @@ function paragraphToAstro(node, ctx) {
550
727
  }).join("");
551
728
  return `<p${classAttr}${passthrough} set:html={${escapeExpr(html)}} />`;
552
729
  }
553
- function codeToAstro(node, ctx) {
730
+ function codeToAstro(node, ctx, figure) {
554
731
  // Code content may live on props.code (Starlight convention) or node.html
555
732
  // (dogsbay-md convention). Same for lang — Starlight uses props.lang,
556
733
  // dogsbay-md uses props.language.
@@ -561,34 +738,52 @@ function codeToAstro(node, ctx) {
561
738
  ?? node.html
562
739
  ?? "";
563
740
  const title = node.props?.title;
564
- const highlights = node.props?.highlights;
741
+ // The fence's documented `mark`, `ins` and `del` are CodeRich's highlights, diffAdd and diffRemove.
742
+ const lineSpec = (...values) => values.filter((v) => typeof v === "string" || typeof v === "number").map(String).filter(Boolean).join(",") || undefined;
743
+ const highlights = lineSpec(node.props?.highlights, node.props?.mark);
565
744
  const lineNumbers = node.props?.lineNumbers;
566
- const isRich = lineNumbers || highlights || code.includes("[!code");
567
- const classAttr = mergeClassAttr("", node.props?.class);
745
+ const copyText = node.props?.copyText;
746
+ const diffAdd = lineSpec(node.props?.diffAdd, node.props?.ins);
747
+ const diffRemove = lineSpec(node.props?.diffRemove, node.props?.del);
748
+ // Expressive Code's `collapse`: line runs folded behind a toggle (`{2-4, 9}` or `2-4,9`)
749
+ const collapse = lineSpec(node.props?.collapse)?.replace(/[{}\s]/g, "") || undefined;
750
+ const isRich = lineNumbers || highlights || code.includes("[!code") || Boolean(diffAdd || diffRemove) || Boolean(collapse);
751
+ const classAttr = mergeClassAttr(figure?.classes ?? "", node.props?.class);
752
+ const figureAttrs = figure?.attrs ?? "";
753
+ // The component with its caption slot filled, or self-closing
754
+ const close = (tag, open) => figure ? `${open}>\n<Fragment slot="caption">${figure.caption}</Fragment>\n</${tag}>` : `${open} />`;
568
755
  const consumed = [
569
756
  "lang", "language", "code", "title", "highlights", "lineNumbers",
570
- "class", "ins", "del", "mark", "collapse",
757
+ "class", "ins", "del", "mark", "collapse", "copyText", "diffAdd", "diffRemove",
571
758
  ];
572
759
  const passthrough = renderPassthroughAttrs(node.props, consumed);
573
760
  if (isRich) {
574
761
  ctx.imports.add("code-rich");
575
762
  const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
576
763
  if (title)
577
- attrs.push(`title="${escapeAttr(title)}"`);
764
+ attrs.push(componentAttr("title", title).trimStart());
578
765
  if (lineNumbers)
579
766
  attrs.push("lineNumbers");
580
767
  if (highlights)
581
768
  attrs.push(`highlights="${escapeAttr(highlights)}"`);
582
- return `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough} />`;
769
+ if (copyText !== undefined)
770
+ attrs.push(`copyText={${escapeExpr(copyText)}}`);
771
+ if (diffAdd)
772
+ attrs.push(`diffAdd="${escapeAttr(diffAdd)}"`);
773
+ if (diffRemove)
774
+ attrs.push(`diffRemove="${escapeAttr(diffRemove)}"`);
775
+ if (collapse)
776
+ attrs.push(`collapse="${escapeAttr(collapse)}"`);
777
+ return close("CodeRich", `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
583
778
  }
584
779
  ctx.imports.add("code");
585
780
  const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
586
781
  if (title)
587
- attrs.push(`title="${escapeAttr(title)}"`);
782
+ attrs.push(componentAttr("title", title).trimStart());
588
783
  if (ctx.codeBlockTitle !== true) {
589
784
  attrs.push(`showTitle="${ctx.codeBlockTitle}"`);
590
785
  }
591
- return `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough} />`;
786
+ return close("CodeBlock", `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
592
787
  }
593
788
  // Map GitHub alert names (and other aliases) to Alert component variants.
594
789
  // The Alert variant palette follows MkDocs Material; not every GitHub name
@@ -600,6 +795,71 @@ const CALLOUT_VARIANT_MAP = {
600
795
  hint: "tip",
601
796
  attention: "warning",
602
797
  };
798
+ // Default title shown when a callout has no explicit one, keyed by the type
799
+ // the author wrote: aliases share a look but keep their own name (`:::info` is
800
+ // "Info", not the "Important" of the variant it shares). The same labels as
801
+ // dogsbay-ssg (src/render/icons.ts CALLOUTS). Anything else: the type, capitalised.
802
+ const CALLOUT_LABELS = {
803
+ note: "Note",
804
+ info: "Info",
805
+ tip: "Tip",
806
+ hint: "Hint",
807
+ success: "Success",
808
+ important: "Important",
809
+ warning: "Warning",
810
+ attention: "Attention",
811
+ caution: "Caution",
812
+ danger: "Danger",
813
+ error: "Error",
814
+ failure: "Failure",
815
+ question: "Question",
816
+ bug: "Bug",
817
+ example: "Example",
818
+ abstract: "Abstract",
819
+ quote: "Quote",
820
+ };
821
+ const CALLOUT_OPEN = /<div role="note" data-callout="([^"]*)"(?: data-title="([^"]*)")?>/g;
822
+ /**
823
+ * Raw HTML as set:html fragments, with each marked callout div rendered by
824
+ * calloutToAstro. The fragments around a callout may hold unbalanced tags
825
+ * (`<table><tr><td>` before, `</td></tr></table>` after); set:html writes
826
+ * them verbatim, so the page's HTML is whole again.
827
+ */
828
+ function htmlToAstro(html, ctx) {
829
+ const raw = (s) => (s ? `<Fragment set:html={${escapeExpr(s)}} />` : "");
830
+ const out = [];
831
+ let rest = html;
832
+ for (;;) {
833
+ CALLOUT_OPEN.lastIndex = 0;
834
+ const open = CALLOUT_OPEN.exec(rest);
835
+ if (!open)
836
+ break;
837
+ // The callout ends at its matching </div>
838
+ const divs = /<div\b|<\/div>/g;
839
+ divs.lastIndex = open.index + open[0].length;
840
+ let depth = 1;
841
+ let end = -1;
842
+ for (let m = divs.exec(rest); m; m = divs.exec(rest)) {
843
+ depth += m[0] === "</div>" ? -1 : 1;
844
+ if (depth === 0) {
845
+ end = m.index;
846
+ break;
847
+ }
848
+ }
849
+ if (end < 0)
850
+ break;
851
+ const unescape = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
852
+ out.push(raw(rest.slice(0, open.index)));
853
+ out.push(calloutToAstro({
854
+ type: "callout",
855
+ props: { type: unescape(open[1]), ...(open[2] !== undefined ? { title: unescape(open[2]) } : {}) },
856
+ children: [{ type: "html", html: rest.slice(open.index + open[0].length, end) }],
857
+ }, ctx));
858
+ rest = rest.slice(end + "</div>".length);
859
+ }
860
+ out.push(raw(rest));
861
+ return out.filter(Boolean).join("\n") || raw(html);
862
+ }
603
863
  function calloutToAstro(node, ctx) {
604
864
  ctx.imports.add("callout");
605
865
  // Callout variant may live on props.variant (Starlight / MkDocs importers)
@@ -608,36 +868,90 @@ function calloutToAstro(node, ctx) {
608
868
  ?? node.props?.type
609
869
  ?? "note").toLowerCase();
610
870
  const variant = CALLOUT_VARIANT_MAP[rawVariant] ?? rawVariant;
611
- const title = node.props?.title ?? "";
871
+ // When the source gives no explicit title, fall back to the type label
872
+ // (Note / Warning / …) instead of an empty <AlertTitle> — AsciiDoc
873
+ // admonitions and GitHub-style alerts both show the type next to the icon,
874
+ // and an empty title leaves the icon standing alone. Issue #004.
875
+ const explicitTitle = node.props?.title;
876
+ // The default title names the authored type, not the variant it maps to.
877
+ const title = explicitTitle && explicitTitle.length > 0
878
+ ? explicitTitle
879
+ : CALLOUT_LABELS[rawVariant] ?? rawVariant.charAt(0).toUpperCase() + rawVariant.slice(1);
612
880
  const icon = node.props?.icon;
613
- const iconAttr = icon ? ` icon="${escapeAttr(icon)}"` : "";
881
+ // The authored type (`hint`, `caution`) beside the variant it shares, as dogsbay-ssg writes it.
882
+ const iconAttr = (icon ? ` icon="${escapeAttr(icon)}"` : "") + (node.props?.["data-type"] === undefined ? ` data-type="${escapeAttr(rawVariant)}"` : "");
614
883
  // User classes and passthrough attributes (id, data-*, aria-*, style)
615
884
  const classAttr = mergeClassAttr("my-4", node.props?.class);
616
885
  const passthrough = renderPassthroughAttrs(node.props, [
617
886
  "type", "variant", "title", "icon", "class",
618
887
  ]);
619
888
  const inner = childrenToAstro(node, ctx);
889
+ // An authored title is inline Markdown (`Before \`harbor init\``), as render-html reads it (MD-002);
890
+ // the default label is text
891
+ const titleNodes = explicitTitle && explicitTitle.length > 0 ? titleInline(title) : [{ type: "text", text: title }];
892
+ const plainTitle = isPlainCaption(titleNodes);
893
+ // `collapsed` / `collapsible`: a disclosure, as render-html draws it (MD-001)
894
+ if (isCollapsibleCallout(node.props)) {
895
+ ctx.imports.add("details");
896
+ const attrs = [`variant="${escapeAttr(collapsibleVariant(variant))}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
897
+ if (calloutStartsOpen(node.props))
898
+ attrs.push("open");
899
+ const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
900
+ const collapsiblePassthrough = renderPassthroughAttrs(node.props, ["type", "variant", "title", "icon", "class", "collapsed", "collapsible", "open"]);
901
+ return `<CollapsibleAlert ${attrs.join(" ")}${iconAttr}${classAttr}${collapsiblePassthrough}>${slot}
902
+ ${indentStr(inner, 2)}
903
+ </CollapsibleAlert>`;
904
+ }
620
905
  return `<Alert variant="${escapeAttr(variant)}"${iconAttr}${classAttr}${passthrough}>
621
- <AlertTitle>${escapeTemplate(title)}</AlertTitle>
906
+ <AlertTitle>${plainTitle ? escapeTemplate(captionText(titleNodes)) : renderInline(titleNodes, ctx)}</AlertTitle>
622
907
  <AlertDescription>
623
908
  ${indentStr(inner, 4)}
624
909
  </AlertDescription>
625
910
  </Alert>`;
626
911
  }
912
+ /**
913
+ * A presence flag: `open`, `open=""`, `open="true"` and `true` are on; absent, `false` and `"false"` are
914
+ * off. A bare `{open}` parses as "" (falsy), and "false" is truthy, so a plain truth test got both wrong
915
+ * (MD-003). render-html reads flags with the same rule (`flagOn`).
916
+ */
917
+ function flagOn(v) {
918
+ return v !== undefined && v !== null && v !== false && v !== "false";
919
+ }
920
+ /** CollapsibleAlert's variants; `plain` is chrome (no tint, icon or role). */
921
+ const COLLAPSIBLE_VARIANTS = new Set(["plain", "destructive", "note", "abstract", "info", "tip", "success", "question", "warning", "failure", "danger", "bug", "example", "quote"]);
922
+ /** A disclosure's variant: lower-cased, aliases mapped, anything CollapsibleAlert does not draw → note. */
923
+ function collapsibleVariant(raw) {
924
+ const v = raw.toLowerCase();
925
+ const mapped = CALLOUT_VARIANT_MAP[v] ?? v;
926
+ return COLLAPSIBLE_VARIANTS.has(mapped) ? mapped : "note";
927
+ }
928
+ /** A callout with `collapsible` on or any `collapsed` is a disclosure (render-html's `callout`). */
929
+ function isCollapsibleCallout(props) {
930
+ return flagOn(props?.collapsible) || (props?.collapsed !== undefined && props?.collapsed !== null);
931
+ }
932
+ /** It starts open when `collapsed` is off (`collapsed="false"`) or `open` is on. */
933
+ function calloutStartsOpen(props) {
934
+ const c = props?.collapsed;
935
+ return c === false || c === "false" || flagOn(props?.open);
936
+ }
627
937
  /**
628
938
  * Merge a default class string with an optional user-provided class string.
629
939
  * Concatenates (user classes appended) so utility frameworks like Tailwind
630
940
  * resolve conflicts via component-internal class-variance-authority / tv().
631
941
  */
632
- function mergeClassAttr(defaults, userClass) {
942
+ function mergeClassAttr(defaults, userClass, target = "astro") {
633
943
  const combined = [defaults, userClass].filter(Boolean).join(" ").trim();
634
- return combined ? ` class="${escapeAttr(combined)}"` : "";
944
+ if (!combined)
945
+ return "";
946
+ return target === "html"
947
+ ? ` class="${escapeAttr(combined)}"`
948
+ : componentAttr("class", combined);
635
949
  }
636
950
  /**
637
951
  * Render attributes not consumed as component props — `id`, `data-*`, `aria-*`,
638
952
  * and `style`. These pass through to the rendered element.
639
953
  */
640
- function renderPassthroughAttrs(props, consumed) {
954
+ function renderPassthroughAttrs(props, consumed, target = "astro") {
641
955
  if (!props)
642
956
  return "";
643
957
  const skip = new Set([...consumed, "source", "children"]);
@@ -652,9 +966,12 @@ function renderPassthroughAttrs(props, consumed) {
652
966
  if (value === true) {
653
967
  parts.push(key);
654
968
  }
655
- else {
969
+ else if (target === "html") {
656
970
  parts.push(`${key}="${escapeAttr(String(value))}"`);
657
971
  }
972
+ else {
973
+ parts.push(componentAttr(key, String(value)).trimStart());
974
+ }
658
975
  }
659
976
  return parts.length ? " " + parts.join(" ") : "";
660
977
  }
@@ -717,25 +1034,78 @@ function wrapBlock(tag, defaultClass, node, content, consumed = []) {
717
1034
  return `<${tag}${classAttr}${passthrough}>${sep}${content}${sep}</${tag}>`;
718
1035
  void needsNewline;
719
1036
  }
1037
+ /**
1038
+ * `:::files` around a nested bullet list: FileTree, whose markup (files/markup.ts) dogsbay-ssg
1039
+ * shares. An entry is a folder when it has entries or its name ends in "/".
1040
+ */
1041
+ function filesToAstro(node, ctx) {
1042
+ ctx.imports.add("files");
1043
+ const isList = (n) => n.type === "unordered-list" || n.type === "ordered-list";
1044
+ const items = (list) => (list?.children ?? [])
1045
+ .filter((li) => li.type === "list-item")
1046
+ .map((li) => {
1047
+ const inline = li.inline ?? (li.children ?? []).find((c) => c.type === "paragraph")?.inline ?? [];
1048
+ const sub = (li.children ?? []).find(isList);
1049
+ const text = inline.map((n) => ("text" in n && typeof n.text === "string" ? n.text : "")).join("");
1050
+ return { label: inlineToHtml(inline), folder: !!sub || /\/\s*$/.test(text), children: items(sub) };
1051
+ });
1052
+ const classAttr = mergeClassAttr("", node.props?.class);
1053
+ // Each list is a tree; anything else in the block (an intro, a note) renders as it is.
1054
+ // `<` is escaped in the JSON: a label may hold inline HTML, and a literal `</script>` in an
1055
+ // expression breaks Vite's dependency scan (see escapeExpr).
1056
+ return (node.children ?? [])
1057
+ .map((child) => isList(child)
1058
+ ? `<FileTree items={${JSON.stringify(items(child)).replace(/</g, "\\u003c")}}${classAttr} />`
1059
+ : nodeToAstro(child, ctx))
1060
+ .filter(Boolean)
1061
+ .join("\n");
1062
+ }
1063
+ /**
1064
+ * A `:::name` block with a name no renderer knows (a generic container): its content in a
1065
+ * marked <div>, the title (text after the name) first.
1066
+ */
1067
+ function genericContainerToAstro(node, ctx) {
1068
+ const name = typeof node.meta?.name === "string" ? node.meta.name : "container";
1069
+ const title = typeof node.meta?.title === "string" ? `<p class="font-semibold">${escapeTemplate(node.meta.title)}</p>\n` : "";
1070
+ const classAttr = mergeClassAttr("", node.props?.class);
1071
+ const passthrough = renderPassthroughAttrs(node.props, ["class"]);
1072
+ // Nested props (a `---` block on an unknown directive) reach the page as one JSON attribute,
1073
+ // so a site's own component or script can read them; scalar props pass through as before.
1074
+ const nested = Object.fromEntries(Object.entries(node.props ?? {}).filter(([, v]) => v !== null && typeof v === "object"));
1075
+ const dataProps = Object.keys(nested).length ? ` data-props="${escapeAttr(JSON.stringify(nested))}"` : "";
1076
+ return `<div data-directive="${escapeAttr(name)}"${classAttr}${passthrough}${dataProps}>\n${title}${childrenToAstro(node, ctx)}\n</div>`;
1077
+ }
720
1078
  function detailsToAstro(node, ctx) {
721
1079
  ctx.imports.add("details");
722
- const variant = node.props?.variant ?? "note";
723
- const title = node.props?.title ?? "Details";
724
- const open = node.props?.open;
1080
+ // `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
1081
+ // Aliases (`caution` → danger) and unknown names (→ note) as render-html maps them
1082
+ const variant = collapsibleVariant(node.props?.variant ?? node.props?.type ?? "note");
1083
+ // `summary` is dogsbay-md's alias for the title (`:::details{summary="…"}`)
1084
+ const title = node.props?.title ?? node.props?.summary ?? "Details";
1085
+ const open = flagOn(node.props?.open);
725
1086
  const icon = node.props?.icon;
726
1087
  const inner = childrenToAstro(node, ctx);
727
- const attrs = [`variant="${escapeAttr(variant)}"`, `title="${escapeAttr(title)}"`];
1088
+ // The title is inline Markdown, as in render-html: plain text stays the `title` prop
1089
+ const titleNodes = titleInline(title);
1090
+ const plainTitle = isPlainCaption(titleNodes);
1091
+ const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
728
1092
  if (open)
729
1093
  attrs.push("open");
730
1094
  if (icon)
731
1095
  attrs.push(`icon="${escapeAttr(icon)}"`);
732
1096
  const classAttr = mergeClassAttr("my-4", node.props?.class);
733
1097
  const passthrough = renderPassthroughAttrs(node.props, [
734
- "variant", "title", "open", "icon", "class", "summary",
1098
+ "variant", "type", "title", "open", "icon", "class", "summary", "sheet",
735
1099
  ]);
736
- return `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>
1100
+ const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
1101
+ const alert = `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>${slot}
737
1102
  ${indentStr(inner, 2)}
738
1103
  </CollapsibleAlert>`;
1104
+ // `sheet`: the same details, opened as a side sheet by <db-details-sheet>
1105
+ if (!isDetailsSheet(node.props))
1106
+ return alert;
1107
+ ctx.imports.add("details-sheet");
1108
+ return `<DetailsSheet>\n${indentStr(alert, 2)}\n</DetailsSheet>`;
739
1109
  }
740
1110
  function tabsToAstro(node, ctx) {
741
1111
  ctx.imports.add("tabs");
@@ -743,45 +1113,162 @@ function tabsToAstro(node, ctx) {
743
1113
  if (tabs.length === 0)
744
1114
  return "";
745
1115
  const tabItems = tabs.map((tab, i) => {
746
- const title = tab.props?.title || tab.props?.label || tab.props?.value || `Tab ${i + 1}`;
1116
+ // The same order as render-html: `label` is ours, `title` what a Comark import wrote
1117
+ const title = tab.props?.label || tab.props?.title || tab.props?.value || `Tab ${i + 1}`;
747
1118
  const value = tab.props?.value || title;
748
1119
  const label = title;
749
1120
  return { value, label, node: tab };
750
1121
  });
751
1122
  const defaultValue = tabItems[0].value;
1123
+ // Diff-trigger indicator: a marked tab panel is invisible until
1124
+ // opened, so its TRIGGER carries a data-diff-tab attribute (styled
1125
+ // as a colored dot by DIFF_CSS) telling the reader WHICH tab to
1126
+ // open. Structural read of @dogsbay/tree-diff marks, same as
1127
+ // diff-decoration; unmarked trees are unaffected.
1128
+ // The indicator must not live in COLOUR alone (WCAG 1.4.1), and a
1129
+ // background-tinted dot disappears in forced-colors mode. So it is a
1130
+ // GLYPH (shape carries the meaning) plus visually-hidden text, so the
1131
+ // trigger announces "Astro, changed in this comparison" rather than
1132
+ // relying on a `title` screen readers may never speak. (TAB_GLYPH and
1133
+ // tabDiffSignal are shared with render-html.)
1134
+ // A variant's class strings are complete (TAB_VARIANTS): TabsList and
1135
+ // TabsTrigger merge a `class` over their base with tailwind-variants
1136
+ const variant = lookup(TAB_VARIANTS, node.props?.variant);
1137
+ const triggerClass = variant ? ` class="${escapeAttr(variant.trigger)}"` : "";
752
1138
  const triggers = tabItems
753
- .map((t) => ` <TabsTrigger value="${escapeAttr(t.value)}">${escapeTemplate(t.label)}</TabsTrigger>`)
1139
+ .map((t) => {
1140
+ const signal = tabDiffSignal(t.node);
1141
+ if (!signal) {
1142
+ return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass}>${escapeTemplate(t.label)}</TabsTrigger>`;
1143
+ }
1144
+ const glyph = TAB_GLYPH[signal] ?? "•";
1145
+ // The glyph and its announced text are ONE element: "Hide
1146
+ // highlights" hides `.db-tab-mark`, and a sibling sr-only span
1147
+ // would survive that — leaving screen-reader users still hearing
1148
+ // "changed in this comparison" on every tab while the visible
1149
+ // marker is gone (code review, 2026-07-13).
1150
+ const marker = `<span class="db-tab-mark" data-diff-tab="${escapeAttr(signal)}">` +
1151
+ `<span aria-hidden="true">${glyph}</span>` +
1152
+ `<span class="sr-only">${escapeTemplate(signal)} in this comparison</span>` +
1153
+ `</span>`;
1154
+ // `value` MUST match TabsContent's, which is a componentAttr expression:
1155
+ // a title containing `&` was emitted `&amp;` here and `&` there, so the
1156
+ // trigger and its panel no longer paired and the tab rendered empty.
1157
+ // `data-diff-tab` is a plain data attribute — entity escaping is right.
1158
+ return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass} data-diff-tab="${escapeAttr(signal)}">${escapeTemplate(t.label)}<Fragment set:html={${escapeExpr(marker)}} /></TabsTrigger>`;
1159
+ })
754
1160
  .join("\n");
755
1161
  const contents = tabItems
756
1162
  .map((t) => {
757
1163
  const inner = childrenToAstro(t.node, ctx);
758
- return ` <TabsContent value="${escapeAttr(t.value)}">\n${indentStr(inner, 4)}\n </TabsContent>`;
1164
+ return ` <TabsContent${componentAttr("value", t.value)}>\n${indentStr(inner, 4)}\n </TabsContent>`;
759
1165
  })
760
1166
  .join("\n");
761
1167
  const classAttr = mergeClassAttr("my-4", node.props?.class);
762
1168
  const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
763
- return `<Tabs defaultValue="${escapeAttr(defaultValue)}"${classAttr}${passthrough}>
764
- <TabsList>
1169
+ // Groups with the same key switch together (db-tabs). Written `syncKey`, `sync-key` or `group`, as dogsbay-ssg reads it.
1170
+ const syncKey = [node.props?.syncKey, node.props?.["sync-key"], node.props?.group].find((v) => typeof v === "string" && v !== "");
1171
+ const syncAttr = syncKey ? componentAttr("syncKey", syncKey) : "";
1172
+ // Same encoding as the trigger/content `value` it selects — see above.
1173
+ return `<Tabs${componentAttr("defaultValue", defaultValue)}${syncAttr}${classAttr}${passthrough}>
1174
+ <TabsList${variant ? ` class="${escapeAttr(variant.list)}"` : ""}>
765
1175
  ${triggers}
766
1176
  </TabsList>
767
1177
  ${contents}
768
1178
  </Tabs>`;
769
1179
  }
1180
+ /**
1181
+ * A caption is inline Markdown. Plain text renders exactly as before;
1182
+ * formatting (`Sample \`<plugin>\` file`) renders as inline markup.
1183
+ */
1184
+ function captionInline(caption) {
1185
+ return parseInline(caption);
1186
+ }
1187
+ const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
1188
+ /**
1189
+ * Formatting tags with no attributes, the HTML a title keeps (a raw `<details><summary><code>x</code>`
1190
+ * becomes the title `<code>x</code>`). render-html has the same list.
1191
+ */
1192
+ const TITLE_TAG = /^<\/?(?:code|kbd|b|strong|i|em|s|del|mark|sub|sup|br)\s*\/?>$/i;
1193
+ /**
1194
+ * A callout or details title: inline Markdown, and any other raw HTML is TEXT. Importers write
1195
+ * plain-text titles with angle brackets (`Map<K, V>`, `List<String>`), which as HTML dropped words,
1196
+ * and a title with `<img onerror=…>` ran a script in the summary. render-html reads titles the same way.
1197
+ */
1198
+ function titleInline(title) {
1199
+ const nodes = captionInline(title);
1200
+ const kept = balancedTitleTags(nodes);
1201
+ return nodes.map((n, i) => (n.type === "html-inline" && !kept.has(i) ? { type: "text", text: n.html } : n));
1202
+ }
1203
+ /**
1204
+ * The indexes of the formatting tags a title keeps: a bare `TITLE_TAG` that is one half of a
1205
+ * matched pair (or `<br>`). An unclosed `<em>` was kept, and the browser carried it past the
1206
+ * title's element into the rest of the page (render-html has the same rule).
1207
+ */
1208
+ function balancedTitleTags(nodes) {
1209
+ const kept = new Set();
1210
+ const open = [];
1211
+ nodes.forEach((n, i) => {
1212
+ if (n.type !== "html-inline")
1213
+ return;
1214
+ const html = n.html.trim();
1215
+ if (!TITLE_TAG.test(html))
1216
+ return;
1217
+ const name = /^<\/?\s*(\w+)/.exec(html)[1].toLowerCase();
1218
+ if (name === "br")
1219
+ kept.add(i);
1220
+ else if (!html.startsWith("</"))
1221
+ open.push({ name, at: i });
1222
+ else if (open.length && open[open.length - 1].name === name) {
1223
+ kept.add(open.pop().at);
1224
+ kept.add(i);
1225
+ }
1226
+ });
1227
+ return kept;
1228
+ }
1229
+ /** A plain caption's text (escapes undone by the parse). */
1230
+ const captionText = (nodes) => nodes.map((n) => (n.type === "text" ? n.text : "")).join("");
1231
+ function captionToAstro(caption, ctx) {
1232
+ // Raw HTML other than balanced formatting tags is text, as in a title (an imported repo's caption
1233
+ // is someone else's; render-html draws captions the same way).
1234
+ const nodes = titleInline(caption);
1235
+ return isPlainCaption(nodes)
1236
+ ? escapeTemplate(captionText(nodes))
1237
+ : renderInline(nodes, ctx);
1238
+ }
770
1239
  function tableToAstro(node, ctx) {
771
1240
  ctx.imports.add("table");
772
1241
  const classAttr = mergeClassAttr("my-4", node.props?.class);
773
- const passthrough = renderPassthroughAttrs(node.props, ["class"]);
774
- return `<Table${classAttr}${passthrough}>\n${childrenToAstro(node, ctx)}\n</Table>`;
1242
+ const caption = node.props?.caption;
1243
+ const nodes = caption ? captionInline(caption) : [];
1244
+ const plain = isPlainCaption(nodes);
1245
+ // A plain caption stays the `caption` prop; a formatted one fills the table's caption slot
1246
+ const captionAttr = caption && plain
1247
+ ? ` caption={${escapeExpr(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))}}`
1248
+ : "";
1249
+ const captionSlot = caption && !plain ? `<Fragment slot="caption">${renderInline(nodes, ctx)}</Fragment>\n` : "";
1250
+ const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
1251
+ return `<Table${classAttr}${captionAttr}${passthrough}>\n${captionSlot}${childrenToAstro(node, ctx)}\n</Table>`;
775
1252
  }
776
1253
  function diagramToAstro(node, ctx) {
777
1254
  ctx.imports.add("diagram");
778
1255
  const lang = node.props?.lang || "mermaid";
779
1256
  const code = node.props?.code ?? "";
780
- const svg = node.props?.svg ?? "";
1257
+ let svg = node.props?.svg ?? "";
781
1258
  const title = node.props?.title;
1259
+ // A Pele SVG's ids (`<prefix>-title`, `<prefix>-desc`, for aria-labelledby) are unique within
1260
+ // its source page. A book view or an include can show the same diagram twice on one page, so
1261
+ // the later copies are renamed; the prefix appears nowhere else in the SVG.
1262
+ const prefix = /^<svg[^>]*\bclass="pele\b/.test(svg) ? /\sid="(dg-[0-9a-f]{12}(?:-\d+)?)-/.exec(svg)?.[1] : undefined;
1263
+ if (prefix) {
1264
+ const n = (ctx.diagramIds.get(prefix) ?? 0) + 1;
1265
+ ctx.diagramIds.set(prefix, n);
1266
+ if (n > 1)
1267
+ svg = svg.split(prefix).join(`${prefix}-p${n}`);
1268
+ }
782
1269
  const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
783
1270
  if (title)
784
- attrs.push(`title="${escapeAttr(title)}"`);
1271
+ attrs.push(componentAttr("title", title).trimStart());
785
1272
  const classAttr = mergeClassAttr("", node.props?.class);
786
1273
  const passthrough = renderPassthroughAttrs(node.props, [
787
1274
  "lang", "code", "svg", "title", "class",
@@ -794,7 +1281,7 @@ function youtubeToAstro(node, ctx) {
794
1281
  const title = node.props?.title ?? "";
795
1282
  const classAttr = mergeClassAttr("my-4", node.props?.class);
796
1283
  const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
797
- return `<YouTube id="${escapeAttr(id)}" title="${escapeAttr(title)}"${classAttr}${passthrough} />`;
1284
+ return `<YouTube${componentAttr("id", id)}${componentAttr("title", title)}${classAttr}${passthrough} />`;
798
1285
  }
799
1286
  function mathBlockToAstro(node, ctx) {
800
1287
  ctx.imports.add("math-block");
@@ -837,6 +1324,11 @@ function footnoteListToAstro(node, ctx) {
837
1324
  function footnoteDefToAstro(node, ctx) {
838
1325
  const id = node.props?.id ?? "";
839
1326
  const backrefId = node.props?.backrefId ?? "";
1327
+ // Block content (a list, code): the blocks, with the backref after them
1328
+ if (node.children?.length) {
1329
+ const backref = backrefId ? `\n<a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
1330
+ return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">\n${childrenToAstro(node, ctx)}${backref}\n</li>`;
1331
+ }
840
1332
  if (ctx.mode === "template" && node.inline) {
841
1333
  const content = inlineToTemplate(node.inline);
842
1334
  const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
@@ -852,8 +1344,14 @@ function apiSymbolToAstro(node, ctx) {
852
1344
  const kind = node.props?.kind ?? "func";
853
1345
  const signature = node.props?.signature;
854
1346
  const bases = node.props?.bases || [];
855
- const level = node.type === "api-class" ? 2 : 3;
856
- const attrs = [`name="${escapeAttr(name)}"`, `kind="${escapeAttr(kind)}"`, `level={${level}}`];
1347
+ // By position, not by type (apiSymbolLevels): a module-level function was
1348
+ // an h3 straight under the page's h1, and a class inside a class an h2.
1349
+ const level = ctx.apiSymbolLevels.get(node) ?? (node.type === "api-class" ? 2 : 3);
1350
+ const attrs = [
1351
+ componentAttr("name", name).trimStart(),
1352
+ componentAttr("kind", kind).trimStart(),
1353
+ `level={${level}}`,
1354
+ ];
857
1355
  if (signature)
858
1356
  attrs.push(`signature={${escapeExpr(signature)}}`);
859
1357
  if (bases.length > 0)
@@ -902,11 +1400,17 @@ function endpointToAstro(node, ctx) {
902
1400
  const path = props.path ?? "";
903
1401
  const baseUrl = props.baseUrl ?? "";
904
1402
  const summary = props.summary;
905
- const description = props.description;
1403
+ // Descriptions are CommonMark, rendered with raw HTML off: the components
1404
+ // put them in the page with set:html (api-markdown.ts).
1405
+ // (Compared as text first: the card hides a description equal to the
1406
+ // summary, and the rendered HTML never equals it.)
1407
+ const description = typeof props.description === "string" && props.description !== props.summary
1408
+ ? apiBlockHtml(props.description)
1409
+ : undefined;
906
1410
  const deprecated = props.deprecated === true;
907
- const parameters = props.parameters ?? [];
908
- const requestBody = props.requestBody ?? [];
909
- const responses = props.responses ?? [];
1411
+ const parameters = apiPropertiesHtml(props.parameters) ?? [];
1412
+ const requestBody = apiPropertiesHtml(props.requestBody) ?? [];
1413
+ const responses = (props.responses ?? []).map((r) => r && Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r);
910
1414
  const codeSamples = props.codeSamples ?? [];
911
1415
  const requestBodyExample = props.requestBodyExample;
912
1416
  const cardAttrs = [
@@ -946,7 +1450,9 @@ function endpointToAstro(node, ctx) {
946
1450
  const sectionsBody = sections.length > 0
947
1451
  ? `\n${indentStr(sections.join("\n"), 4)}\n `
948
1452
  : "";
949
- // Code column — wrapped in <ApiCodePanel> for consistent vertical
1453
+ // Code column — its titles are labels (<p>), not headings: an operation
1454
+ // page has only its h1 above them, so an h3 skipped a level.
1455
+ // Wrapped in <ApiCodePanel> for consistent vertical
950
1456
  // rhythm. Code samples first, then a request body example, then one
951
1457
  // ExampleBlock per response status that carries a concrete example.
952
1458
  // Each ExampleBlock auto-detects language from the response's
@@ -955,7 +1461,7 @@ function endpointToAstro(node, ctx) {
955
1461
  const codePanels = [];
956
1462
  if (codeSamples.length > 0) {
957
1463
  codePanels.push(`<div>\n` +
958
- ` <h3 class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</h3>\n` +
1464
+ ` <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</p>\n` +
959
1465
  ` <CodeSamples samples={${JSON.stringify(codeSamples)}} />\n` +
960
1466
  `</div>`);
961
1467
  }
@@ -975,31 +1481,66 @@ function endpointToAstro(node, ctx) {
975
1481
  const codeBody = codePanels.length > 0
976
1482
  ? `\n${indentStr(codePanels.join("\n"), 4)}\n `
977
1483
  : "";
978
- return (`<ApiLayout>\n` +
1484
+ const layoutAttrs = ctx.embeddedEndpoints ? ' variant="embedded"' : "";
1485
+ return (`<ApiLayout${layoutAttrs}>\n` +
979
1486
  ` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
980
1487
  ` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
981
1488
  `</ApiLayout>`);
982
1489
  }
1490
+ /**
1491
+ * Whole-card links being emitted. Inside one, a link in the card's body is its text: a browser
1492
+ * ends the outer <a> at an inner one, so the rest of the card fell out of the link (and lost
1493
+ * not-prose). The serializer is synchronous, so a depth count is enough.
1494
+ */
1495
+ let cardLinkDepth = 0;
1496
+ function wholeCardLink(yes, fn) {
1497
+ if (!yes)
1498
+ return fn();
1499
+ cardLinkDepth++;
1500
+ try {
1501
+ return fn();
1502
+ }
1503
+ finally {
1504
+ cardLinkDepth--;
1505
+ }
1506
+ }
1507
+ /** A card's title: not-prose, or docs.css's h3 rule (1.25rem, 2rem above) applies inside the card. */
1508
+ const CARD_TITLE_CLASS = "not-prose m-0 border-0 p-0 text-base font-semibold";
1509
+ /**
1510
+ * A card group's columns, responsive as render-html draws them (its GRID_COLS): one column on a
1511
+ * phone, then up to `cols` (1–6) by Grid's breakpoints (0, 640, 768, 1024). Default 2. A list
1512
+ * (`cols="1,2,3"`) is the author's own breakpoints and passes through.
1513
+ */
1514
+ function cardCols(raw) {
1515
+ const list = coerceNumericList(raw, [2]);
1516
+ if (list.length > 1)
1517
+ return list;
1518
+ const n = Math.min(6, Math.max(1, Math.round(list[0]) || 2));
1519
+ return n === 1 ? [1] : n === 2 ? [1, 2] : n <= 4 ? [1, 2, 2, n] : [1, 3, 3, n];
1520
+ }
983
1521
  function cardsToAstro(node, ctx) {
984
1522
  ctx.imports.add("grid");
985
1523
  ctx.imports.add("card");
986
1524
  // Grid-level attributes on the :::cards container
987
1525
  const outerClass = mergeClassAttr("", node.props?.class);
988
1526
  const outerPass = renderPassthroughAttrs(node.props, [
989
- "cols", "gap", "rows", "class",
1527
+ "cols", "gap", "rows", "class", "tone",
990
1528
  ]);
991
1529
  // MDX pattern: children are card nodes with title/href/icon props
992
1530
  const cardNodes = (node.children ?? []).filter((c) => c.type === "card");
993
1531
  if (cardNodes.length > 0) {
994
1532
  const cards = cardNodes.map((card) => {
995
1533
  const title = card.props?.title ?? "";
996
- const href = card.props?.href ?? "";
1534
+ const href = safeHref(card.props?.href);
997
1535
  const description = card.props?.description;
998
1536
  const icon = card.props?.icon;
999
1537
  // Description sources: inline content, children, or props.description.
1000
- const inner = childrenToAstro(card, ctx)
1001
- || (card.inline ? inlineToTemplate(card.inline) : "")
1538
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1539
+ const blocks = wholeCardLink(!!href, () => childrenToAstro(card, ctx));
1540
+ const inner = blocks
1541
+ || (card.inline ? wholeCardLink(!!href, () => inlineToTemplate(card.inline)) : "")
1002
1542
  || (description ? escapeTemplate(description) : "");
1543
+ const descTag = blocks ? "div" : "p";
1003
1544
  // Icon renders inline before the title — picks up Lucide
1004
1545
  // SVG via the `icon` import. Resolved at build time; nothing
1005
1546
  // emitted when the name doesn't resolve (silent fallback).
@@ -1007,26 +1548,32 @@ function cardsToAstro(node, ctx) {
1007
1548
  // top of the page.
1008
1549
  let iconHtml = "";
1009
1550
  if (icon) {
1010
- ctx.imports.add("icon");
1011
- iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1551
+ iconHtml = cardIconHtml(icon, title, ctx);
1012
1552
  }
1013
- const titleHtml = title ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>` : "";
1014
- const desc = inner
1015
- ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
1553
+ const titleHtml = title ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>` : "";
1554
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1555
+ const lead = blocks && description
1556
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1016
1557
  : "";
1558
+ const desc = lead + (inner
1559
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1560
+ : "");
1561
+ // A tone (TONES; the card's own, else the group's) sets the card's
1562
+ // border and tint: Card's tailwind-variants merge replaces bg-card
1563
+ const toned = lookup(CARD_TONE_CLASSES, card.props?.tone ?? node.props?.tone);
1017
1564
  const cardDefaultClass = href
1018
- ? "p-4 hover:border-foreground/20 transition-colors"
1019
- : "p-4";
1565
+ ? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
1566
+ : toned ? `p-4 ${toned}` : "p-4";
1020
1567
  const cardClassAttr = mergeClassAttr(cardDefaultClass, card.props?.class);
1021
1568
  const cardPass = renderPassthroughAttrs(card.props, [
1022
- "title", "href", "description", "icon", "variant", "class",
1569
+ "title", "href", "description", "icon", "variant", "class", "tone",
1023
1570
  ]);
1024
1571
  if (href) {
1025
- return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="[text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
1572
+ return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="not-prose [text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
1026
1573
  }
1027
1574
  return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
1028
1575
  }).join("\n");
1029
- return `<Grid cols={[1, 2]} gap={4}${outerClass}${outerPass}>\n${cards}\n</Grid>`;
1576
+ return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}${outerClass}${outerPass}>\n${cards}\n</Grid>`;
1030
1577
  }
1031
1578
  // MkDocs pattern: children are unordered-list items
1032
1579
  const items = (node.children ?? [])
@@ -1036,10 +1583,10 @@ function cardsToAstro(node, ctx) {
1036
1583
  const cards = items
1037
1584
  .map((item) => `<Card>\n<CardContent>\n${childrenToAstro(item, ctx)}\n</CardContent>\n</Card>`)
1038
1585
  .join("\n");
1039
- return `<Grid cols={[1, 2]} gap={4}>\n${cards}\n</Grid>`;
1586
+ return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${cards}\n</Grid>`;
1040
1587
  }
1041
1588
  // Fallback: render children directly in grid
1042
- return `<Grid cols={[1, 2]} gap={4}>\n${childrenToAstro(node, ctx)}\n</Grid>`;
1589
+ return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${childrenToAstro(node, ctx)}\n</Grid>`;
1043
1590
  }
1044
1591
  /**
1045
1592
  * Render a standalone `card` TreeNode (outside `:::cards`).
@@ -1051,34 +1598,38 @@ function cardsToAstro(node, ctx) {
1051
1598
  function standaloneCardToAstro(node, ctx) {
1052
1599
  ctx.imports.add("card");
1053
1600
  const title = node.props?.title ?? "";
1054
- const href = node.props?.href ?? "";
1601
+ const href = safeHref(node.props?.href);
1055
1602
  const description = node.props?.description;
1056
1603
  const icon = node.props?.icon;
1057
- const inner = childrenToAstro(node, ctx)
1058
- || (node.inline ? inlineToTemplate(node.inline) : "")
1604
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1605
+ const blocks = wholeCardLink(!!href, () => childrenToAstro(node, ctx));
1606
+ const inner = blocks
1607
+ || (node.inline ? wholeCardLink(!!href, () => inlineToTemplate(node.inline)) : "")
1059
1608
  || (description ? escapeTemplate(description) : "");
1060
- // Icon renders inline before the title (same shape as the
1061
- // cards-list path). Resolved at build time via @dogsbay/icons.
1062
- let iconHtml = "";
1063
- if (icon) {
1064
- ctx.imports.add("icon");
1065
- iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1066
- }
1609
+ const descTag = blocks ? "div" : "p";
1610
+ // Icon renders inline before the title, a name or an image path, as in a card group
1611
+ const iconHtml = icon ? cardIconHtml(icon, title, ctx) : "";
1067
1612
  const titleHtml = title
1068
- ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>`
1613
+ ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>`
1069
1614
  : "";
1070
- const desc = inner
1071
- ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
1615
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1616
+ const lead = blocks && description
1617
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1072
1618
  : "";
1619
+ const desc = lead + (inner
1620
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1621
+ : "");
1622
+ // A tone (TONES), as on a card in a group (cardsToAstro)
1623
+ const toned = lookup(CARD_TONE_CLASSES, node.props?.tone);
1073
1624
  const cardDefaultClass = href
1074
- ? "p-4 hover:border-foreground/20 transition-colors"
1075
- : "p-4";
1625
+ ? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
1626
+ : toned ? `p-4 ${toned}` : "p-4";
1076
1627
  const classAttr = mergeClassAttr(cardDefaultClass, node.props?.class);
1077
1628
  const passthrough = renderPassthroughAttrs(node.props, [
1078
- "title", "href", "description", "icon", "variant", "class",
1629
+ "title", "href", "description", "icon", "variant", "class", "tone",
1079
1630
  ]);
1080
1631
  if (href) {
1081
- return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="[text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
1632
+ return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="not-prose [text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
1082
1633
  }
1083
1634
  return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
1084
1635
  }
@@ -1089,6 +1640,59 @@ function standaloneCardToAstro(node, ctx) {
1089
1640
  * If the parser kept rich body children instead of folding into description,
1090
1641
  * we fall back to the standalone-card path which can hold arbitrary content.
1091
1642
  */
1643
+ /**
1644
+ * `link-button` — a call-to-action link styled as a button.
1645
+ *
1646
+ * There was no case for this at all, so it fell through to rendering the
1647
+ * node's CHILDREN: `<p>Get started</p>`. The label survived, the href did not,
1648
+ * and nothing warned — the reader saw a stray sentence where the page's
1649
+ * primary call to action should be. Every importer that produces one was
1650
+ * affected; `Button.astro` has taken an `href` all along.
1651
+ */
1652
+ /**
1653
+ * A card icon is either a registry NAME or a path to an image file.
1654
+ *
1655
+ * `<Icon name>` resolves names and renders NOTHING when the name does not
1656
+ * resolve — so a path handed to it disappeared silently, which is how every
1657
+ * "Related products" card lost its logo without anyone noticing.
1658
+ */
1659
+ function cardIconHtml(icon, title, ctx) {
1660
+ if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon)) {
1661
+ return `<img src="${escapeAttr(icon)}" alt="" class="size-5 mb-2" loading="lazy" />`;
1662
+ }
1663
+ ctx.imports.add("icon");
1664
+ return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1665
+ }
1666
+ /**
1667
+ * A button's variant as `Button.astro` names it. The spec's `primary` is its
1668
+ * filled `default` style; `Button.astro` has no `primary`, so it rendered
1669
+ * with no fill at all.
1670
+ */
1671
+ function buttonVariant(variant) {
1672
+ return !variant || variant === "primary" ? "default" : variant;
1673
+ }
1674
+ function linkButtonToAstro(node, ctx) {
1675
+ ctx.imports.add("link-button");
1676
+ const href = safeHref(node.props?.href);
1677
+ const written = node.props?.variant;
1678
+ const variant = written === undefined ? undefined : buttonVariant(written);
1679
+ const classAttr = mergeClassAttr("", node.props?.class);
1680
+ const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
1681
+ // A button's label is phrasing content, so a lone paragraph child is
1682
+ // unwrapped — `<Button><p>Get started</p></Button>` is invalid markup and
1683
+ // renders with the paragraph's block spacing inside the button.
1684
+ const kids = node.children ?? [];
1685
+ const soleParagraph = kids.length === 1 && kids[0].type === "paragraph" ? kids[0] : undefined;
1686
+ const label = soleParagraph
1687
+ ? leafContent(soleParagraph, ctx)
1688
+ : leafContent(node, ctx) || childrenToAstro(node, ctx);
1689
+ return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
1690
+ }
1691
+ /** A card or button `href` from an importer, or "" (no link) when its
1692
+ * scheme could run script: these never passed markdown-it's link check. */
1693
+ function safeHref(href) {
1694
+ return typeof href === "string" && isSafeHref(href) ? href : "";
1695
+ }
1092
1696
  function linkCardToAstro(node, ctx) {
1093
1697
  const hasRichChildren = (node.children ?? []).length > 0;
1094
1698
  if (hasRichChildren) {
@@ -1097,15 +1701,17 @@ function linkCardToAstro(node, ctx) {
1097
1701
  ctx.imports.add("link-card");
1098
1702
  const title = node.props?.title ?? "";
1099
1703
  const description = node.props?.description;
1100
- const href = node.props?.href ?? "";
1101
- const titleAttr = ` title="${escapeAttr(title)}"`;
1102
- const hrefAttr = ` href="${escapeAttr(href)}"`;
1103
- const descAttr = description ? ` description="${escapeAttr(description)}"` : "";
1704
+ const href = safeHref(node.props?.href);
1705
+ const titleAttr = componentAttr("title", title);
1706
+ const hrefAttr = componentAttr("href", href);
1707
+ const descAttr = description ? componentAttr("description", description) : "";
1708
+ const icon = node.props?.icon;
1709
+ const iconAttr = icon ? componentAttr("icon", icon) : "";
1104
1710
  const classAttr = mergeClassAttr("", node.props?.class);
1105
1711
  const passthrough = renderPassthroughAttrs(node.props, [
1106
1712
  "title", "description", "href", "icon", "class",
1107
1713
  ]);
1108
- return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
1714
+ return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${iconAttr}${classAttr}${passthrough} />`;
1109
1715
  }
1110
1716
  /**
1111
1717
  * Render an `accordion` TreeNode and its `accordion-item` children.
@@ -1124,9 +1730,9 @@ function accordionToAstro(node, ctx) {
1124
1730
  const defaultValue = node.props?.defaultValue;
1125
1731
  const typeAttr = ` type="${escapeAttr(type)}"`;
1126
1732
  const defaultAttr = defaultValue !== undefined
1127
- ? ` defaultValue=${typeof defaultValue === "string"
1128
- ? `"${escapeAttr(defaultValue)}"`
1129
- : `{${JSON.stringify(defaultValue)}}`}`
1733
+ ? typeof defaultValue === "string"
1734
+ ? componentAttr("defaultValue", defaultValue)
1735
+ : ` defaultValue={${JSON.stringify(defaultValue)}}`
1130
1736
  : "";
1131
1737
  const classAttr = mergeClassAttr("", node.props?.class);
1132
1738
  const passthrough = renderPassthroughAttrs(node.props, [
@@ -1134,15 +1740,18 @@ function accordionToAstro(node, ctx) {
1134
1740
  ]);
1135
1741
  const items = (node.children ?? [])
1136
1742
  .filter((c) => c.type === "accordion-item")
1137
- .map((item) => accordionItemToAstro(item, ctx, /*standalone*/ false))
1743
+ .map((item, i) => accordionItemToAstro(item, ctx, /*standalone*/ false, i + 1))
1138
1744
  .join("\n");
1139
1745
  return `<Accordion${typeAttr}${defaultAttr}${classAttr}${passthrough}>\n${items}\n</Accordion>`;
1140
1746
  }
1141
- function accordionItemToAstro(node, ctx, standalone) {
1747
+ function accordionItemToAstro(node, ctx, standalone,
1748
+ /** 1-based position: an item with no `value` must not share one with its siblings */
1749
+ index = 1) {
1142
1750
  ctx.imports.add("accordion");
1143
- const value = node.props?.value ?? "item-1";
1751
+ // Items are matched by value: a shared fallback opened and closed them together
1752
+ const value = node.props?.value ?? `item-${index}`;
1144
1753
  const label = node.props?.label ?? node.props?.title ?? "Item";
1145
- const valueAttr = ` value="${escapeAttr(value)}"`;
1754
+ const valueAttr = componentAttr("value", value);
1146
1755
  const classAttr = mergeClassAttr("", node.props?.class);
1147
1756
  const passthrough = renderPassthroughAttrs(node.props, [
1148
1757
  "value", "label", "title", "class",
@@ -1169,9 +1778,9 @@ function avatarToAstro(node, ctx) {
1169
1778
  const src = node.props?.src ?? "";
1170
1779
  const alt = node.props?.alt ?? "";
1171
1780
  const fallback = node.props?.fallback;
1172
- const srcAttr = src ? ` src="${escapeAttr(src)}"` : "";
1173
- const altAttr = ` alt="${escapeAttr(alt)}"`;
1174
- const fbAttr = fallback ? ` fallback="${escapeAttr(fallback)}"` : "";
1781
+ const srcAttr = src ? componentAttr("src", src) : "";
1782
+ const altAttr = componentAttr("alt", alt);
1783
+ const fbAttr = fallback ? componentAttr("fallback", fallback) : "";
1175
1784
  const classAttr = mergeClassAttr("", node.props?.class);
1176
1785
  const passthrough = renderPassthroughAttrs(node.props, [
1177
1786
  "src", "alt", "fallback", "class",
@@ -1192,7 +1801,7 @@ function exampleToAstro(node, ctx) {
1192
1801
  const source = String(node.props?.source ?? "");
1193
1802
  const title = node.props?.title;
1194
1803
  const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
1195
- const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
1804
+ const titleAttr = title ? componentAttr("title", title) : "";
1196
1805
  const fallbackAttr = showFallback ? " showFallback" : "";
1197
1806
  const inner = childrenToAstro(node, ctx);
1198
1807
  return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
@@ -1207,29 +1816,34 @@ function htmlContainerToAstro(node, ctx) {
1207
1816
  const inner = childrenToAstro(node, ctx);
1208
1817
  return `<div class="columns-1 sm:columns-2 gap-4 [&_ul]:m-0">\n${inner}\n</div>`;
1209
1818
  }
1819
+ // A container parsed from raw HTML (a one-line `<details>` body) carries
1820
+ // its content in `html`, not children — rendering only children dropped it
1821
+ if (!node.children?.length && node.html)
1822
+ return htmlToAstro(node.html, ctx);
1210
1823
  const inner = childrenToAstro(node, ctx);
1211
1824
  return `<div>\n${inner}\n</div>`;
1212
1825
  }
1213
1826
  // ─── Utilities ────────────────────────────────────────────────────
1214
1827
  /**
1215
- * Render a node's content whether it's attached as flat inline (dogsbay-md
1216
- * parser shape) or as wrapped children (Starlight importer shape). Both
1217
- * shapes supported; inline comes before children if both present.
1828
+ * Render a node's content across all THREE TreeNode shapes: flat `inline`
1829
+ * (dogsbay-md parser), wrapped `children[{prose, inline}]` (Starlight
1830
+ * importer), and a node carrying only `html` (MDX/Starlight/MkDocs). Inline
1831
+ * comes before children when both are present.
1218
1832
  *
1219
1833
  * Used for list-item, table cells, step, dt, dd — types that commonly
1220
1834
  * carry leaf text content directly on the node.
1835
+ *
1836
+ * This function is where serialize-core's `renderLeaf` was extracted FROM, so
1837
+ * delegating to it closes the loop: the wrapper-dedup and html-shape handling
1838
+ * the core grew afterwards (from the Docusaurus dogfooding bug) now flow back
1839
+ * here instead of the two implementations continuing to drift apart.
1221
1840
  */
1222
1841
  function leafContent(node, ctx) {
1223
- const parts = [];
1224
- if (node.inline && node.inline.length > 0) {
1225
- parts.push(inlineToTemplate(node.inline));
1226
- }
1227
- if (node.children && node.children.length > 0) {
1228
- const rendered = childrenToAstro(node, ctx);
1229
- if (rendered)
1230
- parts.push(rendered);
1231
- }
1232
- return parts.join("\n");
1842
+ return renderLeaf(node, {
1843
+ inline: inlineToTemplate,
1844
+ children: () => childrenToAstro(node, ctx),
1845
+ html: (html) => `<Fragment set:html={${escapeExpr(html)}} />`,
1846
+ });
1233
1847
  }
1234
1848
  function childrenToAstro(node, ctx) {
1235
1849
  if (!node.children || node.children.length === 0)
@@ -1243,40 +1857,28 @@ function childrenToAstro(node, ctx) {
1243
1857
  * icon usage on cards registers `icon` at the renderer call site;
1244
1858
  * this is the inline-only path. Returns true on first hit.
1245
1859
  */
1246
- function treeContainsInlineIcon(nodes) {
1247
- if (!nodes)
1248
- return false;
1249
- for (const node of nodes) {
1250
- if (node.inline) {
1251
- for (const inl of node.inline) {
1252
- if (inlineHasIcon(inl))
1253
- return true;
1254
- }
1255
- }
1256
- if (treeContainsInlineIcon(node.children))
1257
- return true;
1258
- }
1259
- return false;
1260
- }
1261
- function inlineHasIcon(node) {
1262
- if (node.type === "icon")
1263
- return true;
1264
- if ("children" in node && Array.isArray(node.children)) {
1265
- for (const child of node.children) {
1266
- if (inlineHasIcon(child))
1267
- return true;
1268
- }
1860
+ /** The component imports inline nodes need: `icon`, `badge`, and `link-button` for an inline `:button`. */
1861
+ function inlineComponentsIn(nodes, out = new Set()) {
1862
+ const visit = (node) => {
1863
+ if (node.type === "icon")
1864
+ out.add("icon");
1865
+ else if (node.type === "badge")
1866
+ out.add("badge");
1867
+ else if (node.type === "kbd")
1868
+ out.add("kbd");
1869
+ else if (node.type === "link" && node.button)
1870
+ out.add("link-button");
1871
+ if ("children" in node && Array.isArray(node.children))
1872
+ node.children.forEach(visit);
1873
+ };
1874
+ for (const node of nodes ?? []) {
1875
+ node.inline?.forEach(visit);
1876
+ inlineComponentsIn(node.children, out);
1269
1877
  }
1270
- return false;
1271
- }
1272
- function indentStr(text, spaces) {
1273
- const prefix = " ".repeat(spaces);
1274
- return text
1275
- .split("\n")
1276
- .map((line) => (line ? `${prefix}${line}` : line))
1277
- .join("\n");
1878
+ return out;
1278
1879
  }
1880
+ /** Indent, via the core's shared implementation (was a fourth local copy). */
1881
+ const indentStr = indent;
1279
1882
  function escapeHtml(s) {
1280
1883
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1281
1884
  }
1282
- //# sourceMappingURL=serialize.js.map