@dogsbay/format-astro 0.2.0-beta.13 → 0.2.0-beta.131

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 (63) 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 +323 -0
  7. package/dist/browser.d.ts +13 -0
  8. package/dist/browser.js +13 -0
  9. package/dist/canonical.d.ts +33 -0
  10. package/dist/canonical.js +106 -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 -5
  22. package/dist/index.js +34 -4
  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 +377 -13
  30. package/dist/project.js +2239 -649
  31. package/dist/redirects.d.ts +115 -0
  32. package/dist/redirects.js +223 -0
  33. package/dist/serialize.d.ts +14 -1
  34. package/dist/serialize.js +821 -254
  35. package/dist/sitemap.d.ts +107 -0
  36. package/dist/sitemap.js +126 -0
  37. package/dist/taxonomy.d.ts +48 -2
  38. package/dist/taxonomy.js +46 -12
  39. package/dist/theme-css.d.ts +33 -0
  40. package/dist/theme-css.js +683 -0
  41. package/dist/tone-classes.d.ts +52 -0
  42. package/dist/tone-classes.js +84 -0
  43. package/dist/write-if-changed.d.ts +20 -0
  44. package/dist/write-if-changed.js +57 -0
  45. package/package.json +16 -7
  46. package/dist/base-path.d.ts.map +0 -1
  47. package/dist/base-path.js.map +0 -1
  48. package/dist/cli.d.ts.map +0 -1
  49. package/dist/cli.js.map +0 -1
  50. package/dist/index.d.ts.map +0 -1
  51. package/dist/index.js.map +0 -1
  52. package/dist/lead.d.ts.map +0 -1
  53. package/dist/lead.js.map +0 -1
  54. package/dist/llms-txt.d.ts.map +0 -1
  55. package/dist/llms-txt.js.map +0 -1
  56. package/dist/plugins.d.ts.map +0 -1
  57. package/dist/plugins.js.map +0 -1
  58. package/dist/project.d.ts.map +0 -1
  59. package/dist/project.js.map +0 -1
  60. package/dist/serialize.d.ts.map +0 -1
  61. package/dist/serialize.js.map +0 -1
  62. package/dist/taxonomy.d.ts.map +0 -1
  63. 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,74 @@ 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
