@dogsbay/format-astro 0.2.0-beta.11 → 0.2.0-beta.111

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 (50) hide show
  1. package/dist/base-path.d.ts +93 -7
  2. package/dist/base-path.d.ts.map +1 -1
  3. package/dist/base-path.js +122 -8
  4. package/dist/base-path.js.map +1 -1
  5. package/dist/blog.d.ts +134 -0
  6. package/dist/blog.d.ts.map +1 -0
  7. package/dist/blog.js +319 -0
  8. package/dist/blog.js.map +1 -0
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +1 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/diff-decoration.d.ts +79 -0
  13. package/dist/diff-decoration.d.ts.map +1 -0
  14. package/dist/diff-decoration.js +541 -0
  15. package/dist/diff-decoration.js.map +1 -0
  16. package/dist/granularity.d.ts +83 -0
  17. package/dist/granularity.d.ts.map +1 -0
  18. package/dist/granularity.js +247 -0
  19. package/dist/granularity.js.map +1 -0
  20. package/dist/index.d.ts +22 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +26 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/lead.d.ts +19 -0
  25. package/dist/lead.d.ts.map +1 -1
  26. package/dist/lead.js +101 -6
  27. package/dist/lead.js.map +1 -1
  28. package/dist/llms-txt.d.ts +48 -2
  29. package/dist/llms-txt.d.ts.map +1 -1
  30. package/dist/llms-txt.js +131 -14
  31. package/dist/llms-txt.js.map +1 -1
  32. package/dist/plugins.js +1 -1
  33. package/dist/plugins.js.map +1 -1
  34. package/dist/project.d.ts +311 -13
  35. package/dist/project.d.ts.map +1 -1
  36. package/dist/project.js +2261 -211
  37. package/dist/project.js.map +1 -1
  38. package/dist/serialize.d.ts +23 -0
  39. package/dist/serialize.d.ts.map +1 -1
  40. package/dist/serialize.js +359 -138
  41. package/dist/serialize.js.map +1 -1
  42. package/dist/sitemap.d.ts +81 -0
  43. package/dist/sitemap.d.ts.map +1 -0
  44. package/dist/sitemap.js +200 -0
  45. package/dist/sitemap.js.map +1 -0
  46. package/dist/taxonomy.d.ts +48 -1
  47. package/dist/taxonomy.d.ts.map +1 -1
  48. package/dist/taxonomy.js +61 -23
  49. package/dist/taxonomy.js.map +1 -1
  50. package/package.json +8 -7
package/dist/serialize.js CHANGED
@@ -1,9 +1,18 @@
1
+ import { walkInline, applyTextFlags, renderLeaf, indent, } from "@dogsbay/serialize-core";
2
+ import { decorateDiff, hasDiffMarks } from "./diff-decoration.js";
1
3
  /**
2
4
  * Tone palette for `:::grid-item{label="..." tone="..."}` cells.
3
5
  * Each tone is a `bg + text` Tailwind class pair. Used to make grid demos
4
6
  * declarative — authors specify a tone instead of writing the styling.
7
+ *
8
+ * Exported because the scaffolded site's `global.css` must safelist
9
+ * these classes via `@source inline()` — they only appear in
10
+ * markdown-generated `.astro` pages, which Tailwind's content
11
+ * scanner doesn't always pick up reliably (and consumers who put
12
+ * their content/ outside the scanned globs won't pick them up at
13
+ * all). See generateGlobalCss in project.ts.
5
14
  */
