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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/api-markdown.d.ts +8 -0
  2. package/dist/api-markdown.d.ts.map +1 -0
  3. package/dist/api-markdown.js +50 -0
  4. package/dist/api-markdown.js.map +1 -0
  5. package/dist/base-path.d.ts +93 -7
  6. package/dist/base-path.d.ts.map +1 -1
  7. package/dist/base-path.js +122 -8
  8. package/dist/base-path.js.map +1 -1
  9. package/dist/blog.d.ts +134 -0
  10. package/dist/blog.d.ts.map +1 -0
  11. package/dist/blog.js +324 -0
  12. package/dist/blog.js.map +1 -0
  13. package/dist/browser.d.ts +14 -0
  14. package/dist/browser.d.ts.map +1 -0
  15. package/dist/browser.js +14 -0
  16. package/dist/browser.js.map +1 -0
  17. package/dist/canonical.d.ts +34 -0
  18. package/dist/canonical.d.ts.map +1 -0
  19. package/dist/canonical.js +107 -0
  20. package/dist/canonical.js.map +1 -0
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +1 -0
  23. package/dist/cli.js.map +1 -1
  24. package/dist/diff-decoration.d.ts +79 -0
  25. package/dist/diff-decoration.d.ts.map +1 -0
  26. package/dist/diff-decoration.js +541 -0
  27. package/dist/diff-decoration.js.map +1 -0
  28. package/dist/granularity.d.ts +83 -0
  29. package/dist/granularity.d.ts.map +1 -0
  30. package/dist/granularity.js +247 -0
  31. package/dist/granularity.js.map +1 -0
  32. package/dist/head-metadata.d.ts +26 -0
  33. package/dist/head-metadata.d.ts.map +1 -0
  34. package/dist/head-metadata.js +56 -0
  35. package/dist/head-metadata.js.map +1 -0
  36. package/dist/index.d.ts +24 -4
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +30 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/lead.d.ts +1 -38
  41. package/dist/lead.d.ts.map +1 -1
  42. package/dist/lead.js +3 -37
  43. package/dist/lead.js.map +1 -1
  44. package/dist/llms-txt.d.ts +53 -11
  45. package/dist/llms-txt.d.ts.map +1 -1
  46. package/dist/llms-txt.js +85 -222
  47. package/dist/llms-txt.js.map +1 -1
  48. package/dist/plugins.d.ts.map +1 -1
  49. package/dist/plugins.js +4 -2
  50. package/dist/plugins.js.map +1 -1
  51. package/dist/project.d.ts +377 -12
  52. package/dist/project.d.ts.map +1 -1
  53. package/dist/project.js +2263 -661
  54. package/dist/project.js.map +1 -1
  55. package/dist/redirects.d.ts +116 -0
  56. package/dist/redirects.d.ts.map +1 -0
  57. package/dist/redirects.js +224 -0
  58. package/dist/redirects.js.map +1 -0
  59. package/dist/serialize.d.ts +14 -0
  60. package/dist/serialize.d.ts.map +1 -1
  61. package/dist/serialize.js +643 -216
  62. package/dist/serialize.js.map +1 -1
  63. package/dist/sitemap.d.ts +108 -0
  64. package/dist/sitemap.d.ts.map +1 -0
  65. package/dist/sitemap.js +127 -0
  66. package/dist/sitemap.js.map +1 -0
  67. package/dist/taxonomy.d.ts +48 -1
  68. package/dist/taxonomy.d.ts.map +1 -1
  69. package/dist/taxonomy.js +46 -11
  70. package/dist/taxonomy.js.map +1 -1
  71. package/dist/theme-css.d.ts +26 -0
  72. package/dist/theme-css.d.ts.map +1 -0
  73. package/dist/theme-css.js +642 -0
  74. package/dist/theme-css.js.map +1 -0
  75. package/dist/tone-classes.d.ts +14 -0
  76. package/dist/tone-classes.d.ts.map +1 -0
  77. package/dist/tone-classes.js +30 -0
  78. package/dist/tone-classes.js.map +1 -0
  79. package/dist/write-if-changed.d.ts +21 -0
  80. package/dist/write-if-changed.d.ts.map +1 -0
  81. package/dist/write-if-changed.js +58 -0
  82. package/dist/write-if-changed.js.map +1 -0
  83. package/package.json +16 -7