+ return `<div data-directive="${escapeAttr(name)}"${classAttr}${passthrough}>\n${title}${childrenToAstro(node, ctx)}\n</div>`;
1073
+ }
720
1074
  function detailsToAstro(node, ctx) {
721
1075
  ctx.imports.add("details");
722
- const variant = node.props?.variant ?? "note";
723
- const title = node.props?.title ?? "Details";
724
- const open = node.props?.open;
1076
+ // `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
1077
+ // Aliases (`caution` → danger) and unknown names (→ note) as render-html maps them
1078
+ const variant = collapsibleVariant(node.props?.variant ?? node.props?.type ?? "note");
1079
+ // `summary` is dogsbay-md's alias for the title (`:::details{summary="…"}`)
1080
+ const title = node.props?.title ?? node.props?.summary ?? "Details";
1081
+ const open = flagOn(node.props?.open);
725
1082
  const icon = node.props?.icon;
726
1083
  const inner = childrenToAstro(node, ctx);
727
- const attrs = [`variant="${escapeAttr(variant)}"`, `title="${escapeAttr(title)}"`];
1084
+ // The title is inline Markdown, as in render-html: plain text stays the `title` prop
1085
+ const titleNodes = titleInline(title);
1086
+ const plainTitle = isPlainCaption(titleNodes);
1087
+ const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
728
1088
  if (open)
729
1089
  attrs.push("open");
730
1090
  if (icon)
731
1091
  attrs.push(`icon="${escapeAttr(icon)}"`);
732
1092
  const classAttr = mergeClassAttr("my-4", node.props?.class);
733
1093
  const passthrough = renderPassthroughAttrs(node.props, [
734
- "variant", "title", "open", "icon", "class", "summary",
1094
+ "variant", "type", "title", "open", "icon", "class", "summary", "sheet",
735
1095
  ]);
736
- return `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>
1096
+ const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
1097
+ const alert = `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>${slot}
737
1098
  ${indentStr(inner, 2)}
738
1099
  </CollapsibleAlert>`;
1100
+ // `sheet`: the same details, opened as a side sheet by <db-details-sheet>
1101
+ if (!isDetailsSheet(node.props))
1102
+ return alert;
1103
+ ctx.imports.add("details-sheet");
1104
+ return `<DetailsSheet>\n${indentStr(alert, 2)}\n</DetailsSheet>`;
739
1105
  }
740
1106
  function tabsToAstro(node, ctx) {
741
1107
  ctx.imports.add("tabs");
@@ -749,39 +1115,125 @@ function tabsToAstro(node, ctx) {
749
1115
  return { value, label, node: tab };
750
1116
  });
751
1117
  const defaultValue = tabItems[0].value;
1118
+ // Diff-trigger indicator: a marked tab panel is invisible until
1119
+ // opened, so its TRIGGER carries a data-diff-tab attribute (styled
1120
+ // as a colored dot by DIFF_CSS) telling the reader WHICH tab to
1121
+ // open. Structural read of @dogsbay/tree-diff marks, same as
1122
+ // diff-decoration; unmarked trees are unaffected.
1123
+ // The indicator must not live in COLOUR alone (WCAG 1.4.1), and a
1124
+ // background-tinted dot disappears in forced-colors mode. So it is a
1125
+ // GLYPH (shape carries the meaning) plus visually-hidden text, so the
1126
+ // trigger announces "Astro, changed in this comparison" rather than
1127
+ // relying on a `title` screen readers may never speak. (TAB_GLYPH and
1128
+ // tabDiffSignal are shared with render-html.)
1129
+ // A variant's class strings are complete (TAB_VARIANTS): TabsList and
1130
+ // TabsTrigger merge a `class` over their base with tailwind-variants
1131
+ const variant = lookup(TAB_VARIANTS, node.props?.variant);
1132
+ const triggerClass = variant ? ` class="${escapeAttr(variant.trigger)}"` : "";
752
1133
  const triggers = tabItems
753
- .map((t) => ` <TabsTrigger value="${escapeAttr(t.value)}">${escapeTemplate(t.label)}</TabsTrigger>`)
1134
+ .map((t) => {
1135
+ const signal = tabDiffSignal(t.node);
1136
+ if (!signal) {
1137
+ return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass}>${escapeTemplate(t.label)}</TabsTrigger>`;
1138
+ }
1139
+ const glyph = TAB_GLYPH[signal] ?? "•";
1140
+ // The glyph and its announced text are ONE element: "Hide
1141
+ // highlights" hides `.db-tab-mark`, and a sibling sr-only span
1142
+ // would survive that — leaving screen-reader users still hearing
1143
+ // "changed in this comparison" on every tab while the visible
1144
+ // marker is gone (code review, 2026-07-13).
1145
+ const marker = `<span class="db-tab-mark" data-diff-tab="${escapeAttr(signal)}">` +
1146
+ `<span aria-hidden="true">${glyph}</span>` +
1147
+ `<span class="sr-only">${escapeTemplate(signal)} in this comparison</span>` +
1148
+ `</span>`;
1149
+ // `value` MUST match TabsContent's, which is a componentAttr expression:
1150
+ // a title containing `&` was emitted `&amp;` here and `&` there, so the
1151
+ // trigger and its panel no longer paired and the tab rendered empty.
1152
+ // `data-diff-tab` is a plain data attribute — entity escaping is right.
1153
+ return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass} data-diff-tab="${escapeAttr(signal)}">${escapeTemplate(t.label)}<Fragment set:html={${escapeExpr(marker)}} /></TabsTrigger>`;
1154
+ })
754
1155
  .join("\n");
755
1156
  const contents = tabItems
756
1157
  .map((t) => {
757
1158
  const inner = childrenToAstro(t.node, ctx);
758
- return ` <TabsContent value="${escapeAttr(t.value)}">\n${indentStr(inner, 4)}\n </TabsContent>`;
1159
+ return ` <TabsContent${componentAttr("value", t.value)}>\n${indentStr(inner, 4)}\n </TabsContent>`;
759
1160
  })
760
1161
  .join("\n");
761
1162
  const classAttr = mergeClassAttr("my-4", node.props?.class);
762
1163
  const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
763
- return `<Tabs defaultValue="${escapeAttr(defaultValue)}"${classAttr}${passthrough}>
764
- <TabsList>
1164
+ // Groups with the same key switch together (db-tabs). Written `syncKey`, `sync-key` or `group`, as dogsbay-ssg reads it.
1165
+ const syncKey = [node.props?.syncKey, node.props?.["sync-key"], node.props?.group].find((v) => typeof v === "string" && v !== "");
1166
+ const syncAttr = syncKey ? componentAttr("syncKey", syncKey) : "";
1167
+ // Same encoding as the trigger/content `value` it selects — see above.
1168
+ return `<Tabs${componentAttr("defaultValue", defaultValue)}${syncAttr}${classAttr}${passthrough}>
1169
+ <TabsList${variant ? ` class="${escapeAttr(variant.list)}"` : ""}>
765
1170
  ${triggers}
766
1171
  </TabsList>
767
1172
  ${contents}
768
1173
  </Tabs>`;
769
1174
  }