6
- const TONE_CLASSES = {
15
+ export const TONE_CLASSES = {
7
16
  // Primary scale (intensity)
8
17
  "primary": "bg-primary text-primary-foreground",
9
18
  "primary-strong": "bg-primary/80 text-primary-foreground",
@@ -96,6 +105,9 @@ const COMPONENT_IMPORTS = {
96
105
  "link-card": [
97
106
  'import LinkCard from "@ui/link-card/LinkCard.astro";',
98
107
  ],
108
+ "link-button": [
109
+ 'import Button from "@ui/button/Button.astro";',
110
+ ],
99
111
  avatar: [
100
112
  'import Avatar from "@ui/avatar/Avatar.astro";',
101
113
  ],
@@ -115,6 +127,30 @@ export function escapeExpr(s) {
115
127
  export function escapeAttr(s) {
116
128
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
117
129
  }
130
+ export function componentAttr(name, value) {
131
+ return /[&"<>]/.test(value)
132
+ ? ` ${name}={${escapeExpr(value)}}`
133
+ : ` ${name}="${value}"`;
134
+ }
135
+ /**
136
+ * Render extra attributes from an inline element's `attrs` field
137
+ * (links and images today; spans in a future phase) as ` key="val"`
138
+ * pairs prefixed with a leading space, suitable for splicing into an
139
+ * open tag immediately after the element's first-class attrs (href,
140
+ * src, etc.). `id`, `class`, and `style` are first-class; any other
141
+ * key passes through verbatim.
142
+ */
143
+ function renderInlineElementAttrs(attrs) {
144
+ if (!attrs)
145
+ return "";
146
+ const parts = [];
147
+ for (const [key, value] of Object.entries(attrs)) {
148
+ if (value === undefined)
149
+ continue;
150
+ parts.push(` ${key}="${escapeAttr(String(value))}"`);
151
+ }
152
+ return parts.join("");
153
+ }
118
154
  /** Escape text for use directly in an Astro template (not inside an expression). */
119
155
  export function escapeTemplate(s) {
120
156
  let result = "";
@@ -149,6 +185,12 @@ export function treeToAstro(nodes, options) {
149
185
  mode: options?.mode ?? "template",
150
186
  imageOptimization: options?.imageOptimization ?? false,
151
187
  codeBlockTitle: options?.codeBlockTitle ?? true,
188
+ combinedPrefix: options?.combinedPrefix ?? "",
189
+ // Mixed prose+endpoint pages render endpoints as embedded blocks;
190
+ // pages that are nothing but endpoints/headings keep the
191
+ // full-viewport operation layout (the OpenAPI page shape).
192
+ embeddedEndpoints: nodes.some((n) => n.type === "endpoint") &&
193
+ nodes.some((n) => n.type !== "endpoint" && n.type !== "heading" && n.type !== "hr"),
152
194
  };
153
195
  // Pre-scan for inline `icon` nodes anywhere in the tree.
154
196
  // `inlineNodeToTemplate` is intentionally pure (no ctx) — it
@@ -181,7 +223,18 @@ export function treeToAstro(nodes, options) {
181
223
  };
182
224
  }
183
225
  // ─── Node dispatcher ──────────────────────────────────────────────
226
+ /**
227
+ * Dispatch + diff decoration: nodes carrying DiffTree marks (from
228
+ * @dogsbay/tree-diff, read structurally) decorate the rendered
229
+ * snippet; unmarked nodes render byte-identically to before.
230
+ */
184
231
  function nodeToAstro(node, ctx) {
232
+ const rendered = nodeToAstroBase(node, ctx);
233
+ if (!rendered || !hasDiffMarks(node))
234
+ return rendered;
235
+ return decorateDiff(node, rendered, (peer) => nodeToAstroBase(peer, ctx));
236
+ }
237
+ function nodeToAstroBase(node, ctx) {
185
238
  switch (node.type) {
186
239
  case "prose":
187
240
  return proseToAstro(node, ctx);
@@ -212,6 +265,13 @@ function nodeToAstro(node, ctx) {
212
265
  return wrapBlock("li", "", node, leafContent(node, ctx));
213
266
  case "blockquote":
214
267
  return wrapBlock("blockquote", "border-l-4 border-border pl-4 italic text-muted-foreground", node, childrenToAstro(node, ctx));
268
+ // A transparent grouping wrapper: renders its children inside a plain
269
+ // <div> and adds no semantics of its own. Exists so a caller can carry
270
+ // an attribute (a class, a data-* flag) for a whole run of blocks —
271
+ // the release comparison marks a wholly added/removed PAGE once, at
272
+ // the page level, instead of putting a bar on every block inside it.
273
+ case "group":
274
+ return wrapBlock("div", "", node, childrenToAstro(node, ctx));
215
275
  case "hr": {
216
276
  const classAttr = mergeClassAttr("my-8 border-border", node.props?.class);
217
277
  const passthrough = renderPassthroughAttrs(node.props, ["class"]);
@@ -297,6 +357,8 @@ function nodeToAstro(node, ctx) {
297
357
  return standaloneCardToAstro(node, ctx);
298
358
  case "link-card":
299
359
  return linkCardToAstro(node, ctx);
360
+ case "link-button":
361
+ return linkButtonToAstro(node, ctx);
300
362
  case "accordion":
301
363
  return accordionToAstro(node, ctx);
302
364
  case "accordion-item":
@@ -351,6 +413,28 @@ ${childrenToAstro(node, ctx)}
351
413
  ${childrenToAstro(node, ctx)}
352
414
  </GridItem>`;
353
415
  }
416
+ case "related": {
417
+ // #384 R3: AsciiDoc [role="_additional-resources"] + .Title + list folds
418
+ // to this semantic node (maps to DITA <related-links>). Render a titled
419
+ // section, not a TOC heading. The title is a caption (block title), so a
420
+ // styled <p>, not an <h2>.
421
+ const title = node.props?.title;
422
+ const classAttr = mergeClassAttr("related-resources", node.props?.class);
423
+ const passthrough = renderPassthroughAttrs(node.props, ["title", "class"]);
424
+ const titleHtml = title
425
+ ? `<p class="related-resources-title">${escapeTemplate(title)}</p>\n`
426
+ : "";
427
+ return `<section${classAttr}${passthrough}>\n${titleHtml}${childrenToAstro(node, ctx)}\n</section>`;
428
+ }
429
+ case "mdx-raw": {
430
+ // Preserved-but-unmapped MDX component (format-mdx never-drop; see
431
+ // docs-dev/format-mdx-adapters.md). Rendered as a visible source
432
+ // block — the content must not silently disappear from the HTML
433
+ // output. Map the component (adapter / mdx.components config) to
434
+ // upgrade it to a real widget.
435
+ const source = String(node.props?.source ?? "");
436
+ return `<pre class="mdx-raw my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code>{${escapeExpr(source)}}</code></pre>`;
437
+ }
354
438
  default:
355
439
  if (node.html)
356
440
  return `<Fragment set:html={${escapeExpr(node.html)}} />`;
@@ -360,37 +444,51 @@ ${childrenToAstro(node, ctx)}
360
444
  }
361
445
  }
362
446
  // ─── Inline rendering ─────────────────────────────────────────────
363
- /** Render inline nodes as Astro template markup (clean, editable). */
364
- function inlineToTemplate(nodes) {
365
- return nodes.map(inlineNodeToTemplate).join("");
366
- }
367
- function inlineNodeToTemplate(node) {
368
- switch (node.type) {
369
- case "text": {
370
- let text = escapeTemplate(node.text);
371
- if (node.bold)
372
- text = `<strong>${text}</strong>`;
373
- if (node.italic)
374
- text = `<em>${text}</em>`;
375
- if (node.strikethrough)
376
- text = `<s>${text}</s>`;
377
- return text;
378
- }
379
- case "link": {
380
- const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
381
- return `<a href="${escapeAttr(node.href)}"${titleAttr}>${inlineToTemplate(node.children)}</a>`;
382
- }
383
- case "image":
384
- return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy" />`;
385
- case "code":
386
- return `<code>${escapeTemplate(node.text)}</code>`;
387
- case "footnote-ref":
388
- return `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${escapeTemplate(node.label)}]</a></sup>`;
389
- case "kbd":
390
- return node.keys.map((k) => `<kbd>${escapeTemplate(k)}</kbd>`).join("+");
391
- case "math":
392
- return `<code class="math-inline">${escapeTemplate(node.latex)}</code>`;
393
- case "icon": {
447
+ /**
448
+ * Astro's two inline dialects, as emitters over serialize-core's shared walk.
449
+ *
450
+ * These were the THIRD and FOURTH copies of the 11-variant switch (obsidian and
451
+ * dogsbay-md's html fallback were the other two), and the duplication is
452
+ * precisely why `highlight` was missing from BOTH of them: adding an arm meant
453
+ * remembering four places, so `:highlight[…]` rendered as nothing at all on
454
+ * every Astro page. The walk is now shared, so a variant is handled in one
455
+ * place per format instead of being silently skippable.
456
+ *
457
+ * Only the leaf syntax differs between the two maps: template mode escapes for
458
+ * Astro's `{}` and emits real components (`<Icon>`, `<Fragment set:html>`),
459
+ * hybrid mode escapes for plain HTML and emits inert markup.
460
+ */
461
+ /** Canonical highlight styling — keep in sync with InlineRenderer.astro. */
462
+ const HIGHLIGHT_CLASS = "rounded-sm bg-yellow-200/60 px-0.5 dark:bg-yellow-500/30";
463
+ function templateInlineEmitters() {
464
+ return {
465
+ text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
466
+ bold: (s) => `<strong>${s}</strong>`,
467
+ italic: (s) => `<em>${s}</em>`,
468
+ strike: (s) => `<s>${s}</s>`,
469
+ }),
470
+ link: (node, ctx) => {
471
+ const titleAttr = node.title ? componentAttr("title", node.title) : "";
472
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
473
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
474
+ },
475
+ image: (node) => {
476
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
477
+ return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy"${extraAttrs} />`;
478
+ },
479
+ code: (node, ctx) => `<code>${ctx.escape(node.text)}</code>`,
480
+ // The arm that did not exist — `:highlight[…]` / `:mark[…]` rendered as
481
+ // nothing at all. The class list mirrors the canonical rendering in
482
+ // `packages/ui/src/content-renderer/InlineRenderer.astro`, so a highlight
483
+ // looks the same however a page is produced. A BARE `<mark>` would have
484
+ // been "fixed" but unstyled: the generated `.docs-prose` stylesheet had no
485
+ // `mark` rule, so browsers paint their default yellow — including on dark
486
+ // pages, where nothing declares `color-scheme`.
487
+ highlight: (node, ctx) => `<mark class="${HIGHLIGHT_CLASS}">${ctx.recurse(node.children)}</mark>`,
488
+ "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>`,
489
+ kbd: (node, ctx) => node.keys.map((k) => `<kbd>${ctx.escape(k)}</kbd>`).join("+"),
490
+ math: (node, ctx) => `<code class="math-inline">${ctx.escape(node.latex)}</code>`,
491
+ icon: (node) => {
394
492
  // Inline icon — emits the platform `<Icon>` component which
395
493
  // resolves at build time against Lucide (default) plus any
396
494
  // other Iconify pack via the `pack:name` shorthand. Caller
@@ -404,47 +502,29 @@ function inlineNodeToTemplate(node) {
404
502
  // to Lucide for everything.
405
503
  const fullName = node.library ? `${node.library}:${node.name}` : node.name;
406
504
  return `<Icon name="${escapeAttr(fullName)}" class="inline-block size-[1em] align-[-0.125em]" />`;
407
- }
408
- case "html-inline":
409
- // Raw HTML inline — must use set:html to avoid brace issues
410
- return `<Fragment set:html={${escapeExpr(node.html)}} />`;
411
- case "break":
412
- return "<br />";
413
- default:
414
- return "";
415
- }
416
- }
417
- /** Render inline nodes as HTML string (for hybrid mode set:html). */
418
- function inlineToHtml(nodes) {
419
- return nodes.map(inlineNodeToHtml).join("");
505
+ },
506
+ // Raw HTML inline — must use set:html to avoid brace issues
507
+ "html-inline": (node) => `<Fragment set:html={${escapeExpr(node.html)}} />`,
508
+ break: () => "<br />",
509
+ };
420
510
  }
421
- function inlineNodeToHtml(node) {
422
- switch (node.type) {
423
- case "text": {
424
- let text = escapeHtml(node.text);
425
- if (node.bold)
426
- text = `<strong>${text}</strong>`;
427
- if (node.italic)
428
- text = `<em>${text}</em>`;
429
- if (node.strikethrough)
430
- text = `<s>${text}</s>`;
431
- return text;
432
- }
433
- case "link": {
511
+ function htmlModeInlineEmitters() {
512
+ return {
513
+ ...templateInlineEmitters(),
514
+ // Inherited from the template map, this emitted `title={"…"}` — an
515
+ // EXPRESSION, inside a string that becomes a `set:html` value. Astro never
516
+ // parses it as markup, so the reader saw the braces and quotes.
517
+ link: (node, ctx) => {
434
518
  const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
435
- return `<a href="${escapeAttr(node.href)}"${titleAttr}>${inlineToHtml(node.children)}</a>`;
436
- }
437
- case "image":
438
- return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy" />`;
439
- case "code":
440
- return `<code>${escapeHtml(node.text)}</code>`;
441
- case "footnote-ref":
442
- return `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${escapeHtml(node.label)}]</a></sup>`;
443
- case "kbd":
444
- return node.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join("+");
445
- case "math":
446
- return `<code class="math-inline">${escapeHtml(node.latex)}</code>`;
447
- case "icon": {
519
+ const extraAttrs = renderInlineElementAttrs(node.attrs);
520
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
521
+ },
522
+ text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
523
+ bold: (s) => `<strong>${s}</strong>`,
524
+ italic: (s) => `<em>${s}</em>`,
525
+ strike: (s) => `<s>${s}</s>`,
526
+ }),
527
+ icon: (node) => {
448
528
  // Hybrid-mode HTML rendering — fall back to a textual
449
529
  // shortcode reference when we can't inline the SVG. Real
450
530
  // resolution happens in template mode via the <Icon>
@@ -452,14 +532,19 @@ function inlineNodeToHtml(node) {
452
532
  // production output.
453
533
  const fullName = node.library ? `${node.library}:${node.name}` : node.name;
454
534
  return `<span class="dogsbay-icon" data-icon="${escapeAttr(fullName)}"></span>`;
455
- }
456
- case "html-inline":
457
- return node.html;
458
- case "break":
459
- return "<br />";
460
- default:
461
- return "";
462
- }
535
+ },
536
+ "html-inline": (node) => node.html,
537
+ };
538
+ }
539
+ const TEMPLATE_INLINE = templateInlineEmitters();
540
+ const HTML_INLINE = htmlModeInlineEmitters();
541
+ /** Render inline nodes as Astro template markup (clean, editable). */
542
+ function inlineToTemplate(nodes) {
543
+ return walkInline(nodes, { emitters: TEMPLATE_INLINE, escape: escapeTemplate });
544
+ }
545
+ /** Render inline nodes as HTML string (for hybrid mode set:html). */
546
+ function inlineToHtml(nodes) {
547
+ return walkInline(nodes, { emitters: HTML_INLINE, escape: escapeHtml });
463
548
  }
464
549
  /** Render inline content in the current mode. */
465
550
  function renderInline(nodes, ctx) {
@@ -490,15 +575,21 @@ function headingToAstro(node, ctx) {
490
575
  ? `<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>`
491
576
  : "";
492
577
  const idAttr = id ? ` id="${escapeAttr(id)}"` : "";
493
- const classAttr = mergeClassAttr("group scroll-mt-20", node.props?.class);
494
- const passthrough = renderPassthroughAttrs(node.props, [
495
- "level", "slug", "id", "class", "text",
496
- ]);
578
+ const consumed = ["level", "slug", "id", "class", "text"];
579
+ const userClass = node.props?.class;
497
580
  if (ctx.mode === "template" && node.inline) {
581
+ // Real Astro markup: expressions are parsed.
582
+ const classAttr = mergeClassAttr("group scroll-mt-20", userClass);
583
+ const passthrough = renderPassthroughAttrs(node.props, consumed);
498
584
  const text = inlineToTemplate(node.inline);
499
585
  return `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
500
586
  }
501
- // Hybrid or no inline — use set:html for the whole heading
587
+ // Hybrid or no inline — the whole heading becomes a set:html STRING, where an
588
+ // expression would be literal text. Every MDX/Mintlify heading takes this
589
+ // branch (that importer emits `props.text` + children, never `inline`), so
590
+ // this is the common path, not an edge case.
591
+ const classAttr = mergeClassAttr("group scroll-mt-20", userClass, "html");
592
+ const passthrough = renderPassthroughAttrs(node.props, consumed, "html");
502
593
  const text = node.inline ? inlineToHtml(node.inline) : node.props?.text ?? "";
503
594
  const html = `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
504
595
  return `<Fragment set:html={${escapeExpr(html)}} />`;
@@ -515,15 +606,26 @@ function paragraphToAstro(node, ctx) {
515
606
  if (meaningful.length === 1 && meaningful[0].type === "image") {
516
607
  const img = meaningful[0];
517
608
  ctx.imports.add("image");
609
+ // imageMap is keyed by the unprefixed publicPath built from
610
+ // `import.meta.glob("/src/assets/**", ...)` at runtime, so
611
+ // strip the URL prefix back off if rewriteTreeImageSrcs has
612
+ // already applied it. Keeps the optimized-image lookup working
613
+ // alongside the prefix-on-emit fix.
614
+ const lookupKey = ctx.combinedPrefix && img.src.startsWith(`${ctx.combinedPrefix}/`)
615
+ ? img.src.slice(ctx.combinedPrefix.length)
616
+ : img.src;
518
617
  const imgDataAttr = ctx.imageOptimization
519
- ? ` imageData={imageMap[${escapeExpr(img.src)}]}`
618
+ ? ` imageData={imageMap[${escapeExpr(lookupKey)}]}`
520
619
  : "";
521
620
  // Forward arbitrary `data-*` / `aria-*` attrs that plugins
522
621
  // may have attached to the wrapping paragraph (e.g.
523
622
  // @dogsbay/plugin-image-zoom tags `data-zoomable="true"` so
524
623
  // the runtime can attach handlers without scanning the DOM).
525
624
  const passthrough = renderPassthroughAttrs(node.props, ["class"]);
526
- return `<BlockImage src="${escapeAttr(img.src)}" alt="${escapeAttr(img.alt ?? "")}"${passthrough}${imgDataAttr} />`;
625
+ // Author-supplied {.class #id ...} on the image itself
626
+ // (Phase 1.5 of plans/inline-attrs.md).
627
+ const imageAttrs = renderInlineElementAttrs(img.attrs);
628
+ return `<BlockImage${componentAttr("src", img.src)}${componentAttr("alt", img.alt ?? "")}${imageAttrs}${passthrough}${imgDataAttr} />`;
527
629
  }
528
630
  }
529
631
  const classAttr = mergeClassAttr("", node.props?.class);
@@ -563,28 +665,37 @@ function codeToAstro(node, ctx) {
563
665
  const title = node.props?.title;
564
666
  const highlights = node.props?.highlights;
565
667
  const lineNumbers = node.props?.lineNumbers;
566
- const isRich = lineNumbers || highlights || code.includes("[!code");
668
+ const copyText = node.props?.copyText;
669
+ const diffAdd = node.props?.diffAdd;
670
+ const diffRemove = node.props?.diffRemove;
671
+ const isRich = lineNumbers || highlights || code.includes("[!code") || Boolean(diffAdd || diffRemove);
567
672
  const classAttr = mergeClassAttr("", node.props?.class);
568
673
  const consumed = [
569
674
  "lang", "language", "code", "title", "highlights", "lineNumbers",
570
- "class", "ins", "del", "mark", "collapse",
675
+ "class", "ins", "del", "mark", "collapse", "copyText", "diffAdd", "diffRemove",
571
676
  ];
572
677
  const passthrough = renderPassthroughAttrs(node.props, consumed);
573
678
  if (isRich) {
574
679
  ctx.imports.add("code-rich");
575
680
  const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
576
681
  if (title)
577
- attrs.push(`title="${escapeAttr(title)}"`);
682
+ attrs.push(componentAttr("title", title).trimStart());
578
683
  if (lineNumbers)
579
684
  attrs.push("lineNumbers");
580
685
  if (highlights)
581
686
  attrs.push(`highlights="${escapeAttr(highlights)}"`);
687
+ if (copyText !== undefined)
688
+ attrs.push(`copyText={${escapeExpr(copyText)}}`);
689
+ if (diffAdd)
690
+ attrs.push(`diffAdd="${escapeAttr(diffAdd)}"`);
691
+ if (diffRemove)
692
+ attrs.push(`diffRemove="${escapeAttr(diffRemove)}"`);
582
693
  return `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough} />`;
583
694
  }
584
695
  ctx.imports.add("code");
585
696
  const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
586
697
  if (title)
587
- attrs.push(`title="${escapeAttr(title)}"`);
698
+ attrs.push(componentAttr("title", title).trimStart());
588
699
  if (ctx.codeBlockTitle !== true) {
589
700
  attrs.push(`showTitle="${ctx.codeBlockTitle}"`);
590
701
  }
@@ -600,6 +711,17 @@ const CALLOUT_VARIANT_MAP = {
600
711
  hint: "tip",
601
712
  attention: "warning",
602
713
  };
714
+ // Default title shown when a callout has no explicit one (post-variant-map).
715
+ // Keyed by the mapped variant. Falls back to a capitalised variant otherwise.
716
+ const CALLOUT_LABELS = {
717
+ note: "Note",
718
+ tip: "Tip",
719
+ info: "Important",
720
+ warning: "Warning",
721
+ danger: "Caution",
722
+ failure: "Error",
723
+ success: "Success",
724
+ };
603
725
  function calloutToAstro(node, ctx) {
604
726
  ctx.imports.add("callout");
605
727
  // Callout variant may live on props.variant (Starlight / MkDocs importers)
@@ -608,7 +730,14 @@ function calloutToAstro(node, ctx) {
608
730
  ?? node.props?.type
609
731
  ?? "note").toLowerCase();
610
732
  const variant = CALLOUT_VARIANT_MAP[rawVariant] ?? rawVariant;
611
- const title = node.props?.title ?? "";
733
+ // When the source gives no explicit title, fall back to the type label
734
+ // (Note / Warning / …) instead of an empty <AlertTitle> — AsciiDoc
735
+ // admonitions and GitHub-style alerts both show the type next to the icon,
736
+ // and an empty title leaves the icon standing alone. Issue #004.
737
+ const explicitTitle = node.props?.title;
738
+ const title = explicitTitle && explicitTitle.length > 0
739
+ ? explicitTitle
740
+ : CALLOUT_LABELS[variant] ?? variant.charAt(0).toUpperCase() + variant.slice(1);
612
741
  const icon = node.props?.icon;
613
742
  const iconAttr = icon ? ` icon="${escapeAttr(icon)}"` : "";
614
743
  // User classes and passthrough attributes (id, data-*, aria-*, style)
@@ -629,15 +758,19 @@ ${indentStr(inner, 4)}
629
758
  * Concatenates (user classes appended) so utility frameworks like Tailwind
630
759
  * resolve conflicts via component-internal class-variance-authority / tv().
631
760
  */
632
- function mergeClassAttr(defaults, userClass) {
761
+ function mergeClassAttr(defaults, userClass, target = "astro") {
633
762
  const combined = [defaults, userClass].filter(Boolean).join(" ").trim();
634
- return combined ? ` class="${escapeAttr(combined)}"` : "";
763
+ if (!combined)
764
+ return "";
765
+ return target === "html"
766
+ ? ` class="${escapeAttr(combined)}"`
767
+ : componentAttr("class", combined);
635
768
  }
636
769
  /**
637
770
  * Render attributes not consumed as component props — `id`, `data-*`, `aria-*`,
638
771
  * and `style`. These pass through to the rendered element.
639
772
  */
640
- function renderPassthroughAttrs(props, consumed) {
773
+ function renderPassthroughAttrs(props, consumed, target = "astro") {
641
774
  if (!props)
642
775
  return "";
643
776
  const skip = new Set([...consumed, "source", "children"]);
@@ -652,9 +785,12 @@ function renderPassthroughAttrs(props, consumed) {
652
785
  if (value === true) {
653
786
  parts.push(key);
654
787
  }
655
- else {
788
+ else if (target === "html") {
656
789
  parts.push(`${key}="${escapeAttr(String(value))}"`);
657
790
  }
791
+ else {
792
+ parts.push(componentAttr(key, String(value)).trimStart());
793
+ }
658
794
  }
659
795
  return parts.length ? " " + parts.join(" ") : "";
660
796
  }
@@ -724,7 +860,7 @@ function detailsToAstro(node, ctx) {
724
860
  const open = node.props?.open;
725
861
  const icon = node.props?.icon;
726
862
  const inner = childrenToAstro(node, ctx);
727
- const attrs = [`variant="${escapeAttr(variant)}"`, `title="${escapeAttr(title)}"`];
863
+ const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", title).trimStart()];
728
864
  if (open)
729
865
  attrs.push("open");
730
866
  if (icon)
@@ -749,18 +885,62 @@ function tabsToAstro(node, ctx) {
749
885
  return { value, label, node: tab };
750
886
  });
751
887
  const defaultValue = tabItems[0].value;
888
+ // Diff-trigger indicator: a marked tab panel is invisible until
889
+ // opened, so its TRIGGER carries a data-diff-tab attribute (styled
890
+ // as a colored dot by DIFF_CSS) telling the reader WHICH tab to
891
+ // open. Structural read of @dogsbay/tree-diff marks, same as
892
+ // diff-decoration; unmarked trees are unaffected.
893
+ const tabDiffSignal = (tab) => {
894
+ const own = tab.diff;
895
+ if (own)
896
+ return own;
897
+ const deep = (n) => Boolean(n.diff || n.diffInline) || (n.children ?? []).some(deep);
898
+ return (tab.children ?? []).some(deep) ? "changed" : undefined;
899
+ };
900
+ // The indicator must not live in COLOUR alone (WCAG 1.4.1), and a
901
+ // background-tinted dot disappears in forced-colors mode. So it is a
902
+ // GLYPH (shape carries the meaning) plus visually-hidden text, so the
903
+ // trigger announces "Astro, changed in this comparison" rather than
904
+ // relying on a `title` screen readers may never speak.
905
+ const TAB_GLYPH = {
906
+ added: "+",
907
+ changed: "•",
908
+ removed: "−",
909
+ moved: "→",
910
+ };
752
911
  const triggers = tabItems
753
- .map((t) => ` <TabsTrigger value="${escapeAttr(t.value)}">${escapeTemplate(t.label)}</TabsTrigger>`)
912
+ .map((t) => {
913
+ const signal = tabDiffSignal(t.node);
914
+ if (!signal) {
915
+ return ` <TabsTrigger${componentAttr("value", t.value)}>${escapeTemplate(t.label)}</TabsTrigger>`;
916
+ }
917
+ const glyph = TAB_GLYPH[signal] ?? "•";
918
+ // The glyph and its announced text are ONE element: "Hide
919
+ // highlights" hides `.db-tab-mark`, and a sibling sr-only span
920
+ // would survive that — leaving screen-reader users still hearing
921
+ // "changed in this comparison" on every tab while the visible
922
+ // marker is gone (code review, 2026-07-13).
923
+ const marker = `<span class="db-tab-mark" data-diff-tab="${escapeAttr(signal)}">` +
924
+ `<span aria-hidden="true">${glyph}</span>` +
925
+ `<span class="sr-only">${escapeTemplate(signal)} in this comparison</span>` +
926
+ `</span>`;
927
+ // `value` MUST match TabsContent's, which is a componentAttr expression:
928
+ // a title containing `&` was emitted `&amp;` here and `&` there, so the
929
+ // trigger and its panel no longer paired and the tab rendered empty.
930
+ // `data-diff-tab` is a plain data attribute — entity escaping is right.
931
+ return ` <TabsTrigger${componentAttr("value", t.value)} data-diff-tab="${escapeAttr(signal)}">${escapeTemplate(t.label)}<Fragment set:html={${escapeExpr(marker)}} /></TabsTrigger>`;
932
+ })
754
933
  .join("\n");
755
934
  const contents = tabItems
756
935
  .map((t) => {
757
936
  const inner = childrenToAstro(t.node, ctx);
758
- return ` <TabsContent value="${escapeAttr(t.value)}">\n${indentStr(inner, 4)}\n </TabsContent>`;
937
+ return ` <TabsContent${componentAttr("value", t.value)}>\n${indentStr(inner, 4)}\n </TabsContent>`;
759
938
  })
760
939
  .join("\n");
761
940
  const classAttr = mergeClassAttr("my-4", node.props?.class);
762
941
  const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
763
- return `<Tabs defaultValue="${escapeAttr(defaultValue)}"${classAttr}${passthrough}>
942
+ // Same encoding as the trigger/content `value` it selects — see above.
943
+ return `<Tabs${componentAttr("defaultValue", defaultValue)}${classAttr}${passthrough}>
764
944
  <TabsList>
765
945
  ${triggers}
766
946
  </TabsList>
@@ -770,8 +950,10 @@ ${contents}
770
950
  function tableToAstro(node, ctx) {
771
951
  ctx.imports.add("table");
772
952
  const classAttr = mergeClassAttr("my-4", node.props?.class);
773
- const passthrough = renderPassthroughAttrs(node.props, ["class"]);
774
- return `<Table${classAttr}${passthrough}>\n${childrenToAstro(node, ctx)}\n</Table>`;
953
+ const caption = node.props?.caption;
954
+ const captionAttr = caption ? ` caption={${escapeExpr(caption)}}` : "";
955
+ const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
956
+ return `<Table${classAttr}${captionAttr}${passthrough}>\n${childrenToAstro(node, ctx)}\n</Table>`;
775
957
  }
776
958
  function diagramToAstro(node, ctx) {
777
959
  ctx.imports.add("diagram");
@@ -781,7 +963,7 @@ function diagramToAstro(node, ctx) {
781
963
  const title = node.props?.title;
782
964
  const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
783
965
  if (title)
784
- attrs.push(`title="${escapeAttr(title)}"`);
966
+ attrs.push(componentAttr("title", title).trimStart());
785
967
  const classAttr = mergeClassAttr("", node.props?.class);
786
968
  const passthrough = renderPassthroughAttrs(node.props, [
787
969
  "lang", "code", "svg", "title", "class",
@@ -794,7 +976,7 @@ function youtubeToAstro(node, ctx) {
794
976
  const title = node.props?.title ?? "";
795
977
  const classAttr = mergeClassAttr("my-4", node.props?.class);
796
978
  const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
797
- return `<YouTube id="${escapeAttr(id)}" title="${escapeAttr(title)}"${classAttr}${passthrough} />`;
979
+ return `<YouTube${componentAttr("id", id)}${componentAttr("title", title)}${classAttr}${passthrough} />`;
798
980
  }
799
981
  function mathBlockToAstro(node, ctx) {
800
982
  ctx.imports.add("math-block");
@@ -853,7 +1035,11 @@ function apiSymbolToAstro(node, ctx) {
853
1035
  const signature = node.props?.signature;
854
1036
  const bases = node.props?.bases || [];
855
1037
  const level = node.type === "api-class" ? 2 : 3;
856
- const attrs = [`name="${escapeAttr(name)}"`, `kind="${escapeAttr(kind)}"`, `level={${level}}`];
1038
+ const attrs = [
1039
+ componentAttr("name", name).trimStart(),
1040
+ componentAttr("kind", kind).trimStart(),
1041
+ `level={${level}}`,
1042
+ ];
857
1043
  if (signature)
858
1044
  attrs.push(`signature={${escapeExpr(signature)}}`);
859
1045
  if (bases.length > 0)
@@ -975,7 +1161,8 @@ function endpointToAstro(node, ctx) {
975
1161
  const codeBody = codePanels.length > 0
976
1162
  ? `\n${indentStr(codePanels.join("\n"), 4)}\n `
977
1163
  : "";
978
- return (`<ApiLayout>\n` +
1164
+ const layoutAttrs = ctx.embeddedEndpoints ? ' variant="embedded"' : "";
1165
+ return (`<ApiLayout${layoutAttrs}>\n` +
979
1166
  ` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
980
1167
  ` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
981
1168
  `</ApiLayout>`);
@@ -1007,8 +1194,7 @@ function cardsToAstro(node, ctx) {
1007
1194
  // top of the page.
1008
1195
  let iconHtml = "";
1009
1196
  if (icon) {
1010
- ctx.imports.add("icon");
1011
- iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1197
+ iconHtml = cardIconHtml(icon, title, ctx);
1012
1198
  }
1013
1199
  const titleHtml = title ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>` : "";
1014
1200
  const desc = inner
@@ -1089,6 +1275,45 @@ function standaloneCardToAstro(node, ctx) {
1089
1275
  * If the parser kept rich body children instead of folding into description,
1090
1276
  * we fall back to the standalone-card path which can hold arbitrary content.
1091
1277
  */
1278
+ /**
1279
+ * `link-button` — a call-to-action link styled as a button.
1280
+ *
1281
+ * There was no case for this at all, so it fell through to rendering the
1282
+ * node's CHILDREN: `<p>Get started</p>`. The label survived, the href did not,
1283
+ * and nothing warned — the reader saw a stray sentence where the page's
1284
+ * primary call to action should be. Every importer that produces one was
1285
+ * affected; `Button.astro` has taken an `href` all along.
1286
+ */
1287
+ /**
1288
+ * A card icon is either a registry NAME or a path to an image file.
1289
+ *
1290
+ * `<Icon name>` resolves names and renders NOTHING when the name does not
1291
+ * resolve — so a path handed to it disappeared silently, which is how every
1292
+ * "Related products" card lost its logo without anyone noticing.
1293
+ */
1294
+ function cardIconHtml(icon, title, ctx) {
1295
+ if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon)) {
1296
+ return `<img src="${escapeAttr(icon)}" alt="" class="size-5 mb-2" loading="lazy" />`;
1297
+ }
1298
+ ctx.imports.add("icon");
1299
+ return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1300
+ }
1301
+ function linkButtonToAstro(node, ctx) {
1302
+ ctx.imports.add("link-button");
1303
+ const href = node.props?.href ?? "";
1304
+ const variant = node.props?.variant;
1305
+ const classAttr = mergeClassAttr("", node.props?.class);
1306
+ const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
1307
+ // A button's label is phrasing content, so a lone paragraph child is
1308
+ // unwrapped — `<Button><p>Get started</p></Button>` is invalid markup and
1309
+ // renders with the paragraph's block spacing inside the button.
1310
+ const kids = node.children ?? [];
1311
+ const soleParagraph = kids.length === 1 && kids[0].type === "paragraph" ? kids[0] : undefined;
1312
+ const label = soleParagraph
1313
+ ? leafContent(soleParagraph, ctx)
1314
+ : leafContent(node, ctx) || childrenToAstro(node, ctx);
1315
+ return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
1316
+ }
1092
1317
  function linkCardToAstro(node, ctx) {
1093
1318
  const hasRichChildren = (node.children ?? []).length > 0;
1094
1319
  if (hasRichChildren) {
@@ -1098,9 +1323,9 @@ function linkCardToAstro(node, ctx) {
1098
1323
  const title = node.props?.title ?? "";
1099
1324
  const description = node.props?.description;
1100
1325
  const href = node.props?.href ?? "";
1101
- const titleAttr = ` title="${escapeAttr(title)}"`;
1102
- const hrefAttr = ` href="${escapeAttr(href)}"`;
1103
- const descAttr = description ? ` description="${escapeAttr(description)}"` : "";
1326
+ const titleAttr = componentAttr("title", title);
1327
+ const hrefAttr = componentAttr("href", href);
1328
+ const descAttr = description ? componentAttr("description", description) : "";
1104
1329
  const classAttr = mergeClassAttr("", node.props?.class);
1105
1330
  const passthrough = renderPassthroughAttrs(node.props, [
1106
1331
  "title", "description", "href", "icon", "class",
@@ -1124,9 +1349,9 @@ function accordionToAstro(node, ctx) {
1124
1349
  const defaultValue = node.props?.defaultValue;
1125
1350
  const typeAttr = ` type="${escapeAttr(type)}"`;
1126
1351
  const defaultAttr = defaultValue !== undefined
1127
- ? ` defaultValue=${typeof defaultValue === "string"
1128
- ? `"${escapeAttr(defaultValue)}"`
1129
- : `{${JSON.stringify(defaultValue)}}`}`
1352
+ ? typeof defaultValue === "string"
1353
+ ? componentAttr("defaultValue", defaultValue)
1354
+ : ` defaultValue={${JSON.stringify(defaultValue)}}`
1130
1355
  : "";
1131
1356
  const classAttr = mergeClassAttr("", node.props?.class);
1132
1357
  const passthrough = renderPassthroughAttrs(node.props, [
@@ -1142,7 +1367,7 @@ function accordionItemToAstro(node, ctx, standalone) {
1142
1367
  ctx.imports.add("accordion");
1143
1368
  const value = node.props?.value ?? "item-1";
1144
1369
  const label = node.props?.label ?? node.props?.title ?? "Item";
1145
- const valueAttr = ` value="${escapeAttr(value)}"`;
1370
+ const valueAttr = componentAttr("value", value);
1146
1371
  const classAttr = mergeClassAttr("", node.props?.class);
1147
1372
  const passthrough = renderPassthroughAttrs(node.props, [
1148
1373
  "value", "label", "title", "class",
@@ -1169,9 +1394,9 @@ function avatarToAstro(node, ctx) {
1169
1394
  const src = node.props?.src ?? "";
1170
1395
  const alt = node.props?.alt ?? "";
1171
1396
  const fallback = node.props?.fallback;
1172
- const srcAttr = src ? ` src="${escapeAttr(src)}"` : "";
1173
- const altAttr = ` alt="${escapeAttr(alt)}"`;
1174
- const fbAttr = fallback ? ` fallback="${escapeAttr(fallback)}"` : "";
1397
+ const srcAttr = src ? componentAttr("src", src) : "";
1398
+ const altAttr = componentAttr("alt", alt);
1399
+ const fbAttr = fallback ? componentAttr("fallback", fallback) : "";
1175
1400
  const classAttr = mergeClassAttr("", node.props?.class);
1176
1401
  const passthrough = renderPassthroughAttrs(node.props, [
1177
1402
  "src", "alt", "fallback", "class",
@@ -1192,7 +1417,7 @@ function exampleToAstro(node, ctx) {
1192
1417
  const source = String(node.props?.source ?? "");
1193
1418
  const title = node.props?.title;
1194
1419
  const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
1195
- const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
1420
+ const titleAttr = title ? componentAttr("title", title) : "";
1196
1421
  const fallbackAttr = showFallback ? " showFallback" : "";
1197
1422
  const inner = childrenToAstro(node, ctx);
1198
1423
  return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
@@ -1212,24 +1437,25 @@ function htmlContainerToAstro(node, ctx) {
1212
1437
  }
1213
1438
  // ─── Utilities ────────────────────────────────────────────────────
1214
1439
  /**
1215
- * Render a node's content whether it's attached as flat inline (dogsbay-md
1216
- * parser shape) or as wrapped children (Starlight importer shape). Both
1217
- * shapes supported; inline comes before children if both present.
1440
+ * Render a node's content across all THREE TreeNode shapes: flat `inline`
1441
+ * (dogsbay-md parser), wrapped `children[{prose, inline}]` (Starlight
1442
+ * importer), and a node carrying only `html` (MDX/Starlight/MkDocs). Inline
1443
+ * comes before children when both are present.
1218
1444
  *
1219
1445
  * Used for list-item, table cells, step, dt, dd — types that commonly
1220
1446
  * carry leaf text content directly on the node.
1447
+ *
1448
+ * This function is where serialize-core's `renderLeaf` was extracted FROM, so
1449
+ * delegating to it closes the loop: the wrapper-dedup and html-shape handling
1450
+ * the core grew afterwards (from the Docusaurus dogfooding bug) now flow back
1451
+ * here instead of the two implementations continuing to drift apart.
1221
1452
  */
1222
1453
  function leafContent(node, ctx) {
1223
- const parts = [];
1224
- if (node.inline && node.inline.length > 0) {
1225
- parts.push(inlineToTemplate(node.inline));
1226
- }
1227
- if (node.children && node.children.length > 0) {
1228
- const rendered = childrenToAstro(node, ctx);
1229
- if (rendered)
1230
- parts.push(rendered);
1231
- }
1232
- return parts.join("\n");
1454
+ return renderLeaf(node, {
1455
+ inline: inlineToTemplate,
1456
+ children: () => childrenToAstro(node, ctx),
1457
+ html: (html) => `<Fragment set:html={${escapeExpr(html)}} />`,
1458
+ });
1233
1459
  }
1234
1460
  function childrenToAstro(node, ctx) {
1235
1461
  if (!node.children || node.children.length === 0)
@@ -1269,13 +1495,8 @@ function inlineHasIcon(node) {
1269
1495
  }
1270
1496
  return false;
1271
1497
  }
1272
- function indentStr(text, spaces) {
1273
- const prefix = " ".repeat(spaces);
1274
- return text
1275
- .split("\n")
1276
- .map((line) => (line ? `${prefix}${line}` : line))
1277
- .join("\n");
1278
- }
1498
+ /** Indent, via the core's shared implementation (was a fourth local copy). */
1499
+ const indentStr = indent;
1279
1500
  function escapeHtml(s) {
1280
1501
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1281
1502
  }