@dogsbay/format-astro 0.2.0-beta.16 → 0.2.0-beta.161

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