1175
+ /**
1176
+ * A caption is inline Markdown. Plain text renders exactly as before;
1177
+ * formatting (`Sample \`<plugin>\` file`) renders as inline markup.
1178
+ */
1179
+ function captionInline(caption) {
1180
+ return parseInline(caption);
1181
+ }
1182
+ const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
1183
+ /**
1184
+ * Formatting tags with no attributes, the HTML a title keeps (a raw `<details><summary><code>x</code>`
1185
+ * becomes the title `<code>x</code>`). render-html has the same list.
1186
+ */
1187
+ const TITLE_TAG = /^<\/?(?:code|kbd|b|strong|i|em|s|del|mark|sub|sup|br)\s*\/?>$/i;
1188
+ /**
1189
+ * A callout or details title: inline Markdown, and any other raw HTML is TEXT. Importers write
1190
+ * plain-text titles with angle brackets (`Map<K, V>`, `List<String>`), which as HTML dropped words,
1191
+ * and a title with `<img onerror=…>` ran a script in the summary. render-html reads titles the same way.
1192
+ */
1193
+ function titleInline(title) {
1194
+ return captionInline(title).map((n) => (n.type === "html-inline" && !TITLE_TAG.test(n.html.trim()) ? { type: "text", text: n.html } : n));
1195
+ }
1196
+ /** A plain caption's text (escapes undone by the parse). */
1197
+ const captionText = (nodes) => nodes.map((n) => (n.type === "text" ? n.text : "")).join("");
1198
+ function captionToAstro(caption, ctx) {
1199
+ const nodes = captionInline(caption);
1200
+ return isPlainCaption(nodes)
1201
+ ? escapeTemplate(captionText(nodes))
1202
+ : renderInline(nodes, ctx);
1203
+ }
770
1204
  function tableToAstro(node, ctx) {
771
1205
  ctx.imports.add("table");
772
1206
  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>`;
1207
+ const caption = node.props?.caption;
1208
+ const nodes = caption ? captionInline(caption) : [];
1209
+ const plain = isPlainCaption(nodes);
1210
+ // A plain caption stays the `caption` prop; a formatted one fills the table's caption slot
1211
+ const captionAttr = caption && plain
1212
+ ? ` caption={${escapeExpr(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))}}`
1213
+ : "";
1214
+ const captionSlot = caption && !plain ? `<Fragment slot="caption">${renderInline(nodes, ctx)}</Fragment>\n` : "";
1215
+ const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
1216
+ return `<Table${classAttr}${captionAttr}${passthrough}>\n${captionSlot}${childrenToAstro(node, ctx)}\n</Table>`;
775
1217
  }
776
1218
  function diagramToAstro(node, ctx) {
777
1219
  ctx.imports.add("diagram");
778
1220
  const lang = node.props?.lang || "mermaid";
779
1221
  const code = node.props?.code ?? "";
780
- const svg = node.props?.svg ?? "";
1222
+ let svg = node.props?.svg ?? "";
781
1223
  const title = node.props?.title;
1224
+ // A Pele SVG's ids (`<prefix>-title`, `<prefix>-desc`, for aria-labelledby) are unique within
1225
+ // its source page. A book view or an include can show the same diagram twice on one page, so
1226
+ // the later copies are renamed; the prefix appears nowhere else in the SVG.
1227
+ const prefix = /^<svg[^>]*\bclass="pele\b/.test(svg) ? /\sid="(dg-[0-9a-f]{12}(?:-\d+)?)-/.exec(svg)?.[1] : undefined;
1228
+ if (prefix) {
1229
+ const n = (ctx.diagramIds.get(prefix) ?? 0) + 1;
1230
+ ctx.diagramIds.set(prefix, n);
1231
+ if (n > 1)
1232
+ svg = svg.split(prefix).join(`${prefix}-p${n}`);
1233
+ }
782
1234
  const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
783
1235
  if (title)
784
- attrs.push(`title="${escapeAttr(title)}"`);
1236
+ attrs.push(componentAttr("title", title).trimStart());
785
1237
  const classAttr = mergeClassAttr("", node.props?.class);
786
1238
  const passthrough = renderPassthroughAttrs(node.props, [
787
1239
  "lang", "code", "svg", "title", "class",
@@ -794,7 +1246,7 @@ function youtubeToAstro(node, ctx) {
794
1246
  const title = node.props?.title ?? "";
795
1247
  const classAttr = mergeClassAttr("my-4", node.props?.class);
796
1248
  const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
797
- return `<YouTube id="${escapeAttr(id)}" title="${escapeAttr(title)}"${classAttr}${passthrough} />`;
1249
+ return `<YouTube${componentAttr("id", id)}${componentAttr("title", title)}${classAttr}${passthrough} />`;
798
1250
  }
799
1251
  function mathBlockToAstro(node, ctx) {
800
1252
  ctx.imports.add("math-block");
@@ -837,6 +1289,11 @@ function footnoteListToAstro(node, ctx) {
837
1289
  function footnoteDefToAstro(node, ctx) {
838
1290
  const id = node.props?.id ?? "";
839
1291
  const backrefId = node.props?.backrefId ?? "";
1292
+ // Block content (a list, code): the blocks, with the backref after them
1293
+ if (node.children?.length) {
1294
+ const backref = backrefId ? `\n<a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
1295
+ return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">\n${childrenToAstro(node, ctx)}${backref}\n</li>`;
1296
+ }
840
1297
  if (ctx.mode === "template" && node.inline) {
841
1298
  const content = inlineToTemplate(node.inline);
842
1299
  const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
@@ -852,8 +1309,14 @@ function apiSymbolToAstro(node, ctx) {
852
1309
  const kind = node.props?.kind ?? "func";
853
1310
  const signature = node.props?.signature;
854
1311
  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}}`];
1312
+ // By position, not by type (apiSymbolLevels): a module-level function was
1313
+ // an h3 straight under the page's h1, and a class inside a class an h2.
1314
+ const level = ctx.apiSymbolLevels.get(node) ?? (node.type === "api-class" ? 2 : 3);
1315
+ const attrs = [
1316
+ componentAttr("name", name).trimStart(),
1317
+ componentAttr("kind", kind).trimStart(),
1318
+ `level={${level}}`,
1319
+ ];
857
1320
  if (signature)
858
1321
  attrs.push(`signature={${escapeExpr(signature)}}`);
859
1322
  if (bases.length > 0)
@@ -902,11 +1365,17 @@ function endpointToAstro(node, ctx) {
902
1365
  const path = props.path ?? "";
903
1366
  const baseUrl = props.baseUrl ?? "";
904
1367
  const summary = props.summary;
905
- const description = props.description;
1368
+ // Descriptions are CommonMark, rendered with raw HTML off: the components
1369
+ // put them in the page with set:html (api-markdown.ts).
1370
+ // (Compared as text first: the card hides a description equal to the
1371
+ // summary, and the rendered HTML never equals it.)
1372
+ const description = typeof props.description === "string" && props.description !== props.summary
1373
+ ? apiBlockHtml(props.description)
1374
+ : undefined;
906
1375
  const deprecated = props.deprecated === true;
907
- const parameters = props.parameters ?? [];
908
- const requestBody = props.requestBody ?? [];
909
- const responses = props.responses ?? [];
1376
+ const parameters = apiPropertiesHtml(props.parameters) ?? [];
1377
+ const requestBody = apiPropertiesHtml(props.requestBody) ?? [];
1378
+ const responses = (props.responses ?? []).map((r) => r && Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r);
910
1379
  const codeSamples = props.codeSamples ?? [];
911
1380
  const requestBodyExample = props.requestBodyExample;
912
1381
  const cardAttrs = [
@@ -946,7 +1415,9 @@ function endpointToAstro(node, ctx) {
946
1415
  const sectionsBody = sections.length > 0
947
1416
  ? `\n${indentStr(sections.join("\n"), 4)}\n `
948
1417
  : "";
949
- // Code column — wrapped in <ApiCodePanel> for consistent vertical
1418
+ // Code column — its titles are labels (<p>), not headings: an operation
1419
+ // page has only its h1 above them, so an h3 skipped a level.
1420
+ // Wrapped in <ApiCodePanel> for consistent vertical
950
1421
  // rhythm. Code samples first, then a request body example, then one
951
1422
  // ExampleBlock per response status that carries a concrete example.
952
1423
  // Each ExampleBlock auto-detects language from the response's
@@ -955,7 +1426,7 @@ function endpointToAstro(node, ctx) {
955
1426
  const codePanels = [];
956
1427
  if (codeSamples.length > 0) {
957
1428
  codePanels.push(`<div>\n` +
958
- ` <h3 class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</h3>\n` +
1429
+ ` <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</p>\n` +
959
1430
  ` <CodeSamples samples={${JSON.stringify(codeSamples)}} />\n` +
960
1431
  `</div>`);
961
1432
  }
@@ -975,31 +1446,66 @@ function endpointToAstro(node, ctx) {
975
1446
  const codeBody = codePanels.length > 0
976
1447
  ? `\n${indentStr(codePanels.join("\n"), 4)}\n `
977
1448
  : "";
978
- return (`<ApiLayout>\n` +
1449
+ const layoutAttrs = ctx.embeddedEndpoints ? ' variant="embedded"' : "";
1450
+ return (`<ApiLayout${layoutAttrs}>\n` +
979
1451
  ` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