package/dist/serialize.js CHANGED
@@ -1,25 +1,9 @@
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, } from "@dogsbay/serialize-core";
2
+ import { parseInline } from "@dogsbay/format-dogsbay-md";
3
+ import { decorateDiff, hasDiffMarks } from "./diff-decoration.js";
4
+ import { TONE_CLASSES } from "./tone-classes.js";
5
+ import { apiBlockHtml, apiPropertiesHtml } from "./api-markdown.js";
6
+ export { TONE_CLASSES };
23
7
  const COMPONENT_IMPORTS = {
24
8
  "markdown-example": [
25
9
  'import MarkdownExample from "@ui/markdown-example/MarkdownExample.astro";',
@@ -55,6 +39,9 @@ const COMPONENT_IMPORTS = {
55
39
  ],
56
40
  diagram: ['import Diagram from "@ui/diagram/Diagram.astro";'],
57
41
  youtube: ['import YouTube from "@ui/youtube/YouTube.astro";'],
42
+ badge: ['import Badge from "@ui/badge/Badge.astro";'],
43
+ kbd: ['import Kbd from "@ui/kbd/Kbd.astro";'],
44
+ files: ['import FileTree from "@ui/files/FileTree.astro";'],
58
45
  "math-block": ['import MathKatex from "@ui/math/MathKatex.astro";'],
59
46
  image: ['import BlockImage from "@ui/image/Image.astro";'],
60
47
  "api-class": ['import ApiSymbol from "@ui/api-symbol/ApiSymbol.astro";'],
@@ -96,6 +83,9 @@ const COMPONENT_IMPORTS = {
96
83
  "link-card": [
97
84
  'import LinkCard from "@ui/link-card/LinkCard.astro";',
98
85
  ],
86
+ "link-button": [
87
+ 'import Button from "@ui/button/Button.astro";',
88
+ ],
99
89
  avatar: [
100
90
  'import Avatar from "@ui/avatar/Avatar.astro";',
101
91
  ],
@@ -109,12 +99,50 @@ export function escapeExpr(s) {
109
99
  // JSON.stringify handles all special characters (quotes, backslashes, newlines, etc.)
110
100
  // and produces a valid JS string literal. This avoids template literal edge cases
111
101
  // where backtick + brace combinations create false expression boundaries.
112
- return JSON.stringify(s);
102
+ //
103
+ // `<` becomes \u003c: Astro parses the template as HTML before the expression is
104
+ // JavaScript, so a `<script>` inside a string (a code block showing one, say) would
105
+ // be taken for a real script tag and its text compiled as JavaScript. Vite's
106
+ // dependency scan then fails to parse it and a production build breaks.
107
+ return JSON.stringify(s).replace(/</g, "\\u003c");
113
108
  }
114
109
  /** Escape for use in an HTML attribute value (double-quoted). */
115
110
  export function escapeAttr(s) {
116
111
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
117
112
  }
113
+ export function componentAttr(name, value) {
114
+ return /[&"<>]/.test(value)
115
+ ? ` ${name}={${escapeExpr(value)}}`
116
+ : ` ${name}="${value}"`;
117
+ }
118
+ /**
119
+ * Render extra attributes from an inline element's `attrs` field
120
+ * (links and images today; spans in a future phase) as ` key="val"`
121
+ * pairs prefixed with a leading space, suitable for splicing into an
122
+ * open tag immediately after the element's first-class attrs (href,
123
+ * src, etc.). `id`, `class`, and `style` are first-class; any other
124
+ * key passes through verbatim.
125
+ */
126
+ /** `{…}` attributes on a COMPONENT (Badge, Button): `componentAttr`, which Astro does not entity-decode. */
127
+ function renderInlineComponentAttrs(attrs) {
128
+ if (!attrs)
129
+ return "";
130
+ return Object.entries(attrs)
131
+ .filter(([, value]) => value !== undefined)
132
+ .map(([key, value]) => componentAttr(key, String(value)))
133
+ .join("");
134
+ }
135
+ function renderInlineElementAttrs(attrs) {
136
+ if (!attrs)
137
+ return "";
138
+ const parts = [];
139
+ for (const [key, value] of Object.entries(attrs)) {
140
+ if (value === undefined)
141
+ continue;
142
+ parts.push(` ${key}="${escapeAttr(String(value))}"`);
143
+ }
144
+ return parts.join("");
145
+ }
118
146
  /** Escape text for use directly in an Astro template (not inside an expression). */
119
147
  export function escapeTemplate(s) {
120
148
  let result = "";
@@ -149,6 +177,13 @@ export function treeToAstro(nodes, options) {
149
177
  mode: options?.mode ?? "template",
150
178
  imageOptimization: options?.imageOptimization ?? false,
151
179
  codeBlockTitle: options?.codeBlockTitle ?? true,
180
+ imageCaptions: options?.imageCaptions ?? false,
181
+ combinedPrefix: options?.combinedPrefix ?? "",
182
+ // Mixed prose+endpoint pages render endpoints as embedded blocks;
183
+ // pages that are nothing but endpoints/headings keep the
184
+ // full-viewport operation layout (the OpenAPI page shape).
185
+ embeddedEndpoints: apiPageLayout(nodes).embeddedEndpoints,
186
+ apiSymbolLevels: apiSymbolLevels(nodes),
152
187
  };
153
188
  // Pre-scan for inline `icon` nodes anywhere in the tree.
154
189
  // `inlineNodeToTemplate` is intentionally pure (no ctx) — it
@@ -157,9 +192,9 @@ export function treeToAstro(nodes, options) {
157
192
  // (card.props.icon) registers `icon` directly at the call
158
193
  // site; this pre-scan picks up the inline `:icon[name]`
159
194
  // directives the writer can drop into prose.
160
- if (treeContainsInlineIcon(nodes)) {
161
- ctx.imports.add("icon");
162
- }
195
+ // Badges and inline buttons are components too, registered the same way.
196
+ for (const key of inlineComponentsIn(nodes))
197
+ ctx.imports.add(key);
163
198
  const body = nodes.map((n) => nodeToAstro(n, ctx)).filter(Boolean).join("\n");
164
199
  const imports = [];
165
200
  const seen = new Set();
@@ -181,7 +216,18 @@ export function treeToAstro(nodes, options) {
181
216
  };
182
217
  }
183
218
  // ─── Node dispatcher ──────────────────────────────────────────────
219
+ /**
220
+ * Dispatch + diff decoration: nodes carrying DiffTree marks (from
221
+ * @dogsbay/tree-diff, read structurally) decorate the rendered
222
+ * snippet; unmarked nodes render byte-identically to before.
223
+ */
184
224
  function nodeToAstro(node, ctx) {
225
+ const rendered = nodeToAstroBase(node, ctx);
226
+ if (!rendered || !hasDiffMarks(node))
227
+ return rendered;
228
+ return decorateDiff(node, rendered, (peer) => nodeToAstroBase(peer, ctx));
229
+ }
230
+ function nodeToAstroBase(node, ctx) {
185
231
  switch (node.type) {
186
232
  case "prose":
187
233
  return proseToAstro(node, ctx);
@@ -193,6 +239,10 @@ function nodeToAstro(node, ctx) {
193
239
  return codeToAstro(node, ctx);
194
240
  case "callout":
195
241
  return calloutToAstro(node, ctx);
242
+ case "files":
243
+ return filesToAstro(node, ctx);
244
+ case "container":
245
+ return genericContainerToAstro(node, ctx);
196
246
  case "details":
197
247
  return detailsToAstro(node, ctx);
198
248
  case "tabs":
@@ -212,14 +262,23 @@ function nodeToAstro(node, ctx) {
212
262
  return wrapBlock("li", "", node, leafContent(node, ctx));
213
263
  case "blockquote":
214
264
  return wrapBlock("blockquote", "border-l-4 border-border pl-4 italic text-muted-foreground", node, childrenToAstro(node, ctx));
265
+ // A transparent grouping wrapper: renders its children inside a plain
266
+ // <div> and adds no semantics of its own. Exists so a caller can carry
267
+ // an attribute (a class, a data-* flag) for a whole run of blocks —
268
+ // the release comparison marks a wholly added/removed PAGE once, at
269
+ // the page level, instead of putting a bar on every block inside it.
270
+ case "group":
271
+ return wrapBlock("div", "", node, childrenToAstro(node, ctx));
215
272
  case "hr": {
216
273
  const classAttr = mergeClassAttr("my-8 border-border", node.props?.class);
217
274
  const passthrough = renderPassthroughAttrs(node.props, ["class"]);
218
275
  return `<hr${classAttr}${passthrough} />`;
219
276
  }
220
277
  case "html":
221
- // Raw HTML always uses set:html — it may contain anything
222
- return `<Fragment set:html={${escapeExpr(node.html ?? "")}} />`;
278
+ // Raw HTML always uses set:html — it may contain anything. Callouts inside it
279
+ // (dogsbay-md's HTML table fallback writes `<div role="note" data-callout>`)
280
+ // render as real callouts
281
+ return htmlToAstro(node.html ?? "", ctx);
223
282
  case "table":
224
283
  return tableToAstro(node, ctx);
225
284
  case "thead":
@@ -270,9 +329,20 @@ function nodeToAstro(node, ctx) {
270
329
  return exampleToAstro(node, ctx);
271
330
  case "figure": {
272
331
  const caption = node.props?.caption;
332
+ // A figure of one code block: the caption goes in the code block's header bar,
333
+ // which then is the figure's caption
334
+ const only = node.children?.length === 1 ? node.children[0] : undefined;
335
+ if (caption && only?.type === "code") {
336
+ return codeToAstro(only, ctx, {
337
+ caption: captionToAstro(caption, ctx),
338
+ // The figure's attributes, except those the code block has itself (an id twice is invalid)
339
+ attrs: renderPassthroughAttrs(node.props, ["caption", "class", ...Object.keys(only.props ?? {})]),
340
+ classes: ["my-4", node.props?.class].filter(Boolean).join(" "),
341
+ });
342
+ }
273
343
  const inner = childrenToAstro(node, ctx);
274
344
  const body = caption
275
- ? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${escapeTemplate(caption)}</figcaption>`
345
+ ? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${captionToAstro(caption, ctx)}</figcaption>`
276
346
  : inner;
277
347
  return wrapBlock("figure", "my-4", node, body, ["caption"]);
278
348
  }
@@ -297,6 +367,8 @@ function nodeToAstro(node, ctx) {
297
367
  return standaloneCardToAstro(node, ctx);
298
368
  case "link-card":
299
369
  return linkCardToAstro(node, ctx);
370
+ case "link-button":
371
+ return linkButtonToAstro(node, ctx);
300
372
  case "accordion":
301
373
  return accordionToAstro(node, ctx);
302
374
  case "accordion-item":
@@ -351,6 +423,28 @@ ${childrenToAstro(node, ctx)}
351
423
  ${childrenToAstro(node, ctx)}
352
424
  </GridItem>`;
353
425
  }
426
+ case "related": {
427
+ // #384 R3: AsciiDoc [role="_additional-resources"] + .Title + list folds
428
+ // to this semantic node (maps to DITA <related-links>). Render a titled
429
+ // section, not a TOC heading. The title is a caption (block title), so a
430
+ // styled <p>, not an <h2>.
431
+ const title = node.props?.title;
432
+ const classAttr = mergeClassAttr("related-resources", node.props?.class);
433
+ const passthrough = renderPassthroughAttrs(node.props, ["title", "class"]);
434
+ const titleHtml = title
435
+ ? `<p class="related-resources-title">${escapeTemplate(title)}</p>\n`
436
+ : "";
437
+ return `<section${classAttr}${passthrough}>\n${titleHtml}${childrenToAstro(node, ctx)}\n</section>`;
438
+ }
439
+ case "mdx-raw": {
440
+ // Preserved-but-unmapped MDX component (format-mdx never-drop; see
441
+ // docs-dev/format-mdx-adapters.md). Rendered as a visible source
442
+ // block — the content must not silently disappear from the HTML
443
+ // output. Map the component (adapter / mdx.components config) to
444
+ // upgrade it to a real widget.
445
+ const source = String(node.props?.source ?? "");
446
+ return `<pre class="mdx-raw my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code>{${escapeExpr(source)}}</code></pre>`;
447
+ }
354
448
  default:
355
449
  if (node.html)
356
450
  return `<Fragment set:html={${escapeExpr(node.html)}} />`;
@@ -360,37 +454,74 @@ ${childrenToAstro(node, ctx)}
360
454
  }
361
455
  }
362
456
  // ─── 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": {
457
+ /**
458
+ * Astro's two inline dialects, as emitters over serialize-core's shared walk.
459
+ *
460
+ * These were the THIRD and FOURTH copies of the 11-variant switch (obsidian and
461
+ * dogsbay-md's html fallback were the other two), and the duplication is
462
+ * precisely why `highlight` was missing from BOTH of them: adding an arm meant
463
+ * remembering four places, so `:highlight[…]` rendered as nothing at all on
464
+ * every Astro page. The walk is now shared, so a variant is handled in one
465
+ * place per format instead of being silently skippable.
466
+ *
467
+ * Only the leaf syntax differs between the two maps: template mode escapes for
468
+ * Astro's `{}` and emits real components (`<Icon>`, `<Fragment set:html>`),
469
+ * hybrid mode escapes for plain HTML and emits inert markup.
470
+ */
471
+ /** Canonical highlight styling — keep in sync with InlineRenderer.astro. */
472
+ const HIGHLIGHT_CLASS = "rounded-sm bg-yellow-200/60 px-0.5 dark:bg-yellow-500/30";
473
+ function templateInlineEmitters() {
474
+ return {
475
+ text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
476
+ bold: (s) => `<strong>${s}</strong>`,
477
+ italic: (s) => `<em>${s}</em>`,
478
+ strike: (s) => `<s>${s}</s>`,
479
+ }),
480
+ link: (node, ctx) => {
481
+ // A link node an importer built directly never passed markdown-it's
482
+ // scheme check: a `javascript:` href keeps its text and loses the link.
483
+ if (!isSafeHref(node.href)) {
484
+ ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
485
+ return ctx.recurse(node.children);
486
+ }
487
+ const titleAttr = node.title ? componentAttr("title", node.title) : "";
488
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
489
+ // An inline `:button`: the Button component, small, keeping its variant ("primary" is the filled default).
490
+ if (node.button) {
491
+ const variant = buttonVariant(node.button.variant);
492
+ return `<Button${componentAttr("href", node.href)}${componentAttr("variant", variant)} size="sm"${titleAttr}${renderInlineComponentAttrs(node.attrs)}>${ctx.recurse(node.children)}</Button>`;
493
+ }
494
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
495
+ },
496
+ badge: (node, ctx) => `<Badge${componentAttr("variant", node.variant ?? "default")}${renderInlineComponentAttrs(node.attrs)}>${ctx.escape(node.text)}</Badge>`,
497
+ image: (node, ctx) => {
498
+ if (!isSafeHref(node.src, "image")) {
499
+ ctx.report("image", `unsafe image source dropped (${node.src.slice(0, 40)})`);
500
+ return ctx.escape(node.alt ?? "");
501
+ }
502
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
503
+ return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy"${extraAttrs} />`;
504
+ },
505
+ // Code inherits the emphasis around it (`*using the \`dita\` command*`): the parser
506
+ // flags the code span, and dropping the flags split the italics around the code
507
+ code: (node, ctx) => applyTextFlags(`<code>${ctx.escape(node.text)}</code>`, node, {
508
+ bold: (s) => `<strong>${s}</strong>`,
509
+ italic: (s) => `<em>${s}</em>`,
510
+ strike: (s) => `<s>${s}</s>`,
511
+ }),
512
+ // The arm that did not exist — `:highlight[…]` / `:mark[…]` rendered as
513
+ // nothing at all. The class list mirrors the canonical rendering in
514
+ // `packages/ui/src/content-renderer/InlineRenderer.astro`, so a highlight
515
+ // looks the same however a page is produced. A BARE `<mark>` would have
516
+ // been "fixed" but unstyled: the generated `.docs-prose` stylesheet had no
517
+ // `mark` rule, so browsers paint their default yellow — including on dark
518
+ // pages, where nothing declares `color-scheme`.
519
+ highlight: (node, ctx) => `<mark class="${HIGHLIGHT_CLASS}">${ctx.recurse(node.children)}</mark>`,
520
+ "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>`,
521
+ // The Kbd component (keys styled as keycaps); plain <kbd> had no style beyond a monospace font.
522
+ kbd: (node, ctx) => node.keys.map((k) => `<Kbd>${ctx.escape(k)}</Kbd>`).join("+"),
523
+ math: (node, ctx) => `<code class="math-inline">${ctx.escape(node.latex)}</code>`,
524
+ icon: (node) => {
394
525
  // Inline icon — emits the platform `<Icon>` component which
395
526
  // resolves at build time against Lucide (default) plus any
396
527
  // other Iconify pack via the `pack:name` shorthand. Caller
@@ -404,47 +535,37 @@ function inlineNodeToTemplate(node) {
404
535
  // to Lucide for everything.
405
536
  const fullName = node.library ? `${node.library}:${node.name}` : node.name;
406
537
  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("");
538
+ },
539
+ // Raw HTML inline — must use set:html to avoid brace issues
540
+ "html-inline": (node) => `<Fragment set:html={${escapeExpr(node.html)}} />`,
541
+ break: () => "<br />",
542
+ };
420
543
  }
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": {
544
+ function htmlModeInlineEmitters() {
545
+ return {
546
+ ...templateInlineEmitters(),
547
+ // Inherited from the template map, this emitted `title={"…"}` — an
548
+ // EXPRESSION, inside a string that becomes a `set:html` value. Astro never
549
+ // parses it as markup, so the reader saw the braces and quotes.
550
+ link: (node, ctx) => {
551
+ if (!isSafeHref(node.href)) {
552
+ ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
553
+ return ctx.recurse(node.children);
554
+ }
434
555
  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": {
556
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
557
+ // No components in HTML mode: a marked link (and a marked span for a badge below).
558
+ const button = node.button ? ` data-button data-variant="${escapeAttr(buttonVariant(node.button.variant))}"` : "";
559
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}${button}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
560
+ },
561
+ kbd: (node, ctx) => node.keys.map((k) => `<kbd>${ctx.escape(k)}</kbd>`).join("+"),
562
+ badge: (node, ctx) => `<span data-badge data-variant="${escapeAttr(node.variant ?? "default")}"${renderInlineElementAttrs(node.attrs)}>${ctx.escape(node.text)}</span>`,
563
+ text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
564
+ bold: (s) => `<strong>${s}</strong>`,
565
+ italic: (s) => `<em>${s}</em>`,
566
+ strike: (s) => `<s>${s}</s>`,
567
+ }),
568
+ icon: (node) => {
448
569
  // Hybrid-mode HTML rendering — fall back to a textual
449
570
  // shortcode reference when we can't inline the SVG. Real
450
571
  // resolution happens in template mode via the <Icon>
@@ -452,14 +573,19 @@ function inlineNodeToHtml(node) {
452
573
  // production output.
453
574
  const fullName = node.library ? `${node.library}:${node.name}` : node.name;
454
575
  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
- }
576
+ },
577
+ "html-inline": (node) => node.html,
578
+ };
579
+ }
580
+ const TEMPLATE_INLINE = templateInlineEmitters();
581
+ const HTML_INLINE = htmlModeInlineEmitters();
582
+ /** Render inline nodes as Astro template markup (clean, editable). */
583
+ function inlineToTemplate(nodes) {
584
+ return walkInline(nodes, { emitters: TEMPLATE_INLINE, escape: escapeTemplate });
585
+ }
586
+ /** Render inline nodes as HTML string (for hybrid mode set:html). */
587
+ function inlineToHtml(nodes) {
588
+ return walkInline(nodes, { emitters: HTML_INLINE, escape: escapeHtml });
463
589
  }
464
590
  /** Render inline content in the current mode. */
465
591
  function renderInline(nodes, ctx) {
@@ -467,6 +593,8 @@ function renderInline(nodes, ctx) {
467
593
  const html = inlineToHtml(nodes);
468
594
  return `<Fragment set:html={${escapeExpr(html)}} />`;
469
595
  }
596
+ // Inline parsed from a prop (a caption) is not in the page-level pre-scan; its components are imported here.
597
+ inlineComponentsIn([{ type: "paragraph", inline: nodes }], ctx.imports);
470
598
  return inlineToTemplate(nodes);
471
599
  }
472
600
  /** Render an HTML string in the current mode. */
@@ -487,18 +615,24 @@ function headingToAstro(node, ctx) {
487
615
  // `id` takes precedence over `slug` (slug is auto-generated, id is explicit user override)
488
616
  const id = node.props?.id ?? node.props?.slug ?? "";
489
617
  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>`
618
+ ? `<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
619
  : "";
492
620
  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
- ]);
621
+ const consumed = ["level", "slug", "id", "class", "text"];
622
+ const userClass = node.props?.class;
497
623
  if (ctx.mode === "template" && node.inline) {
624
+ // Real Astro markup: expressions are parsed.
625
+ const classAttr = mergeClassAttr("group scroll-mt-20", userClass);
626
+ const passthrough = renderPassthroughAttrs(node.props, consumed);
498
627
  const text = inlineToTemplate(node.inline);
499
628
  return `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
500
629
  }
501
- // Hybrid or no inline — use set:html for the whole heading
630
+ // Hybrid or no inline — the whole heading becomes a set:html STRING, where an
631
+ // expression would be literal text. Every MDX/Mintlify heading takes this
632
+ // branch (that importer emits `props.text` + children, never `inline`), so
633
+ // this is the common path, not an edge case.
634
+ const classAttr = mergeClassAttr("group scroll-mt-20", userClass, "html");
635
+ const passthrough = renderPassthroughAttrs(node.props, consumed, "html");
502
636
  const text = node.inline ? inlineToHtml(node.inline) : node.props?.text ?? "";
503
637
  const html = `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
504
638
  return `<Fragment set:html={${escapeExpr(html)}} />`;
@@ -515,15 +649,28 @@ function paragraphToAstro(node, ctx) {
515
649
  if (meaningful.length === 1 && meaningful[0].type === "image") {
516
650
  const img = meaningful[0];
517
651
  ctx.imports.add("image");
652
+ // imageMap is keyed by the unprefixed publicPath built from
653
+ // `import.meta.glob("/src/assets/**", ...)` at runtime, so
654
+ // strip the URL prefix back off if rewriteTreeImageSrcs has
655
+ // already applied it. Keeps the optimized-image lookup working
656
+ // alongside the prefix-on-emit fix.
657
+ const lookupKey = ctx.combinedPrefix && img.src.startsWith(`${ctx.combinedPrefix}/`)
658
+ ? img.src.slice(ctx.combinedPrefix.length)
659
+ : img.src;
518
660
  const imgDataAttr = ctx.imageOptimization
519
- ? ` imageData={imageMap[${escapeExpr(img.src)}]}`
661
+ ? ` imageData={imageMap[${escapeExpr(lookupKey)}]}`
520
662
  : "";
521
663
  // Forward arbitrary `data-*` / `aria-*` attrs that plugins
522
664
  // may have attached to the wrapping paragraph (e.g.
523
665
  // @dogsbay/plugin-image-zoom tags `data-zoomable="true"` so
524
666
  // the runtime can attach handlers without scanning the DOM).
525
667
  const passthrough = renderPassthroughAttrs(node.props, ["class"]);
526
- return `<BlockImage src="${escapeAttr(img.src)}" alt="${escapeAttr(img.alt ?? "")}"${passthrough}${imgDataAttr} />`;
668
+ // Author-supplied {.class #id ...} on the image itself
669
+ // (Phase 1.5 of plans/inline-attrs.md).
670
+ const imageAttrs = renderInlineElementAttrs(img.attrs);
671
+ // `content.imageCaptions: title`: the figure form of BlockImage, captioned by the title (as dogsbay-ssg).
672
+ const figure = ctx.imageCaptions && img.title ? ` block${componentAttr("title", img.title)}` : "";
673
+ return `<BlockImage${componentAttr("src", img.src)}${componentAttr("alt", img.alt ?? "")}${figure}${imageAttrs}${passthrough}${imgDataAttr} />`;
527
674
  }
528
675
  }
529
676
  const classAttr = mergeClassAttr("", node.props?.class);
@@ -550,7 +697,7 @@ function paragraphToAstro(node, ctx) {
550
697
  }).join("");
551
698
  return `<p${classAttr}${passthrough} set:html={${escapeExpr(html)}} />`;
552
699
  }
553
- function codeToAstro(node, ctx) {
700
+ function codeToAstro(node, ctx, figure) {
554
701
  // Code content may live on props.code (Starlight convention) or node.html
555
702
  // (dogsbay-md convention). Same for lang — Starlight uses props.lang,
556
703
  // dogsbay-md uses props.language.
@@ -561,34 +708,48 @@ function codeToAstro(node, ctx) {
561
708
  ?? node.html
562
709
  ?? "";
563
710
  const title = node.props?.title;
564
- const highlights = node.props?.highlights;
711
+ // The fence's documented `mark`, `ins` and `del` are CodeRich's highlights, diffAdd and diffRemove.
712
+ const lineSpec = (...values) => values.filter((v) => typeof v === "string" || typeof v === "number").map(String).filter(Boolean).join(",") || undefined;
713
+ const highlights = lineSpec(node.props?.highlights, node.props?.mark);
565
714
  const lineNumbers = node.props?.lineNumbers;
566
- const isRich = lineNumbers || highlights || code.includes("[!code");
567
- const classAttr = mergeClassAttr("", node.props?.class);
715
+ const copyText = node.props?.copyText;
716
+ const diffAdd = lineSpec(node.props?.diffAdd, node.props?.ins);
717
+ const diffRemove = lineSpec(node.props?.diffRemove, node.props?.del);
718
+ const isRich = lineNumbers || highlights || code.includes("[!code") || Boolean(diffAdd || diffRemove);
719
+ const classAttr = mergeClassAttr(figure?.classes ?? "", node.props?.class);
720
+ const figureAttrs = figure?.attrs ?? "";
721
+ // The component with its caption slot filled, or self-closing
722
+ const close = (tag, open) => figure ? `${open}>\n<Fragment slot="caption">${figure.caption}</Fragment>\n</${tag}>` : `${open} />`;
568
723
  const consumed = [
569
724
  "lang", "language", "code", "title", "highlights", "lineNumbers",
570
- "class", "ins", "del", "mark", "collapse",
725
+ "class", "ins", "del", "mark", "collapse", "copyText", "diffAdd", "diffRemove",
571
726
  ];
572
727
  const passthrough = renderPassthroughAttrs(node.props, consumed);
573
728
  if (isRich) {
574
729
  ctx.imports.add("code-rich");
575
730
  const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
576
731
  if (title)
577
- attrs.push(`title="${escapeAttr(title)}"`);
732
+ attrs.push(componentAttr("title", title).trimStart());
578
733
  if (lineNumbers)
579
734
  attrs.push("lineNumbers");
580
735
  if (highlights)
581
736
  attrs.push(`highlights="${escapeAttr(highlights)}"`);
582
- return `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough} />`;
737
+ if (copyText !== undefined)
738
+ attrs.push(`copyText={${escapeExpr(copyText)}}`);
739
+ if (diffAdd)
740
+ attrs.push(`diffAdd="${escapeAttr(diffAdd)}"`);
741
+ if (diffRemove)
742
+ attrs.push(`diffRemove="${escapeAttr(diffRemove)}"`);
743
+ return close("CodeRich", `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
583
744
  }
584
745
  ctx.imports.add("code");
585
746
  const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
586
747
  if (title)
587
- attrs.push(`title="${escapeAttr(title)}"`);
748
+ attrs.push(componentAttr("title", title).trimStart());
588
749
  if (ctx.codeBlockTitle !== true) {
589
750
  attrs.push(`showTitle="${ctx.codeBlockTitle}"`);
590
751
  }
591
- return `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough} />`;
752
+ return close("CodeBlock", `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
592
753
  }
593
754
  // Map GitHub alert names (and other aliases) to Alert component variants.
594
755
  // The Alert variant palette follows MkDocs Material; not every GitHub name
@@ -600,6 +761,71 @@ const CALLOUT_VARIANT_MAP = {
600
761
  hint: "tip",
601
762
  attention: "warning",
602
763
  };
764
+ // Default title shown when a callout has no explicit one, keyed by the type
765
+ // the author wrote: aliases share a look but keep their own name (`:::info` is
766
+ // "Info", not the "Important" of the variant it shares). The same labels as
767
+ // dogsbay-ssg (src/render/icons.ts CALLOUTS). Anything else: the type, capitalised.
768
+ const CALLOUT_LABELS = {
769
+ note: "Note",
770
+ info: "Info",
771
+ tip: "Tip",
772
+ hint: "Hint",
773
+ success: "Success",
774
+ important: "Important",
775
+ warning: "Warning",
776
+ attention: "Attention",
777
+ caution: "Caution",
778
+ danger: "Danger",
779
+ error: "Error",
780
+ failure: "Failure",
781
+ question: "Question",
782
+ bug: "Bug",
783
+ example: "Example",
784
+ abstract: "Abstract",
785
+ quote: "Quote",
786
+ };
787
+ const CALLOUT_OPEN = /<div role="note" data-callout="([^"]*)"(?: data-title="([^"]*)")?>/g;
788
+ /**
789
+ * Raw HTML as set:html fragments, with each marked callout div rendered by
790
+ * calloutToAstro. The fragments around a callout may hold unbalanced tags
791
+ * (`<table><tr><td>` before, `</td></tr></table>` after); set:html writes
792
+ * them verbatim, so the page's HTML is whole again.
793
+ */
794
+ function htmlToAstro(html, ctx) {
795
+ const raw = (s) => (s ? `<Fragment set:html={${escapeExpr(s)}} />` : "");
796
+ const out = [];
797
+ let rest = html;
798
+ for (;;) {
799
+ CALLOUT_OPEN.lastIndex = 0;
800
+ const open = CALLOUT_OPEN.exec(rest);
801
+ if (!open)
802
+ break;
803
+ // The callout ends at its matching </div>
804
+ const divs = /<div\b|<\/div>/g;
805
+ divs.lastIndex = open.index + open[0].length;
806
+ let depth = 1;
807
+ let end = -1;
808
+ for (let m = divs.exec(rest); m; m = divs.exec(rest)) {
809
+ depth += m[0] === "</div>" ? -1 : 1;
810
+ if (depth === 0) {
811
+ end = m.index;
812
+ break;
813
+ }
814
+ }
815
+ if (end < 0)
816
+ break;
817
+ const unescape = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
818
+ out.push(raw(rest.slice(0, open.index)));
819
+ out.push(calloutToAstro({
820
+ type: "callout",
821
+ props: { type: unescape(open[1]), ...(open[2] !== undefined ? { title: unescape(open[2]) } : {}) },
822
+ children: [{ type: "html", html: rest.slice(open.index + open[0].length, end) }],
823
+ }, ctx));
824
+ rest = rest.slice(end + "</div>".length);
825
+ }
826
+ out.push(raw(rest));
827
+ return out.filter(Boolean).join("\n") || raw(html);
828
+ }
603
829
  function calloutToAstro(node, ctx) {
604
830
  ctx.imports.add("callout");
605
831
  // Callout variant may live on props.variant (Starlight / MkDocs importers)
@@ -608,9 +834,18 @@ function calloutToAstro(node, ctx) {
608
834
  ?? node.props?.type
609
835
  ?? "note").toLowerCase();
610
836
  const variant = CALLOUT_VARIANT_MAP[rawVariant] ?? rawVariant;
611
- const title = node.props?.title ?? "";
837
+ // When the source gives no explicit title, fall back to the type label
838
+ // (Note / Warning / …) instead of an empty <AlertTitle> — AsciiDoc
839
+ // admonitions and GitHub-style alerts both show the type next to the icon,
840
+ // and an empty title leaves the icon standing alone. Issue #004.
841
+ const explicitTitle = node.props?.title;
842
+ // The default title names the authored type, not the variant it maps to.
843
+ const title = explicitTitle && explicitTitle.length > 0
844
+ ? explicitTitle
845
+ : CALLOUT_LABELS[rawVariant] ?? rawVariant.charAt(0).toUpperCase() + rawVariant.slice(1);
612
846
  const icon = node.props?.icon;
613
- const iconAttr = icon ? ` icon="${escapeAttr(icon)}"` : "";
847
+ // The authored type (`hint`, `caution`) beside the variant it shares, as dogsbay-ssg writes it.
848
+ const iconAttr = (icon ? ` icon="${escapeAttr(icon)}"` : "") + (node.props?.["data-type"] === undefined ? ` data-type="${escapeAttr(rawVariant)}"` : "");
614
849
  // User classes and passthrough attributes (id, data-*, aria-*, style)
615
850
  const classAttr = mergeClassAttr("my-4", node.props?.class);
616
851
  const passthrough = renderPassthroughAttrs(node.props, [
@@ -629,15 +864,19 @@ ${indentStr(inner, 4)}
629
864
  * Concatenates (user classes appended) so utility frameworks like Tailwind
630
865
  * resolve conflicts via component-internal class-variance-authority / tv().
631
866
  */
632
- function mergeClassAttr(defaults, userClass) {
867
+ function mergeClassAttr(defaults, userClass, target = "astro") {
633
868
  const combined = [defaults, userClass].filter(Boolean).join(" ").trim();
634
- return combined ? ` class="${escapeAttr(combined)}"` : "";
869
+ if (!combined)
870
+ return "";
871
+ return target === "html"
872
+ ? ` class="${escapeAttr(combined)}"`
873
+ : componentAttr("class", combined);
635
874
  }
636
875
  /**
637
876
  * Render attributes not consumed as component props — `id`, `data-*`, `aria-*`,
638
877
  * and `style`. These pass through to the rendered element.
639
878
  */
640
- function renderPassthroughAttrs(props, consumed) {
879
+ function renderPassthroughAttrs(props, consumed, target = "astro") {
641
880
  if (!props)
642
881
  return "";
643
882
  const skip = new Set([...consumed, "source", "children"]);
@@ -652,9 +891,12 @@ function renderPassthroughAttrs(props, consumed) {
652
891
  if (value === true) {
653
892
  parts.push(key);
654
893
  }
655
- else {
894
+ else if (target === "html") {
656
895
  parts.push(`${key}="${escapeAttr(String(value))}"`);
657
896
  }
897
+ else {
898
+ parts.push(componentAttr(key, String(value)).trimStart());
899
+ }
658
900
  }
659
901
  return parts.length ? " " + parts.join(" ") : "";
660
902
  }
@@ -717,21 +959,59 @@ function wrapBlock(tag, defaultClass, node, content, consumed = []) {
717
959
  return `<${tag}${classAttr}${passthrough}>${sep}${content}${sep}</${tag}>`;
718
960
  void needsNewline;
719
961
  }
962
+ /**
963
+ * `:::files` around a nested bullet list: FileTree, whose markup (files/markup.ts) dogsbay-ssg
964
+ * shares. An entry is a folder when it has entries or its name ends in "/".
965
+ */
966
+ function filesToAstro(node, ctx) {
967
+ ctx.imports.add("files");
968
+ const isList = (n) => n.type === "unordered-list" || n.type === "ordered-list";
969
+ const items = (list) => (list?.children ?? [])
970
+ .filter((li) => li.type === "list-item")
971
+ .map((li) => {
972
+ const inline = li.inline ?? (li.children ?? []).find((c) => c.type === "paragraph")?.inline ?? [];
973
+ const sub = (li.children ?? []).find(isList);
974
+ const text = inline.map((n) => ("text" in n && typeof n.text === "string" ? n.text : "")).join("");
975
+ return { label: inlineToHtml(inline), folder: !!sub || /\/\s*$/.test(text), children: items(sub) };
976
+ });
977
+ const classAttr = mergeClassAttr("", node.props?.class);
978
+ // Each list is a tree; anything else in the block (an intro, a note) renders as it is.
979
+ // `<` is escaped in the JSON: a label may hold inline HTML, and a literal `</script>` in an
980
+ // expression breaks Vite's dependency scan (see escapeExpr).
981
+ return (node.children ?? [])
982
+ .map((child) => isList(child)
983
+ ? `<FileTree items={${JSON.stringify(items(child)).replace(/</g, "\\u003c")}}${classAttr} />`
984
+ : nodeToAstro(child, ctx))
985
+ .filter(Boolean)
986
+ .join("\n");
987
+ }
988
+ /**
989
+ * A `:::name` block with a name no renderer knows (a generic container): its content in a
990
+ * marked <div>, the title (text after the name) first.
991
+ */
992
+ function genericContainerToAstro(node, ctx) {
993
+ const name = typeof node.meta?.name === "string" ? node.meta.name : "container";
994
+ const title = typeof node.meta?.title === "string" ? `<p class="font-semibold">${escapeTemplate(node.meta.title)}</p>\n` : "";
995
+ const classAttr = mergeClassAttr("", node.props?.class);
996
+ const passthrough = renderPassthroughAttrs(node.props, ["class"]);
997
+ return `<div data-directive="${escapeAttr(name)}"${classAttr}${passthrough}>\n${title}${childrenToAstro(node, ctx)}\n</div>`;
998
+ }
720
999
  function detailsToAstro(node, ctx) {
721
1000
  ctx.imports.add("details");
722
- const variant = node.props?.variant ?? "note";
1001
+ // `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
1002
+ const variant = node.props?.variant ?? node.props?.type ?? "note";
723
1003
  const title = node.props?.title ?? "Details";
724
1004
  const open = node.props?.open;
725
1005
  const icon = node.props?.icon;
726
1006
  const inner = childrenToAstro(node, ctx);
727
- const attrs = [`variant="${escapeAttr(variant)}"`, `title="${escapeAttr(title)}"`];
1007
+ const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", title).trimStart()];
728
1008
  if (open)
729
1009
  attrs.push("open");
730
1010
  if (icon)
731
1011
  attrs.push(`icon="${escapeAttr(icon)}"`);
732
1012
  const classAttr = mergeClassAttr("my-4", node.props?.class);
733
1013
  const passthrough = renderPassthroughAttrs(node.props, [
734
- "variant", "title", "open", "icon", "class", "summary",
1014
+ "variant", "type", "title", "open", "icon", "class", "summary",
735
1015
  ]);
736
1016
  return `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>
737
1017
  ${indentStr(inner, 2)}
@@ -749,29 +1029,98 @@ function tabsToAstro(node, ctx) {
749
1029
  return { value, label, node: tab };
750
1030
  });
751
1031
  const defaultValue = tabItems[0].value;
1032
+ // Diff-trigger indicator: a marked tab panel is invisible until
1033
+ // opened, so its TRIGGER carries a data-diff-tab attribute (styled
1034
+ // as a colored dot by DIFF_CSS) telling the reader WHICH tab to
1035
+ // open. Structural read of @dogsbay/tree-diff marks, same as
1036
+ // diff-decoration; unmarked trees are unaffected.
1037
+ const tabDiffSignal = (tab) => {
1038
+ const own = tab.diff;
1039
+ if (own)
1040
+ return own;
1041
+ const deep = (n) => Boolean(n.diff || n.diffInline) || (n.children ?? []).some(deep);
1042
+ return (tab.children ?? []).some(deep) ? "changed" : undefined;
1043
+ };
1044
+ // The indicator must not live in COLOUR alone (WCAG 1.4.1), and a
1045
+ // background-tinted dot disappears in forced-colors mode. So it is a
1046
+ // GLYPH (shape carries the meaning) plus visually-hidden text, so the
1047
+ // trigger announces "Astro, changed in this comparison" rather than
1048
+ // relying on a `title` screen readers may never speak.
1049
+ const TAB_GLYPH = {
1050
+ added: "+",
1051
+ changed: "•",
1052
+ removed: "−",
1053
+ moved: "→",
1054
+ };
752
1055
  const triggers = tabItems
753
- .map((t) => ` <TabsTrigger value="${escapeAttr(t.value)}">${escapeTemplate(t.label)}</TabsTrigger>`)
1056
+ .map((t) => {
1057
+ const signal = tabDiffSignal(t.node);
1058
+ if (!signal) {
1059
+ return ` <TabsTrigger${componentAttr("value", t.value)}>${escapeTemplate(t.label)}</TabsTrigger>`;
1060
+ }
1061
+ const glyph = TAB_GLYPH[signal] ?? "•";
1062
+ // The glyph and its announced text are ONE element: "Hide
1063
+ // highlights" hides `.db-tab-mark`, and a sibling sr-only span
1064
+ // would survive that — leaving screen-reader users still hearing
1065
+ // "changed in this comparison" on every tab while the visible
1066
+ // marker is gone (code review, 2026-07-13).
1067
+ const marker = `<span class="db-tab-mark" data-diff-tab="${escapeAttr(signal)}">` +
1068
+ `<span aria-hidden="true">${glyph}</span>` +
1069
+ `<span class="sr-only">${escapeTemplate(signal)} in this comparison</span>` +
1070
+ `</span>`;
1071
+ // `value` MUST match TabsContent's, which is a componentAttr expression:
1072
+ // a title containing `&` was emitted `&amp;` here and `&` there, so the
1073
+ // trigger and its panel no longer paired and the tab rendered empty.
1074
+ // `data-diff-tab` is a plain data attribute — entity escaping is right.
1075
+ return ` <TabsTrigger${componentAttr("value", t.value)} data-diff-tab="${escapeAttr(signal)}">${escapeTemplate(t.label)}<Fragment set:html={${escapeExpr(marker)}} /></TabsTrigger>`;
1076
+ })
754
1077
  .join("\n");
755
1078
  const contents = tabItems
756
1079
  .map((t) => {
757
1080
  const inner = childrenToAstro(t.node, ctx);
758
- return ` <TabsContent value="${escapeAttr(t.value)}">\n${indentStr(inner, 4)}\n </TabsContent>`;
1081
+ return ` <TabsContent${componentAttr("value", t.value)}>\n${indentStr(inner, 4)}\n </TabsContent>`;
759
1082
  })
760
1083
  .join("\n");
761
1084
  const classAttr = mergeClassAttr("my-4", node.props?.class);
762
1085
  const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
763
- return `<Tabs defaultValue="${escapeAttr(defaultValue)}"${classAttr}${passthrough}>
1086
+ // Groups with the same key switch together (db-tabs). Written `syncKey`, `sync-key` or `group`, as dogsbay-ssg reads it.
1087
+ const syncKey = [node.props?.syncKey, node.props?.["sync-key"], node.props?.group].find((v) => typeof v === "string" && v !== "");
1088
+ const syncAttr = syncKey ? componentAttr("syncKey", syncKey) : "";
1089
+ // Same encoding as the trigger/content `value` it selects — see above.
1090
+ return `<Tabs${componentAttr("defaultValue", defaultValue)}${syncAttr}${classAttr}${passthrough}>
764
1091
  <TabsList>
765
1092
  ${triggers}
766
1093
  </TabsList>
767
1094
  ${contents}
768
1095
  </Tabs>`;
769
1096
  }
1097
+ /**
1098
+ * A caption is inline Markdown. Plain text renders exactly as before;
1099
+ * formatting (`Sample \`<plugin>\` file`) renders as inline markup.
1100
+ */
1101
+ function captionInline(caption) {
1102
+ return parseInline(caption);
1103
+ }
1104
+ const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
1105
+ function captionToAstro(caption, ctx) {
1106
+ const nodes = captionInline(caption);
1107
+ return isPlainCaption(nodes)
1108
+ ? escapeTemplate(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))
1109
+ : renderInline(nodes, ctx);
1110
+ }
770
1111
  function tableToAstro(node, ctx) {
771
1112
  ctx.imports.add("table");
772
1113
  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>`;
1114
+ const caption = node.props?.caption;
1115
+ const nodes = caption ? captionInline(caption) : [];
1116
+ const plain = isPlainCaption(nodes);
1117
+ // A plain caption stays the `caption` prop; a formatted one fills the table's caption slot
1118
+ const captionAttr = caption && plain
1119
+ ? ` caption={${escapeExpr(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))}}`
1120
+ : "";
1121
+ const captionSlot = caption && !plain ? `<Fragment slot="caption">${renderInline(nodes, ctx)}</Fragment>\n` : "";
1122
+ const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
1123
+ return `<Table${classAttr}${captionAttr}${passthrough}>\n${captionSlot}${childrenToAstro(node, ctx)}\n</Table>`;
775
1124
  }
776
1125
  function diagramToAstro(node, ctx) {
777
1126
  ctx.imports.add("diagram");
@@ -781,7 +1130,7 @@ function diagramToAstro(node, ctx) {
781
1130
  const title = node.props?.title;
782
1131
  const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
783
1132
  if (title)
784
- attrs.push(`title="${escapeAttr(title)}"`);
1133
+ attrs.push(componentAttr("title", title).trimStart());
785
1134
  const classAttr = mergeClassAttr("", node.props?.class);
786
1135
  const passthrough = renderPassthroughAttrs(node.props, [
787
1136
  "lang", "code", "svg", "title", "class",
@@ -794,7 +1143,7 @@ function youtubeToAstro(node, ctx) {
794
1143
  const title = node.props?.title ?? "";
795
1144
  const classAttr = mergeClassAttr("my-4", node.props?.class);
796
1145
  const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
797
- return `<YouTube id="${escapeAttr(id)}" title="${escapeAttr(title)}"${classAttr}${passthrough} />`;
1146
+ return `<YouTube${componentAttr("id", id)}${componentAttr("title", title)}${classAttr}${passthrough} />`;
798
1147
  }
799
1148
  function mathBlockToAstro(node, ctx) {
800
1149
  ctx.imports.add("math-block");
@@ -837,6 +1186,11 @@ function footnoteListToAstro(node, ctx) {
837
1186
  function footnoteDefToAstro(node, ctx) {
838
1187
  const id = node.props?.id ?? "";
839
1188
  const backrefId = node.props?.backrefId ?? "";
1189
+ // Block content (a list, code): the blocks, with the backref after them
1190
+ if (node.children?.length) {
1191
+ const backref = backrefId ? `\n<a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
1192
+ return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">\n${childrenToAstro(node, ctx)}${backref}\n</li>`;
1193
+ }
840
1194
  if (ctx.mode === "template" && node.inline) {
841
1195
  const content = inlineToTemplate(node.inline);
842
1196
  const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
@@ -852,8 +1206,14 @@ function apiSymbolToAstro(node, ctx) {
852
1206
  const kind = node.props?.kind ?? "func";
853
1207
  const signature = node.props?.signature;
854
1208
  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}}`];
1209
+ // By position, not by type (apiSymbolLevels): a module-level function was
1210
+ // an h3 straight under the page's h1, and a class inside a class an h2.
1211
+ const level = ctx.apiSymbolLevels.get(node) ?? (node.type === "api-class" ? 2 : 3);
1212
+ const attrs = [
1213
+ componentAttr("name", name).trimStart(),
1214
+ componentAttr("kind", kind).trimStart(),
1215
+ `level={${level}}`,
1216
+ ];
857
1217
  if (signature)
858
1218
  attrs.push(`signature={${escapeExpr(signature)}}`);
859
1219
  if (bases.length > 0)
@@ -902,11 +1262,17 @@ function endpointToAstro(node, ctx) {
902
1262
  const path = props.path ?? "";
903
1263
  const baseUrl = props.baseUrl ?? "";
904
1264
  const summary = props.summary;
905
- const description = props.description;
1265
+ // Descriptions are CommonMark, rendered with raw HTML off: the components
1266
+ // put them in the page with set:html (api-markdown.ts).
1267
+ // (Compared as text first: the card hides a description equal to the
1268
+ // summary, and the rendered HTML never equals it.)
1269
+ const description = typeof props.description === "string" && props.description !== props.summary
1270
+ ? apiBlockHtml(props.description)
1271
+ : undefined;
906
1272
  const deprecated = props.deprecated === true;
907
- const parameters = props.parameters ?? [];
908
- const requestBody = props.requestBody ?? [];
909
- const responses = props.responses ?? [];
1273
+ const parameters = apiPropertiesHtml(props.parameters) ?? [];
1274
+ const requestBody = apiPropertiesHtml(props.requestBody) ?? [];
1275
+ const responses = (props.responses ?? []).map((r) => r && Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r);
910
1276
  const codeSamples = props.codeSamples ?? [];
911
1277
  const requestBodyExample = props.requestBodyExample;
912
1278
  const cardAttrs = [
@@ -946,7 +1312,9 @@ function endpointToAstro(node, ctx) {
946
1312
  const sectionsBody = sections.length > 0
947
1313
  ? `\n${indentStr(sections.join("\n"), 4)}\n `
948
1314
  : "";
949
- // Code column — wrapped in <ApiCodePanel> for consistent vertical
1315
+ // Code column — its titles are labels (<p>), not headings: an operation
1316
+ // page has only its h1 above them, so an h3 skipped a level.
1317
+ // Wrapped in <ApiCodePanel> for consistent vertical
950
1318
  // rhythm. Code samples first, then a request body example, then one
951
1319
  // ExampleBlock per response status that carries a concrete example.
952
1320
  // Each ExampleBlock auto-detects language from the response's
@@ -955,7 +1323,7 @@ function endpointToAstro(node, ctx) {
955
1323
  const codePanels = [];
956
1324
  if (codeSamples.length > 0) {
957
1325
  codePanels.push(`<div>\n` +
958
- ` <h3 class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</h3>\n` +
1326
+ ` <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</p>\n` +
959
1327
  ` <CodeSamples samples={${JSON.stringify(codeSamples)}} />\n` +
960
1328
  `</div>`);
961
1329
  }
@@ -975,7 +1343,8 @@ function endpointToAstro(node, ctx) {
975
1343
  const codeBody = codePanels.length > 0
976
1344
  ? `\n${indentStr(codePanels.join("\n"), 4)}\n `
977
1345
  : "";
978
- return (`<ApiLayout>\n` +
1346
+ const layoutAttrs = ctx.embeddedEndpoints ? ' variant="embedded"' : "";
1347
+ return (`<ApiLayout${layoutAttrs}>\n` +
979
1348
  ` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
980
1349
  ` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
981
1350
  `</ApiLayout>`);
@@ -993,13 +1362,16 @@ function cardsToAstro(node, ctx) {
993
1362
  if (cardNodes.length > 0) {
994
1363
  const cards = cardNodes.map((card) => {
995
1364
  const title = card.props?.title ?? "";
996
- const href = card.props?.href ?? "";
1365
+ const href = safeHref(card.props?.href);
997
1366
  const description = card.props?.description;
998
1367
  const icon = card.props?.icon;
999
1368
  // Description sources: inline content, children, or props.description.
1000
- const inner = childrenToAstro(card, ctx)
1369
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1370
+ const blocks = childrenToAstro(card, ctx);
1371
+ const inner = blocks
1001
1372
  || (card.inline ? inlineToTemplate(card.inline) : "")
1002
1373
  || (description ? escapeTemplate(description) : "");
1374
+ const descTag = blocks ? "div" : "p";
1003
1375
  // Icon renders inline before the title — picks up Lucide
1004
1376
  // SVG via the `icon` import. Resolved at build time; nothing
1005
1377
  // emitted when the name doesn't resolve (silent fallback).
@@ -1007,13 +1379,16 @@ function cardsToAstro(node, ctx) {
1007
1379
  // top of the page.
1008
1380
  let iconHtml = "";
1009
1381
  if (icon) {
1010
- ctx.imports.add("icon");
1011
- iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1382
+ iconHtml = cardIconHtml(icon, title, ctx);
1012
1383
  }
1013
1384
  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>`
1385
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1386
+ const lead = blocks && description
1387
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1016
1388
  : "";
1389
+ const desc = lead + (inner
1390
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1391
+ : "");
1017
1392
  const cardDefaultClass = href
1018
1393
  ? "p-4 hover:border-foreground/20 transition-colors"
1019
1394
  : "p-4";
@@ -1051,12 +1426,15 @@ function cardsToAstro(node, ctx) {
1051
1426
  function standaloneCardToAstro(node, ctx) {
1052
1427
  ctx.imports.add("card");
1053
1428
  const title = node.props?.title ?? "";
1054
- const href = node.props?.href ?? "";
1429
+ const href = safeHref(node.props?.href);
1055
1430
  const description = node.props?.description;
1056
1431
  const icon = node.props?.icon;
1057
- const inner = childrenToAstro(node, ctx)
1432
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1433
+ const blocks = childrenToAstro(node, ctx);
1434
+ const inner = blocks
1058
1435
  || (node.inline ? inlineToTemplate(node.inline) : "")
1059
1436
  || (description ? escapeTemplate(description) : "");
1437
+ const descTag = blocks ? "div" : "p";
1060
1438
  // Icon renders inline before the title (same shape as the
1061
1439
  // cards-list path). Resolved at build time via @dogsbay/icons.
1062
1440
  let iconHtml = "";
@@ -1067,9 +1445,13 @@ function standaloneCardToAstro(node, ctx) {
1067
1445
  const titleHtml = title
1068
1446
  ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>`
1069
1447
  : "";
1070
- const desc = inner
1071
- ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
1448
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1449
+ const lead = blocks && description
1450
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1072
1451
  : "";
1452
+ const desc = lead + (inner
1453
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1454
+ : "");
1073
1455
  const cardDefaultClass = href
1074
1456
  ? "p-4 hover:border-foreground/20 transition-colors"
1075
1457
  : "p-4";
@@ -1089,6 +1471,59 @@ function standaloneCardToAstro(node, ctx) {
1089
1471
  * If the parser kept rich body children instead of folding into description,
1090
1472
  * we fall back to the standalone-card path which can hold arbitrary content.
1091
1473
  */
1474
+ /**
1475
+ * `link-button` — a call-to-action link styled as a button.
1476
+ *
1477
+ * There was no case for this at all, so it fell through to rendering the
1478
+ * node's CHILDREN: `<p>Get started</p>`. The label survived, the href did not,
1479
+ * and nothing warned — the reader saw a stray sentence where the page's
1480
+ * primary call to action should be. Every importer that produces one was
1481
+ * affected; `Button.astro` has taken an `href` all along.
1482
+ */
1483
+ /**
1484
+ * A card icon is either a registry NAME or a path to an image file.
1485
+ *
1486
+ * `<Icon name>` resolves names and renders NOTHING when the name does not
1487
+ * resolve — so a path handed to it disappeared silently, which is how every
1488
+ * "Related products" card lost its logo without anyone noticing.
1489
+ */
1490
+ function cardIconHtml(icon, title, ctx) {
1491
+ if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon)) {
1492
+ return `<img src="${escapeAttr(icon)}" alt="" class="size-5 mb-2" loading="lazy" />`;
1493
+ }
1494
+ ctx.imports.add("icon");
1495
+ return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1496
+ }
1497
+ /**
1498
+ * A button's variant as `Button.astro` names it. The spec's `primary` is its
1499
+ * filled `default` style; `Button.astro` has no `primary`, so it rendered
1500
+ * with no fill at all.
1501
+ */
1502
+ function buttonVariant(variant) {
1503
+ return !variant || variant === "primary" ? "default" : variant;
1504
+ }
1505
+ function linkButtonToAstro(node, ctx) {
1506
+ ctx.imports.add("link-button");
1507
+ const href = safeHref(node.props?.href);
1508
+ const written = node.props?.variant;
1509
+ const variant = written === undefined ? undefined : buttonVariant(written);
1510
+ const classAttr = mergeClassAttr("", node.props?.class);
1511
+ const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
1512
+ // A button's label is phrasing content, so a lone paragraph child is
1513
+ // unwrapped — `<Button><p>Get started</p></Button>` is invalid markup and
1514
+ // renders with the paragraph's block spacing inside the button.
1515
+ const kids = node.children ?? [];
1516
+ const soleParagraph = kids.length === 1 && kids[0].type === "paragraph" ? kids[0] : undefined;
1517
+ const label = soleParagraph
1518
+ ? leafContent(soleParagraph, ctx)
1519
+ : leafContent(node, ctx) || childrenToAstro(node, ctx);
1520
+ return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
1521
+ }
1522
+ /** A card or button `href` from an importer, or "" (no link) when its
1523
+ * scheme could run script: these never passed markdown-it's link check. */
1524
+ function safeHref(href) {
1525
+ return typeof href === "string" && isSafeHref(href) ? href : "";
1526
+ }
1092
1527
  function linkCardToAstro(node, ctx) {
1093
1528
  const hasRichChildren = (node.children ?? []).length > 0;
1094
1529
  if (hasRichChildren) {
@@ -1097,15 +1532,17 @@ function linkCardToAstro(node, ctx) {
1097
1532
  ctx.imports.add("link-card");
1098
1533
  const title = node.props?.title ?? "";
1099
1534
  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)}"` : "";
1535
+ const href = safeHref(node.props?.href);
1536
+ const titleAttr = componentAttr("title", title);
1537
+ const hrefAttr = componentAttr("href", href);
1538
+ const descAttr = description ? componentAttr("description", description) : "";
1539
+ const icon = node.props?.icon;
1540
+ const iconAttr = icon ? componentAttr("icon", icon) : "";
1104
1541
  const classAttr = mergeClassAttr("", node.props?.class);
1105
1542
  const passthrough = renderPassthroughAttrs(node.props, [
1106
1543
  "title", "description", "href", "icon", "class",
1107
1544
  ]);
1108
- return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
1545
+ return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${iconAttr}${classAttr}${passthrough} />`;
1109
1546
  }
1110
1547
  /**
1111
1548
  * Render an `accordion` TreeNode and its `accordion-item` children.
@@ -1124,9 +1561,9 @@ function accordionToAstro(node, ctx) {
1124
1561
  const defaultValue = node.props?.defaultValue;
1125
1562
  const typeAttr = ` type="${escapeAttr(type)}"`;
1126
1563
  const defaultAttr = defaultValue !== undefined
1127
- ? ` defaultValue=${typeof defaultValue === "string"
1128
- ? `"${escapeAttr(defaultValue)}"`
1129
- : `{${JSON.stringify(defaultValue)}}`}`
1564
+ ? typeof defaultValue === "string"
1565
+ ? componentAttr("defaultValue", defaultValue)
1566
+ : ` defaultValue={${JSON.stringify(defaultValue)}}`
1130
1567
  : "";
1131
1568
  const classAttr = mergeClassAttr("", node.props?.class);
1132
1569
  const passthrough = renderPassthroughAttrs(node.props, [
@@ -1142,7 +1579,7 @@ function accordionItemToAstro(node, ctx, standalone) {
1142
1579
  ctx.imports.add("accordion");
1143
1580
  const value = node.props?.value ?? "item-1";
1144
1581
  const label = node.props?.label ?? node.props?.title ?? "Item";
1145
- const valueAttr = ` value="${escapeAttr(value)}"`;
1582
+ const valueAttr = componentAttr("value", value);
1146
1583
  const classAttr = mergeClassAttr("", node.props?.class);
1147
1584
  const passthrough = renderPassthroughAttrs(node.props, [
1148
1585
  "value", "label", "title", "class",
@@ -1169,9 +1606,9 @@ function avatarToAstro(node, ctx) {
1169
1606
  const src = node.props?.src ?? "";
1170
1607
  const alt = node.props?.alt ?? "";
1171
1608
  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)}"` : "";
1609
+ const srcAttr = src ? componentAttr("src", src) : "";
1610
+ const altAttr = componentAttr("alt", alt);
1611
+ const fbAttr = fallback ? componentAttr("fallback", fallback) : "";
1175
1612
  const classAttr = mergeClassAttr("", node.props?.class);
1176
1613
  const passthrough = renderPassthroughAttrs(node.props, [
1177
1614
  "src", "alt", "fallback", "class",
@@ -1192,7 +1629,7 @@ function exampleToAstro(node, ctx) {
1192
1629
  const source = String(node.props?.source ?? "");
1193
1630
  const title = node.props?.title;
1194
1631
  const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
1195
- const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
1632
+ const titleAttr = title ? componentAttr("title", title) : "";
1196
1633
  const fallbackAttr = showFallback ? " showFallback" : "";
1197
1634
  const inner = childrenToAstro(node, ctx);
1198
1635
  return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
@@ -1212,24 +1649,25 @@ function htmlContainerToAstro(node, ctx) {
1212
1649
  }
1213
1650
  // ─── Utilities ────────────────────────────────────────────────────
1214
1651
  /**
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.
1652
+ * Render a node's content across all THREE TreeNode shapes: flat `inline`
1653
+ * (dogsbay-md parser), wrapped `children[{prose, inline}]` (Starlight
1654
+ * importer), and a node carrying only `html` (MDX/Starlight/MkDocs). Inline
1655
+ * comes before children when both are present.
1218
1656
  *
1219
1657
  * Used for list-item, table cells, step, dt, dd — types that commonly
1220
1658
  * carry leaf text content directly on the node.
1659
+ *
1660
+ * This function is where serialize-core's `renderLeaf` was extracted FROM, so
1661
+ * delegating to it closes the loop: the wrapper-dedup and html-shape handling
1662
+ * the core grew afterwards (from the Docusaurus dogfooding bug) now flow back
1663
+ * here instead of the two implementations continuing to drift apart.
1221
1664
  */
1222
1665
  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");
1666
+ return renderLeaf(node, {
1667
+ inline: inlineToTemplate,
1668
+ children: () => childrenToAstro(node, ctx),
1669
+ html: (html) => `<Fragment set:html={${escapeExpr(html)}} />`,
1670
+ });
1233
1671
  }
1234
1672
  function childrenToAstro(node, ctx) {
1235
1673
  if (!node.children || node.children.length === 0)
@@ -1243,39 +1681,28 @@ function childrenToAstro(node, ctx) {
1243
1681
  * icon usage on cards registers `icon` at the renderer call site;
1244
1682
  * this is the inline-only path. Returns true on first hit.
1245
1683
  */
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
- }
1684
+ /** The component imports inline nodes need: `icon`, `badge`, and `link-button` for an inline `:button`. */
1685
+ function inlineComponentsIn(nodes, out = new Set()) {
1686
+ const visit = (node) => {
1687
+ if (node.type === "icon")
1688
+ out.add("icon");
1689
+ else if (node.type === "badge")
1690
+ out.add("badge");
1691
+ else if (node.type === "kbd")
1692
+ out.add("kbd");
1693
+ else if (node.type === "link" && node.button)
1694
+ out.add("link-button");
1695
+ if ("children" in node && Array.isArray(node.children))
1696
+ node.children.forEach(visit);
1697
+ };
1698
+ for (const node of nodes ?? []) {
1699
+ node.inline?.forEach(visit);
1700
+ inlineComponentsIn(node.children, out);
1269
1701
  }
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");
1702
+ return out;
1278
1703
  }
1704
+ /** Indent, via the core's shared implementation (was a fourth local copy). */
1705
+ const indentStr = indent;
1279
1706
  function escapeHtml(s) {
1280
1707
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1281
1708
  }