980
1452
  ` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
981
1453
  `</ApiLayout>`);
982
1454
  }
1455
+ /**
1456
+ * Whole-card links being emitted. Inside one, a link in the card's body is its text: a browser
1457
+ * ends the outer <a> at an inner one, so the rest of the card fell out of the link (and lost
1458
+ * not-prose). The serializer is synchronous, so a depth count is enough.
1459
+ */
1460
+ let cardLinkDepth = 0;
1461
+ function wholeCardLink(yes, fn) {
1462
+ if (!yes)
1463
+ return fn();
1464
+ cardLinkDepth++;
1465
+ try {
1466
+ return fn();
1467
+ }
1468
+ finally {
1469
+ cardLinkDepth--;
1470
+ }
1471
+ }
1472
+ /** A card's title: not-prose, or docs.css's h3 rule (1.25rem, 2rem above) applies inside the card. */
1473
+ const CARD_TITLE_CLASS = "not-prose m-0 border-0 p-0 text-base font-semibold";
1474
+ /**
1475
+ * A card group's columns, responsive as render-html draws them (its GRID_COLS): one column on a
1476
+ * phone, then up to `cols` (1–6) by Grid's breakpoints (0, 640, 768, 1024). Default 2. A list
1477
+ * (`cols="1,2,3"`) is the author's own breakpoints and passes through.
1478
+ */
1479
+ function cardCols(raw) {
1480
+ const list = coerceNumericList(raw, [2]);
1481
+ if (list.length > 1)
1482
+ return list;
1483
+ const n = Math.min(6, Math.max(1, Math.round(list[0]) || 2));
1484
+ return n === 1 ? [1] : n === 2 ? [1, 2] : n <= 4 ? [1, 2, 2, n] : [1, 3, 3, n];
1485
+ }
983
1486
  function cardsToAstro(node, ctx) {
984
1487
  ctx.imports.add("grid");
985
1488
  ctx.imports.add("card");
986
1489
  // Grid-level attributes on the :::cards container
987
1490
  const outerClass = mergeClassAttr("", node.props?.class);
988
1491
  const outerPass = renderPassthroughAttrs(node.props, [
989
- "cols", "gap", "rows", "class",
1492
+ "cols", "gap", "rows", "class", "tone",
990
1493
  ]);
991
1494
  // MDX pattern: children are card nodes with title/href/icon props
992
1495
  const cardNodes = (node.children ?? []).filter((c) => c.type === "card");
993
1496
  if (cardNodes.length > 0) {
994
1497
  const cards = cardNodes.map((card) => {
995
1498
  const title = card.props?.title ?? "";
996
- const href = card.props?.href ?? "";
1499
+ const href = safeHref(card.props?.href);
997
1500
  const description = card.props?.description;
998
1501
  const icon = card.props?.icon;
999
1502
  // Description sources: inline content, children, or props.description.
1000
- const inner = childrenToAstro(card, ctx)
1001
- || (card.inline ? inlineToTemplate(card.inline) : "")
1503
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1504
+ const blocks = wholeCardLink(!!href, () => childrenToAstro(card, ctx));
1505
+ const inner = blocks
1506
+ || (card.inline ? wholeCardLink(!!href, () => inlineToTemplate(card.inline)) : "")
1002
1507
  || (description ? escapeTemplate(description) : "");
1508
+ const descTag = blocks ? "div" : "p";
1003
1509
  // Icon renders inline before the title — picks up Lucide
1004
1510
  // SVG via the `icon` import. Resolved at build time; nothing
1005
1511
  // emitted when the name doesn't resolve (silent fallback).
@@ -1007,26 +1513,32 @@ function cardsToAstro(node, ctx) {
1007
1513
  // top of the page.
1008
1514
  let iconHtml = "";
1009
1515
  if (icon) {
1010
- ctx.imports.add("icon");
1011
- iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1516
+ iconHtml = cardIconHtml(icon, title, ctx);
1012
1517
  }
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>`
1518
+ const titleHtml = title ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>` : "";
1519
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1520
+ const lead = blocks && description
1521
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1016
1522
  : "";
1523
+ const desc = lead + (inner
1524
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1525
+ : "");
1526
+ // A tone (TONES; the card's own, else the group's) sets the card's
1527
+ // border and tint: Card's tailwind-variants merge replaces bg-card
1528
+ const toned = lookup(CARD_TONE_CLASSES, card.props?.tone ?? node.props?.tone);
1017
1529
  const cardDefaultClass = href
1018
- ? "p-4 hover:border-foreground/20 transition-colors"
1019
- : "p-4";
1530
+ ? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
1531
+ : toned ? `p-4 ${toned}` : "p-4";
1020
1532
  const cardClassAttr = mergeClassAttr(cardDefaultClass, card.props?.class);
1021
1533
  const cardPass = renderPassthroughAttrs(card.props, [
1022
- "title", "href", "description", "icon", "variant", "class",
1534
+ "title", "href", "description", "icon", "variant", "class", "tone",
1023
1535
  ]);
1024
1536
  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>`;
1537
+ 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
1538
  }
1027
1539
  return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
1028
1540
  }).join("\n");
1029
- return `<Grid cols={[1, 2]} gap={4}${outerClass}${outerPass}>\n${cards}\n</Grid>`;
1541
+ return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}${outerClass}${outerPass}>\n${cards}\n</Grid>`;
1030
1542
  }
1031
1543
  // MkDocs pattern: children are unordered-list items
1032
1544
  const items = (node.children ?? [])
@@ -1036,10 +1548,10 @@ function cardsToAstro(node, ctx) {
1036
1548
  const cards = items
1037
1549
  .map((item) => `<Card>\n<CardContent>\n${childrenToAstro(item, ctx)}\n</CardContent>\n</Card>`)
1038
1550
  .join("\n");
1039
- return `<Grid cols={[1, 2]} gap={4}>\n${cards}\n</Grid>`;
1551
+ return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${cards}\n</Grid>`;
1040
1552
  }
1041
1553
  // Fallback: render children directly in grid
1042
- return `<Grid cols={[1, 2]} gap={4}>\n${childrenToAstro(node, ctx)}\n</Grid>`;
1554
+ return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${childrenToAstro(node, ctx)}\n</Grid>`;
1043
1555
  }
1044
1556
  /**
1045
1557
  * Render a standalone `card` TreeNode (outside `:::cards`).
@@ -1051,34 +1563,38 @@ function cardsToAstro(node, ctx) {
1051
1563
  function standaloneCardToAstro(node, ctx) {
1052
1564
  ctx.imports.add("card");
1053
1565
  const title = node.props?.title ?? "";
1054
- const href = node.props?.href ?? "";
1566
+ const href = safeHref(node.props?.href);
1055
1567
  const description = node.props?.description;
1056
1568
  const icon = node.props?.icon;
1057
- const inner = childrenToAstro(node, ctx)
1058
- || (node.inline ? inlineToTemplate(node.inline) : "")
1569
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1570
+ const blocks = wholeCardLink(!!href, () => childrenToAstro(node, ctx));
1571
+ const inner = blocks
1572
+ || (node.inline ? wholeCardLink(!!href, () => inlineToTemplate(node.inline)) : "")
1059
1573
  || (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
- }
1574
+ const descTag = blocks ? "div" : "p";
1575
+ // Icon renders inline before the title, a name or an image path, as in a card group
1576
+ const iconHtml = icon ? cardIconHtml(icon, title, ctx) : "";
1067
1577
  const titleHtml = title
1068
- ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>`
1578
+ ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>`
1069
1579
  : "";
1070
- const desc = inner
1071
- ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
1580
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1581
+ const lead = blocks && description
1582
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1072
1583
  : "";
1584
+ const desc = lead + (inner
1585
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1586
+ : "");
1587
+ // A tone (TONES), as on a card in a group (cardsToAstro)
1588
+ const toned = lookup(CARD_TONE_CLASSES, node.props?.tone);
1073
1589
  const cardDefaultClass = href
1074
- ? "p-4 hover:border-foreground/20 transition-colors"
1075
- : "p-4";
1590
+ ? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
1591
+ : toned ? `p-4 ${toned}` : "p-4";
1076
1592
  const classAttr = mergeClassAttr(cardDefaultClass, node.props?.class);
1077
1593
  const passthrough = renderPassthroughAttrs(node.props, [
1078
- "title", "href", "description", "icon", "variant", "class",
1594
+ "title", "href", "description", "icon", "variant", "class", "tone",
1079
1595
  ]);
1080
1596
  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>`;
1597
+ 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
1598
  }
1083
1599
  return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
1084
1600
  }
@@ -1089,6 +1605,59 @@ function standaloneCardToAstro(node, ctx) {
1089
1605
  * If the parser kept rich body children instead of folding into description,
1090
1606
  * we fall back to the standalone-card path which can hold arbitrary content.
1091
1607
  */
1608
+ /**
1609
+ * `link-button` — a call-to-action link styled as a button.
1610
+ *
1611
+ * There was no case for this at all, so it fell through to rendering the
1612
+ * node's CHILDREN: `<p>Get started</p>`. The label survived, the href did not,
1613
+ * and nothing warned — the reader saw a stray sentence where the page's
1614
+ * primary call to action should be. Every importer that produces one was
1615
+ * affected; `Button.astro` has taken an `href` all along.
1616
+ */
1617
+ /**
1618
+ * A card icon is either a registry NAME or a path to an image file.
1619
+ *
1620
+ * `<Icon name>` resolves names and renders NOTHING when the name does not
1621
+ * resolve — so a path handed to it disappeared silently, which is how every
1622
+ * "Related products" card lost its logo without anyone noticing.
1623
+ */
1624
+ function cardIconHtml(icon, title, ctx) {
1625
+ if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon)) {
1626
+ return `<img src="${escapeAttr(icon)}" alt="" class="size-5 mb-2" loading="lazy" />`;
1627
+ }
1628
+ ctx.imports.add("icon");
1629
+ return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1630
+ }
1631
+ /**
1632
+ * A button's variant as `Button.astro` names it. The spec's `primary` is its
1633
+ * filled `default` style; `Button.astro` has no `primary`, so it rendered
1634
+ * with no fill at all.
1635
+ */
1636
+ function buttonVariant(variant) {
1637
+ return !variant || variant === "primary" ? "default" : variant;
1638
+ }
1639
+ function linkButtonToAstro(node, ctx) {
1640
+ ctx.imports.add("link-button");
1641
+ const href = safeHref(node.props?.href);
1642
+ const written = node.props?.variant;
1643
+ const variant = written === undefined ? undefined : buttonVariant(written);
1644
+ const classAttr = mergeClassAttr("", node.props?.class);
1645
+ const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
1646
+ // A button's label is phrasing content, so a lone paragraph child is
1647
+ // unwrapped — `<Button><p>Get started</p></Button>` is invalid markup and
1648
+ // renders with the paragraph's block spacing inside the button.
1649
+ const kids = node.children ?? [];
1650
+ const soleParagraph = kids.length === 1 && kids[0].type === "paragraph" ? kids[0] : undefined;
1651
+ const label = soleParagraph
1652
+ ? leafContent(soleParagraph, ctx)
1653
+ : leafContent(node, ctx) || childrenToAstro(node, ctx);
1654
+ return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
1655
+ }
1656
+ /** A card or button `href` from an importer, or "" (no link) when its
1657
+ * scheme could run script: these never passed markdown-it's link check. */
1658
+ function safeHref(href) {
1659
+ return typeof href === "string" && isSafeHref(href) ? href : "";
1660
+ }
1092
1661
  function linkCardToAstro(node, ctx) {
1093
1662
  const hasRichChildren = (node.children ?? []).length > 0;
1094
1663
  if (hasRichChildren) {
@@ -1097,15 +1666,17 @@ function linkCardToAstro(node, ctx) {
1097
1666
  ctx.imports.add("link-card");
1098
1667
  const title = node.props?.title ?? "";
1099
1668
  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)}"` : "";
1669
+ const href = safeHref(node.props?.href);
1670
+ const titleAttr = componentAttr("title", title);
1671
+ const hrefAttr = componentAttr("href", href);
1672
+ const descAttr = description ? componentAttr("description", description) : "";
1673
+ const icon = node.props?.icon;
1674
+ const iconAttr = icon ? componentAttr("icon", icon) : "";
1104
1675
  const classAttr = mergeClassAttr("", node.props?.class);
1105
1676
  const passthrough = renderPassthroughAttrs(node.props, [
1106
1677
  "title", "description", "href", "icon", "class",
1107
1678
  ]);
1108
- return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
1679
+ return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${iconAttr}${classAttr}${passthrough} />`;
1109
1680
  }
1110
1681
  /**
1111
1682
  * Render an `accordion` TreeNode and its `accordion-item` children.
@@ -1124,9 +1695,9 @@ function accordionToAstro(node, ctx) {
1124
1695
  const defaultValue = node.props?.defaultValue;
1125
1696
  const typeAttr = ` type="${escapeAttr(type)}"`;
1126
1697
  const defaultAttr = defaultValue !== undefined
1127
- ? ` defaultValue=${typeof defaultValue === "string"
1128
- ? `"${escapeAttr(defaultValue)}"`
1129
- : `{${JSON.stringify(defaultValue)}}`}`
1698
+ ? typeof defaultValue === "string"
1699
+ ? componentAttr("defaultValue", defaultValue)
1700
+ : ` defaultValue={${JSON.stringify(defaultValue)}}`
1130
1701
  : "";
1131
1702
  const classAttr = mergeClassAttr("", node.props?.class);
1132
1703
  const passthrough = renderPassthroughAttrs(node.props, [
@@ -1134,15 +1705,18 @@ function accordionToAstro(node, ctx) {
1134
1705
  ]);
1135
1706
  const items = (node.children ?? [])
1136
1707
  .filter((c) => c.type === "accordion-item")
1137
- .map((item) => accordionItemToAstro(item, ctx, /*standalone*/ false))
1708
+ .map((item, i) => accordionItemToAstro(item, ctx, /*standalone*/ false, i + 1))
1138
1709
  .join("\n");
1139
1710
  return `<Accordion${typeAttr}${defaultAttr}${classAttr}${passthrough}>\n${items}\n</Accordion>`;
1140
1711
  }
1141
- function accordionItemToAstro(node, ctx, standalone) {
1712
+ function accordionItemToAstro(node, ctx, standalone,
1713
+ /** 1-based position: an item with no `value` must not share one with its siblings */
1714
+ index = 1) {
1142
1715
  ctx.imports.add("accordion");
1143
- const value = node.props?.value ?? "item-1";
1716
+ // Items are matched by value: a shared fallback opened and closed them together
1717
+ const value = node.props?.value ?? `item-${index}`;
1144
1718
  const label = node.props?.label ?? node.props?.title ?? "Item";
1145
- const valueAttr = ` value="${escapeAttr(value)}"`;
1719
+ const valueAttr = componentAttr("value", value);
1146
1720
  const classAttr = mergeClassAttr("", node.props?.class);
1147
1721
  const passthrough = renderPassthroughAttrs(node.props, [
1148
1722
  "value", "label", "title", "class",
@@ -1169,9 +1743,9 @@ function avatarToAstro(node, ctx) {
1169
1743
  const src = node.props?.src ?? "";
1170
1744
  const alt = node.props?.alt ?? "";
1171
1745
  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)}"` : "";
1746
+ const srcAttr = src ? componentAttr("src", src) : "";
1747
+ const altAttr = componentAttr("alt", alt);
1748
+ const fbAttr = fallback ? componentAttr("fallback", fallback) : "";
1175
1749
  const classAttr = mergeClassAttr("", node.props?.class);
1176
1750
  const passthrough = renderPassthroughAttrs(node.props, [
1177
1751
  "src", "alt", "fallback", "class",
@@ -1192,7 +1766,7 @@ function exampleToAstro(node, ctx) {
1192
1766
  const source = String(node.props?.source ?? "");
1193
1767
  const title = node.props?.title;
1194
1768
  const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
1195
- const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
1769
+ const titleAttr = title ? componentAttr("title", title) : "";
1196
1770
  const fallbackAttr = showFallback ? " showFallback" : "";
1197
1771
  const inner = childrenToAstro(node, ctx);
1198
1772
  return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
@@ -1207,29 +1781,34 @@ function htmlContainerToAstro(node, ctx) {
1207
1781
  const inner = childrenToAstro(node, ctx);
1208
1782
  return `<div class="columns-1 sm:columns-2 gap-4 [&_ul]:m-0">\n${inner}\n</div>`;
1209
1783
  }
1784
+ // A container parsed from raw HTML (a one-line `<details>` body) carries
1785
+ // its content in `html`, not children — rendering only children dropped it
1786
+ if (!node.children?.length && node.html)
1787
+ return htmlToAstro(node.html, ctx);
1210
1788
  const inner = childrenToAstro(node, ctx);
1211
1789
  return `<div>\n${inner}\n</div>`;
1212
1790
  }
1213
1791
  // ─── Utilities ────────────────────────────────────────────────────
1214
1792
  /**
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.
1793
+ * Render a node's content across all THREE TreeNode shapes: flat `inline`
1794
+ * (dogsbay-md parser), wrapped `children[{prose, inline}]` (Starlight
1795
+ * importer), and a node carrying only `html` (MDX/Starlight/MkDocs). Inline
1796
+ * comes before children when both are present.
1218
1797
  *
1219
1798
  * Used for list-item, table cells, step, dt, dd — types that commonly
1220
1799
  * carry leaf text content directly on the node.
1800
+ *
1801
+ * This function is where serialize-core's `renderLeaf` was extracted FROM, so
1802
+ * delegating to it closes the loop: the wrapper-dedup and html-shape handling
1803
+ * the core grew afterwards (from the Docusaurus dogfooding bug) now flow back
1804
+ * here instead of the two implementations continuing to drift apart.
1221
1805
  */
1222
1806
  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");
1807
+ return renderLeaf(node, {
1808
+ inline: inlineToTemplate,
1809
+ children: () => childrenToAstro(node, ctx),
1810
+ html: (html) => `<Fragment set:html={${escapeExpr(html)}} />`,
1811
+ });
1233
1812
  }
1234
1813
  function childrenToAstro(node, ctx) {
1235
1814
  if (!node.children || node.children.length === 0)
@@ -1243,40 +1822,28 @@ function childrenToAstro(node, ctx) {
1243
1822
  * icon usage on cards registers `icon` at the renderer call site;
1244
1823
  * this is the inline-only path. Returns true on first hit.
1245
1824
  */
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
- }
1825
+ /** The component imports inline nodes need: `icon`, `badge`, and `link-button` for an inline `:button`. */
1826
+ function inlineComponentsIn(nodes, out = new Set()) {
1827
+ const visit = (node) => {
1828
+ if (node.type === "icon")
1829
+ out.add("icon");
1830
+ else if (node.type === "badge")
1831
+ out.add("badge");
1832
+ else if (node.type === "kbd")
1833
+ out.add("kbd");
1834
+ else if (node.type === "link" && node.button)
1835
+ out.add("link-button");
1836
+ if ("children" in node && Array.isArray(node.children))
1837
+ node.children.forEach(visit);
1838
+ };
1839
+ for (const node of nodes ?? []) {
1840
+ node.inline?.forEach(visit);
1841
+ inlineComponentsIn(node.children, out);
1269
1842
  }
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");
1843
+ return out;
1278
1844
  }
1845
+ /** Indent, via the core's shared implementation (was a fourth local copy). */
1846
+ const indentStr = indent;
1279
1847
  function escapeHtml(s) {
1280
1848
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1281
1849
  }
1282
- //# sourceMappingURL=serialize.js.map