@dogsbay/format-astro 0.2.0-beta.115 → 0.2.0-beta.116

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 (62) hide show
  1. package/dist/api-markdown.d.ts +8 -0
  2. package/dist/api-markdown.d.ts.map +1 -0
  3. package/dist/api-markdown.js +41 -0
  4. package/dist/api-markdown.js.map +1 -0
  5. package/dist/blog.d.ts.map +1 -1
  6. package/dist/blog.js +5 -1
  7. package/dist/blog.js.map +1 -1
  8. package/dist/browser.d.ts +14 -0
  9. package/dist/browser.d.ts.map +1 -0
  10. package/dist/browser.js +14 -0
  11. package/dist/browser.js.map +1 -0
  12. package/dist/canonical.d.ts +34 -0
  13. package/dist/canonical.d.ts.map +1 -0
  14. package/dist/canonical.js +107 -0
  15. package/dist/canonical.js.map +1 -0
  16. package/dist/head-metadata.d.ts +26 -0
  17. package/dist/head-metadata.d.ts.map +1 -0
  18. package/dist/head-metadata.js +56 -0
  19. package/dist/head-metadata.js.map +1 -0
  20. package/dist/index.d.ts +4 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/llms-txt.d.ts +3 -3
  25. package/dist/llms-txt.d.ts.map +1 -1
  26. package/dist/llms-txt.js +48 -242
  27. package/dist/llms-txt.js.map +1 -1
  28. package/dist/plugins.d.ts.map +1 -1
  29. package/dist/plugins.js +3 -1
  30. package/dist/plugins.js.map +1 -1
  31. package/dist/project.d.ts +36 -1
  32. package/dist/project.d.ts.map +1 -1
  33. package/dist/project.js +276 -841
  34. package/dist/project.js.map +1 -1
  35. package/dist/redirects.d.ts +22 -0
  36. package/dist/redirects.d.ts.map +1 -1
  37. package/dist/redirects.js +70 -123
  38. package/dist/redirects.js.map +1 -1
  39. package/dist/serialize.d.ts +4 -13
  40. package/dist/serialize.d.ts.map +1 -1
  41. package/dist/serialize.js +304 -102
  42. package/dist/serialize.js.map +1 -1
  43. package/dist/sitemap.d.ts +44 -17
  44. package/dist/sitemap.d.ts.map +1 -1
  45. package/dist/sitemap.js +31 -104
  46. package/dist/sitemap.js.map +1 -1
  47. package/dist/taxonomy.d.ts.map +1 -1
  48. package/dist/taxonomy.js +7 -1
  49. package/dist/taxonomy.js.map +1 -1
  50. package/dist/theme-css.d.ts +26 -0
  51. package/dist/theme-css.d.ts.map +1 -0
  52. package/dist/theme-css.js +642 -0
  53. package/dist/theme-css.js.map +1 -0
  54. package/dist/tone-classes.d.ts +14 -0
  55. package/dist/tone-classes.d.ts.map +1 -0
  56. package/dist/tone-classes.js +30 -0
  57. package/dist/tone-classes.js.map +1 -0
  58. package/dist/write-if-changed.d.ts +21 -0
  59. package/dist/write-if-changed.d.ts.map +1 -0
  60. package/dist/write-if-changed.js +58 -0
  61. package/dist/write-if-changed.js.map +1 -0
  62. package/package.json +13 -5
package/dist/serialize.js CHANGED
@@ -1,34 +1,9 @@
1
- import { walkInline, applyTextFlags, renderLeaf, indent, } from "@dogsbay/serialize-core";
1
+ import { walkInline, applyTextFlags, renderLeaf, indent, isSafeHref, } from "@dogsbay/serialize-core";
2
+ import { parseInline } from "@dogsbay/format-dogsbay-md";
2
3
  import { decorateDiff, hasDiffMarks } from "./diff-decoration.js";
3
- /**
4
- * Tone palette for `:::grid-item{label="..." tone="..."}` cells.
5
- * Each tone is a `bg + text` Tailwind class pair. Used to make grid demos
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.
14
- */
15
- export const TONE_CLASSES = {
16
- // Primary scale (intensity)
17
- "primary": "bg-primary text-primary-foreground",
18
- "primary-strong": "bg-primary/80 text-primary-foreground",
19
- "primary-medium": "bg-primary/60 text-primary-foreground",
20
- "primary-half": "bg-primary/50 text-primary-foreground",
21
- "primary-light": "bg-primary/20 text-primary",
22
- "primary-soft": "bg-primary/10 text-primary",
23
- // Neutral
24
- "muted": "bg-muted text-muted-foreground",
25
- // Named colors (for layout demos with distinct cells)
26
- "blue": "bg-blue-500/15 text-blue-700 dark:text-blue-300",
27
- "amber": "bg-amber-500/15 text-amber-700 dark:text-amber-300",
28
- "green": "bg-green-500/15 text-green-700 dark:text-green-300",
29
- "purple": "bg-purple-500/15 text-purple-700 dark:text-purple-300",
30
- "red": "bg-red-500/15 text-red-700 dark:text-red-300",
31
- };
4
+ import { TONE_CLASSES } from "./tone-classes.js";
5
+ import { apiBlockHtml, apiPropertiesHtml } from "./api-markdown.js";
6
+ export { TONE_CLASSES };
32
7
  const COMPONENT_IMPORTS = {
33
8
  "markdown-example": [
34
9
  'import MarkdownExample from "@ui/markdown-example/MarkdownExample.astro";',
@@ -64,6 +39,9 @@ const COMPONENT_IMPORTS = {
64
39
  ],
65
40
  diagram: ['import Diagram from "@ui/diagram/Diagram.astro";'],
66
41
  youtube: ['import YouTube from "@ui/youtube/YouTube.astro";'],
42
+ badge: ['import Badge from "@ui/badge/Badge.astro";'],
43
+ kbd: ['import Kbd from "@ui/kbd/Kbd.astro";'],
44
+ files: ['import FileTree from "@ui/files/FileTree.astro";'],
67
45
  "math-block": ['import MathKatex from "@ui/math/MathKatex.astro";'],
68
46
  image: ['import BlockImage from "@ui/image/Image.astro";'],
69
47
  "api-class": ['import ApiSymbol from "@ui/api-symbol/ApiSymbol.astro";'],
@@ -121,7 +99,12 @@ export function escapeExpr(s) {
121
99
  // JSON.stringify handles all special characters (quotes, backslashes, newlines, etc.)
122
100
  // and produces a valid JS string literal. This avoids template literal edge cases
123
101
  // where backtick + brace combinations create false expression boundaries.
124
- return JSON.stringify(s);
102
+ //
103
+ // `<` becomes \u003c: Astro parses the template as HTML before the expression is
104
+ // JavaScript, so a `<script>` inside a string (a code block showing one, say) would
105
+ // be taken for a real script tag and its text compiled as JavaScript. Vite's
106
+ // dependency scan then fails to parse it and a production build breaks.
107
+ return JSON.stringify(s).replace(/</g, "\\u003c");
125
108
  }
126
109
  /** Escape for use in an HTML attribute value (double-quoted). */
127
110
  export function escapeAttr(s) {
@@ -140,6 +123,15 @@ export function componentAttr(name, value) {
140
123
  * src, etc.). `id`, `class`, and `style` are first-class; any other
141
124
  * key passes through verbatim.
142
125
  */
126
+ /** `{…}` attributes on a COMPONENT (Badge, Button): `componentAttr`, which Astro does not entity-decode. */
127
+ function renderInlineComponentAttrs(attrs) {
128
+ if (!attrs)
129
+ return "";
130
+ return Object.entries(attrs)
131
+ .filter(([, value]) => value !== undefined)
132
+ .map(([key, value]) => componentAttr(key, String(value)))
133
+ .join("");
134
+ }
143
135
  function renderInlineElementAttrs(attrs) {
144
136
  if (!attrs)
145
137
  return "";
@@ -185,6 +177,7 @@ export function treeToAstro(nodes, options) {
185
177
  mode: options?.mode ?? "template",
186
178
  imageOptimization: options?.imageOptimization ?? false,
187
179
  codeBlockTitle: options?.codeBlockTitle ?? true,
180
+ imageCaptions: options?.imageCaptions ?? false,
188
181
  combinedPrefix: options?.combinedPrefix ?? "",
189
182
  // Mixed prose+endpoint pages render endpoints as embedded blocks;
190
183
  // pages that are nothing but endpoints/headings keep the
@@ -199,9 +192,9 @@ export function treeToAstro(nodes, options) {
199
192
  // (card.props.icon) registers `icon` directly at the call
200
193
  // site; this pre-scan picks up the inline `:icon[name]`
201
194
  // directives the writer can drop into prose.
202
- if (treeContainsInlineIcon(nodes)) {
203
- ctx.imports.add("icon");
204
- }
195
+ // Badges and inline buttons are components too, registered the same way.
196
+ for (const key of inlineComponentsIn(nodes))
197
+ ctx.imports.add(key);
205
198
  const body = nodes.map((n) => nodeToAstro(n, ctx)).filter(Boolean).join("\n");
206
199
  const imports = [];
207
200
  const seen = new Set();
@@ -246,6 +239,10 @@ function nodeToAstroBase(node, ctx) {
246
239
  return codeToAstro(node, ctx);
247
240
  case "callout":
248
241
  return calloutToAstro(node, ctx);
242
+ case "files":
243
+ return filesToAstro(node, ctx);
244
+ case "container":
245
+ return genericContainerToAstro(node, ctx);
249
246
  case "details":
250
247
  return detailsToAstro(node, ctx);
251
248
  case "tabs":
@@ -278,8 +275,10 @@ function nodeToAstroBase(node, ctx) {
278
275
  return `<hr${classAttr}${passthrough} />`;
279
276
  }
280
277
  case "html":
281
- // Raw HTML always uses set:html — it may contain anything
282
- return `<Fragment set:html={${escapeExpr(node.html ?? "")}} />`;
278
+ // Raw HTML always uses set:html — it may contain anything. Callouts inside it
279
+ // (dogsbay-md's HTML table fallback writes `<div role="note" data-callout>`)
280
+ // render as real callouts
281
+ return htmlToAstro(node.html ?? "", ctx);
283
282
  case "table":
284
283
  return tableToAstro(node, ctx);
285
284
  case "thead":
@@ -330,9 +329,20 @@ function nodeToAstroBase(node, ctx) {
330
329
  return exampleToAstro(node, ctx);
331
330
  case "figure": {
332
331
  const caption = node.props?.caption;
332
+ // A figure of one code block: the caption goes in the code block's header bar,
333
+ // which then is the figure's caption
334
+ const only = node.children?.length === 1 ? node.children[0] : undefined;
335
+ if (caption && only?.type === "code") {
336
+ return codeToAstro(only, ctx, {
337
+ caption: captionToAstro(caption, ctx),
338
+ // The figure's attributes, except those the code block has itself (an id twice is invalid)
339
+ attrs: renderPassthroughAttrs(node.props, ["caption", "class", ...Object.keys(only.props ?? {})]),
340
+ classes: ["my-4", node.props?.class].filter(Boolean).join(" "),
341
+ });
342
+ }
333
343
  const inner = childrenToAstro(node, ctx);
334
344
  const body = caption
335
- ? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${escapeTemplate(caption)}</figcaption>`
345
+ ? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${captionToAstro(caption, ctx)}</figcaption>`
336
346
  : inner;
337
347
  return wrapBlock("figure", "my-4", node, body, ["caption"]);
338
348
  }
@@ -468,15 +478,37 @@ function templateInlineEmitters() {
468
478
  strike: (s) => `<s>${s}</s>`,
469
479
  }),
470
480
  link: (node, ctx) => {
481
+ // A link node an importer built directly never passed markdown-it's
482
+ // scheme check: a `javascript:` href keeps its text and loses the link.
483
+ if (!isSafeHref(node.href)) {
484
+ ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
485
+ return ctx.recurse(node.children);
486
+ }
471
487
  const titleAttr = node.title ? componentAttr("title", node.title) : "";
472
488
  const extraAttrs = renderInlineElementAttrs(node.attrs);
489
+ // An inline `:button`: the Button component, small, keeping its variant ("primary" is the filled default).
490
+ if (node.button) {
491
+ const variant = buttonVariant(node.button.variant);
492
+ return `<Button${componentAttr("href", node.href)}${componentAttr("variant", variant)} size="sm"${titleAttr}${renderInlineComponentAttrs(node.attrs)}>${ctx.recurse(node.children)}</Button>`;
493
+ }
473
494
  return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
474
495
  },
475
- image: (node) => {
496
+ badge: (node, ctx) => `<Badge${componentAttr("variant", node.variant ?? "default")}${renderInlineComponentAttrs(node.attrs)}>${ctx.escape(node.text)}</Badge>`,
497
+ image: (node, ctx) => {
498
+ if (!isSafeHref(node.src, "image")) {
499
+ ctx.report("image", `unsafe image source dropped (${node.src.slice(0, 40)})`);
500
+ return ctx.escape(node.alt ?? "");
501
+ }
476
502
  const extraAttrs = renderInlineElementAttrs(node.attrs);
477
503
  return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy"${extraAttrs} />`;
478
504
  },
479
- code: (node, ctx) => `<code>${ctx.escape(node.text)}</code>`,
505
+ // Code inherits the emphasis around it (`*using the \`dita\` command*`): the parser
506
+ // flags the code span, and dropping the flags split the italics around the code
507
+ code: (node, ctx) => applyTextFlags(`<code>${ctx.escape(node.text)}</code>`, node, {
508
+ bold: (s) => `<strong>${s}</strong>`,
509
+ italic: (s) => `<em>${s}</em>`,
510
+ strike: (s) => `<s>${s}</s>`,
511
+ }),
480
512
  // The arm that did not exist — `:highlight[…]` / `:mark[…]` rendered as
481
513
  // nothing at all. The class list mirrors the canonical rendering in
482
514
  // `packages/ui/src/content-renderer/InlineRenderer.astro`, so a highlight
@@ -486,7 +518,8 @@ function templateInlineEmitters() {
486
518
  // pages, where nothing declares `color-scheme`.
487
519
  highlight: (node, ctx) => `<mark class="${HIGHLIGHT_CLASS}">${ctx.recurse(node.children)}</mark>`,
488
520
  "footnote-ref": (node, ctx) => `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${ctx.escape(node.label)}]</a></sup>`,
489
- kbd: (node, ctx) => node.keys.map((k) => `<kbd>${ctx.escape(k)}</kbd>`).join("+"),
521
+ // The Kbd component (keys styled as keycaps); plain <kbd> had no style beyond a monospace font.
522
+ kbd: (node, ctx) => node.keys.map((k) => `<Kbd>${ctx.escape(k)}</Kbd>`).join("+"),
490
523
  math: (node, ctx) => `<code class="math-inline">${ctx.escape(node.latex)}</code>`,
491
524
  icon: (node) => {
492
525
  // Inline icon — emits the platform `<Icon>` component which
@@ -515,10 +548,18 @@ function htmlModeInlineEmitters() {
515
548
  // EXPRESSION, inside a string that becomes a `set:html` value. Astro never
516
549
  // parses it as markup, so the reader saw the braces and quotes.
517
550
  link: (node, ctx) => {
551
+ if (!isSafeHref(node.href)) {
552
+ ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
553
+ return ctx.recurse(node.children);
554
+ }
518
555
  const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
519
556
  const extraAttrs = renderInlineElementAttrs(node.attrs);
520
- return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
557
+ // No components in HTML mode: a marked link (and a marked span for a badge below).
558
+ const button = node.button ? ` data-button data-variant="${escapeAttr(buttonVariant(node.button.variant))}"` : "";
559
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}${button}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
521
560
  },
561
+ kbd: (node, ctx) => node.keys.map((k) => `<kbd>${ctx.escape(k)}</kbd>`).join("+"),
562
+ badge: (node, ctx) => `<span data-badge data-variant="${escapeAttr(node.variant ?? "default")}"${renderInlineElementAttrs(node.attrs)}>${ctx.escape(node.text)}</span>`,
522
563
  text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
523
564
  bold: (s) => `<strong>${s}</strong>`,
524
565
  italic: (s) => `<em>${s}</em>`,
@@ -552,6 +593,8 @@ function renderInline(nodes, ctx) {
552
593
  const html = inlineToHtml(nodes);
553
594
  return `<Fragment set:html={${escapeExpr(html)}} />`;
554
595
  }
596
+ // Inline parsed from a prop (a caption) is not in the page-level pre-scan; its components are imported here.
597
+ inlineComponentsIn([{ type: "paragraph", inline: nodes }], ctx.imports);
555
598
  return inlineToTemplate(nodes);
556
599
  }
557
600
  /** Render an HTML string in the current mode. */
@@ -625,7 +668,9 @@ function paragraphToAstro(node, ctx) {
625
668
  // Author-supplied {.class #id ...} on the image itself
626
669
  // (Phase 1.5 of plans/inline-attrs.md).
627
670
  const imageAttrs = renderInlineElementAttrs(img.attrs);
628
- return `<BlockImage${componentAttr("src", img.src)}${componentAttr("alt", img.alt ?? "")}${imageAttrs}${passthrough}${imgDataAttr} />`;
671
+ // `content.imageCaptions: title`: the figure form of BlockImage, captioned by the title (as dogsbay-ssg).
672
+ const figure = ctx.imageCaptions && img.title ? ` block${componentAttr("title", img.title)}` : "";
673
+ return `<BlockImage${componentAttr("src", img.src)}${componentAttr("alt", img.alt ?? "")}${figure}${imageAttrs}${passthrough}${imgDataAttr} />`;
629
674
  }
630
675
  }
631
676
  const classAttr = mergeClassAttr("", node.props?.class);
@@ -652,7 +697,7 @@ function paragraphToAstro(node, ctx) {
652
697
  }).join("");
653
698
  return `<p${classAttr}${passthrough} set:html={${escapeExpr(html)}} />`;
654
699
  }
655
- function codeToAstro(node, ctx) {
700
+ function codeToAstro(node, ctx, figure) {
656
701
  // Code content may live on props.code (Starlight convention) or node.html
657
702
  // (dogsbay-md convention). Same for lang — Starlight uses props.lang,
658
703
  // dogsbay-md uses props.language.
@@ -663,13 +708,18 @@ function codeToAstro(node, ctx) {
663
708
  ?? node.html
664
709
  ?? "";
665
710
  const title = node.props?.title;
666
- const highlights = node.props?.highlights;
711
+ // The fence's documented `mark`, `ins` and `del` are CodeRich's highlights, diffAdd and diffRemove.
712
+ const lineSpec = (...values) => values.filter((v) => typeof v === "string" || typeof v === "number").map(String).filter(Boolean).join(",") || undefined;
713
+ const highlights = lineSpec(node.props?.highlights, node.props?.mark);
667
714
  const lineNumbers = node.props?.lineNumbers;
668
715
  const copyText = node.props?.copyText;
669
- const diffAdd = node.props?.diffAdd;
670
- const diffRemove = node.props?.diffRemove;
716
+ const diffAdd = lineSpec(node.props?.diffAdd, node.props?.ins);
717
+ const diffRemove = lineSpec(node.props?.diffRemove, node.props?.del);
671
718
  const isRich = lineNumbers || highlights || code.includes("[!code") || Boolean(diffAdd || diffRemove);
672
- const classAttr = mergeClassAttr("", node.props?.class);
719
+ const classAttr = mergeClassAttr(figure?.classes ?? "", node.props?.class);
720
+ const figureAttrs = figure?.attrs ?? "";
721
+ // The component with its caption slot filled, or self-closing
722
+ const close = (tag, open) => figure ? `${open}>\n<Fragment slot="caption">${figure.caption}</Fragment>\n</${tag}>` : `${open} />`;
673
723
  const consumed = [
674
724
  "lang", "language", "code", "title", "highlights", "lineNumbers",
675
725
  "class", "ins", "del", "mark", "collapse", "copyText", "diffAdd", "diffRemove",
@@ -690,7 +740,7 @@ function codeToAstro(node, ctx) {
690
740
  attrs.push(`diffAdd="${escapeAttr(diffAdd)}"`);
691
741
  if (diffRemove)
692
742
  attrs.push(`diffRemove="${escapeAttr(diffRemove)}"`);
693
- return `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough} />`;
743
+ return close("CodeRich", `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
694
744
  }
695
745
  ctx.imports.add("code");
696
746
  const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
@@ -699,7 +749,7 @@ function codeToAstro(node, ctx) {
699
749
  if (ctx.codeBlockTitle !== true) {
700
750
  attrs.push(`showTitle="${ctx.codeBlockTitle}"`);
701
751
  }
702
- return `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough} />`;
752
+ return close("CodeBlock", `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
703
753
  }
704
754
  // Map GitHub alert names (and other aliases) to Alert component variants.
705
755
  // The Alert variant palette follows MkDocs Material; not every GitHub name
@@ -711,17 +761,71 @@ const CALLOUT_VARIANT_MAP = {
711
761
  hint: "tip",
712
762
  attention: "warning",
713
763
  };
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.
764
+ // Default title shown when a callout has no explicit one, keyed by the type
765
+ // the author wrote: aliases share a look but keep their own name (`:::info` is
766
+ // "Info", not the "Important" of the variant it shares). The same labels as
767
+ // dogsbay-ssg (src/render/icons.ts CALLOUTS). Anything else: the type, capitalised.
716
768
  const CALLOUT_LABELS = {
717
769
  note: "Note",
770
+ info: "Info",
718
771
  tip: "Tip",
719
- info: "Important",
720
- warning: "Warning",
721
- danger: "Caution",
722
- failure: "Error",
772
+ hint: "Hint",
723
773
  success: "Success",
774
+ important: "Important",
775
+ warning: "Warning",
776
+ attention: "Attention",
777
+ caution: "Caution",
778
+ danger: "Danger",
779
+ error: "Error",
780
+ failure: "Failure",
781
+ question: "Question",
782
+ bug: "Bug",
783
+ example: "Example",
784
+ abstract: "Abstract",
785
+ quote: "Quote",
724
786
  };
787
+ const CALLOUT_OPEN = /<div role="note" data-callout="([^"]*)"(?: data-title="([^"]*)")?>/g;
788
+ /**
789
+ * Raw HTML as set:html fragments, with each marked callout div rendered by
790
+ * calloutToAstro. The fragments around a callout may hold unbalanced tags
791
+ * (`<table><tr><td>` before, `</td></tr></table>` after); set:html writes
792
+ * them verbatim, so the page's HTML is whole again.
793
+ */
794
+ function htmlToAstro(html, ctx) {
795
+ const raw = (s) => (s ? `<Fragment set:html={${escapeExpr(s)}} />` : "");
796
+ const out = [];
797
+ let rest = html;
798
+ for (;;) {
799
+ CALLOUT_OPEN.lastIndex = 0;
800
+ const open = CALLOUT_OPEN.exec(rest);
801
+ if (!open)
802
+ break;
803
+ // The callout ends at its matching </div>
804
+ const divs = /<div\b|<\/div>/g;
805
+ divs.lastIndex = open.index + open[0].length;
806
+ let depth = 1;
807
+ let end = -1;
808
+ for (let m = divs.exec(rest); m; m = divs.exec(rest)) {
809
+ depth += m[0] === "</div>" ? -1 : 1;
810
+ if (depth === 0) {
811
+ end = m.index;
812
+ break;
813
+ }
814
+ }
815
+ if (end < 0)
816
+ break;
817
+ const unescape = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
818
+ out.push(raw(rest.slice(0, open.index)));
819
+ out.push(calloutToAstro({
820
+ type: "callout",
821
+ props: { type: unescape(open[1]), ...(open[2] !== undefined ? { title: unescape(open[2]) } : {}) },
822
+ children: [{ type: "html", html: rest.slice(open.index + open[0].length, end) }],
823
+ }, ctx));
824
+ rest = rest.slice(end + "</div>".length);
825
+ }
826
+ out.push(raw(rest));
827
+ return out.filter(Boolean).join("\n") || raw(html);
828
+ }
725
829
  function calloutToAstro(node, ctx) {
726
830
  ctx.imports.add("callout");
727
831
  // Callout variant may live on props.variant (Starlight / MkDocs importers)
@@ -735,11 +839,13 @@ function calloutToAstro(node, ctx) {
735
839
  // admonitions and GitHub-style alerts both show the type next to the icon,
736
840
  // and an empty title leaves the icon standing alone. Issue #004.
737
841
  const explicitTitle = node.props?.title;
842
+ // The default title names the authored type, not the variant it maps to.
738
843
  const title = explicitTitle && explicitTitle.length > 0
739
844
  ? explicitTitle
740
- : CALLOUT_LABELS[variant] ?? variant.charAt(0).toUpperCase() + variant.slice(1);
845
+ : CALLOUT_LABELS[rawVariant] ?? rawVariant.charAt(0).toUpperCase() + rawVariant.slice(1);
741
846
  const icon = node.props?.icon;
742
- const iconAttr = icon ? ` icon="${escapeAttr(icon)}"` : "";
847
+ // The authored type (`hint`, `caution`) beside the variant it shares, as dogsbay-ssg writes it.
848
+ const iconAttr = (icon ? ` icon="${escapeAttr(icon)}"` : "") + (node.props?.["data-type"] === undefined ? ` data-type="${escapeAttr(rawVariant)}"` : "");
743
849
  // User classes and passthrough attributes (id, data-*, aria-*, style)
744
850
  const classAttr = mergeClassAttr("my-4", node.props?.class);
745
851
  const passthrough = renderPassthroughAttrs(node.props, [
@@ -853,9 +959,47 @@ function wrapBlock(tag, defaultClass, node, content, consumed = []) {
853
959
  return `<${tag}${classAttr}${passthrough}>${sep}${content}${sep}</${tag}>`;
854
960
  void needsNewline;
855
961
  }
962
+ /**
963
+ * `:::files` around a nested bullet list: FileTree, whose markup (files/markup.ts) dogsbay-ssg
964
+ * shares. An entry is a folder when it has entries or its name ends in "/".
965
+ */
966
+ function filesToAstro(node, ctx) {
967
+ ctx.imports.add("files");
968
+ const isList = (n) => n.type === "unordered-list" || n.type === "ordered-list";
969
+ const items = (list) => (list?.children ?? [])
970
+ .filter((li) => li.type === "list-item")
971
+ .map((li) => {
972
+ const inline = li.inline ?? (li.children ?? []).find((c) => c.type === "paragraph")?.inline ?? [];
973
+ const sub = (li.children ?? []).find(isList);
974
+ const text = inline.map((n) => ("text" in n && typeof n.text === "string" ? n.text : "")).join("");
975
+ return { label: inlineToHtml(inline), folder: !!sub || /\/\s*$/.test(text), children: items(sub) };
976
+ });
977
+ const classAttr = mergeClassAttr("", node.props?.class);
978
+ // Each list is a tree; anything else in the block (an intro, a note) renders as it is.
979
+ // `<` is escaped in the JSON: a label may hold inline HTML, and a literal `</script>` in an
980
+ // expression breaks Vite's dependency scan (see escapeExpr).
981
+ return (node.children ?? [])
982
+ .map((child) => isList(child)
983
+ ? `<FileTree items={${JSON.stringify(items(child)).replace(/</g, "\\u003c")}}${classAttr} />`
984
+ : nodeToAstro(child, ctx))
985
+ .filter(Boolean)
986
+ .join("\n");
987
+ }
988
+ /**
989
+ * A `:::name` block with a name no renderer knows (a generic container): its content in a
990
+ * marked <div>, the title (text after the name) first.
991
+ */
992
+ function genericContainerToAstro(node, ctx) {
993
+ const name = typeof node.meta?.name === "string" ? node.meta.name : "container";
994
+ const title = typeof node.meta?.title === "string" ? `<p class="font-semibold">${escapeTemplate(node.meta.title)}</p>\n` : "";
995
+ const classAttr = mergeClassAttr("", node.props?.class);
996
+ const passthrough = renderPassthroughAttrs(node.props, ["class"]);
997
+ return `<div data-directive="${escapeAttr(name)}"${classAttr}${passthrough}>\n${title}${childrenToAstro(node, ctx)}\n</div>`;
998
+ }
856
999
  function detailsToAstro(node, ctx) {
857
1000
  ctx.imports.add("details");
858
- const variant = node.props?.variant ?? "note";
1001
+ // `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
1002
+ const variant = node.props?.variant ?? node.props?.type ?? "note";
859
1003
  const title = node.props?.title ?? "Details";
860
1004
  const open = node.props?.open;
861
1005
  const icon = node.props?.icon;
@@ -867,7 +1011,7 @@ function detailsToAstro(node, ctx) {
867
1011
  attrs.push(`icon="${escapeAttr(icon)}"`);
868
1012
  const classAttr = mergeClassAttr("my-4", node.props?.class);
869
1013
  const passthrough = renderPassthroughAttrs(node.props, [
870
- "variant", "title", "open", "icon", "class", "summary",
1014
+ "variant", "type", "title", "open", "icon", "class", "summary",
871
1015
  ]);
872
1016
  return `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>
873
1017
  ${indentStr(inner, 2)}
@@ -939,21 +1083,44 @@ function tabsToAstro(node, ctx) {
939
1083
  .join("\n");
940
1084
  const classAttr = mergeClassAttr("my-4", node.props?.class);
941
1085
  const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
1086
+ // Groups with the same key switch together (db-tabs). Written `syncKey`, `sync-key` or `group`, as dogsbay-ssg reads it.
1087
+ const syncKey = [node.props?.syncKey, node.props?.["sync-key"], node.props?.group].find((v) => typeof v === "string" && v !== "");
1088
+ const syncAttr = syncKey ? componentAttr("syncKey", syncKey) : "";
942
1089
  // Same encoding as the trigger/content `value` it selects — see above.
943
- return `<Tabs${componentAttr("defaultValue", defaultValue)}${classAttr}${passthrough}>
1090
+ return `<Tabs${componentAttr("defaultValue", defaultValue)}${syncAttr}${classAttr}${passthrough}>
944
1091
  <TabsList>
945
1092
  ${triggers}
946
1093
  </TabsList>
947
1094
  ${contents}
948
1095
  </Tabs>`;
949
1096
  }
1097
+ /**
1098
+ * A caption is inline Markdown. Plain text renders exactly as before;
1099
+ * formatting (`Sample \`<plugin>\` file`) renders as inline markup.
1100
+ */
1101
+ function captionInline(caption) {
1102
+ return parseInline(caption);
1103
+ }
1104
+ const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
1105
+ function captionToAstro(caption, ctx) {
1106
+ const nodes = captionInline(caption);
1107
+ return isPlainCaption(nodes)
1108
+ ? escapeTemplate(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))
1109
+ : renderInline(nodes, ctx);
1110
+ }
950
1111
  function tableToAstro(node, ctx) {
951
1112
  ctx.imports.add("table");
952
1113
  const classAttr = mergeClassAttr("my-4", node.props?.class);
953
1114
  const caption = node.props?.caption;
954
- const captionAttr = caption ? ` caption={${escapeExpr(caption)}}` : "";
1115
+ const nodes = caption ? captionInline(caption) : [];
1116
+ const plain = isPlainCaption(nodes);
1117
+ // A plain caption stays the `caption` prop; a formatted one fills the table's caption slot
1118
+ const captionAttr = caption && plain
1119
+ ? ` caption={${escapeExpr(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))}}`
1120
+ : "";
1121
+ const captionSlot = caption && !plain ? `<Fragment slot="caption">${renderInline(nodes, ctx)}</Fragment>\n` : "";
955
1122
  const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
956
- return `<Table${classAttr}${captionAttr}${passthrough}>\n${childrenToAstro(node, ctx)}\n</Table>`;
1123
+ return `<Table${classAttr}${captionAttr}${passthrough}>\n${captionSlot}${childrenToAstro(node, ctx)}\n</Table>`;
957
1124
  }
958
1125
  function diagramToAstro(node, ctx) {
959
1126
  ctx.imports.add("diagram");
@@ -1019,6 +1186,11 @@ function footnoteListToAstro(node, ctx) {
1019
1186
  function footnoteDefToAstro(node, ctx) {
1020
1187
  const id = node.props?.id ?? "";
1021
1188
  const backrefId = node.props?.backrefId ?? "";
1189
+ // Block content (a list, code): the blocks, with the backref after them
1190
+ if (node.children?.length) {
1191
+ const backref = backrefId ? `\n<a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
1192
+ return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">\n${childrenToAstro(node, ctx)}${backref}\n</li>`;
1193
+ }
1022
1194
  if (ctx.mode === "template" && node.inline) {
1023
1195
  const content = inlineToTemplate(node.inline);
1024
1196
  const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
@@ -1088,11 +1260,17 @@ function endpointToAstro(node, ctx) {
1088
1260
  const path = props.path ?? "";
1089
1261
  const baseUrl = props.baseUrl ?? "";
1090
1262
  const summary = props.summary;
1091
- const description = props.description;
1263
+ // Descriptions are CommonMark, rendered with raw HTML off: the components
1264
+ // put them in the page with set:html (api-markdown.ts).
1265
+ // (Compared as text first: the card hides a description equal to the
1266
+ // summary, and the rendered HTML never equals it.)
1267
+ const description = typeof props.description === "string" && props.description !== props.summary
1268
+ ? apiBlockHtml(props.description)
1269
+ : undefined;
1092
1270
  const deprecated = props.deprecated === true;
1093
- const parameters = props.parameters ?? [];
1094
- const requestBody = props.requestBody ?? [];
1095
- const responses = props.responses ?? [];
1271
+ const parameters = apiPropertiesHtml(props.parameters) ?? [];
1272
+ const requestBody = apiPropertiesHtml(props.requestBody) ?? [];
1273
+ const responses = (props.responses ?? []).map((r) => r && Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r);
1096
1274
  const codeSamples = props.codeSamples ?? [];
1097
1275
  const requestBodyExample = props.requestBodyExample;
1098
1276
  const cardAttrs = [
@@ -1180,13 +1358,16 @@ function cardsToAstro(node, ctx) {
1180
1358
  if (cardNodes.length > 0) {
1181
1359
  const cards = cardNodes.map((card) => {
1182
1360
  const title = card.props?.title ?? "";
1183
- const href = card.props?.href ?? "";
1361
+ const href = safeHref(card.props?.href);
1184
1362
  const description = card.props?.description;
1185
1363
  const icon = card.props?.icon;
1186
1364
  // Description sources: inline content, children, or props.description.
1187
- const inner = childrenToAstro(card, ctx)
1365
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1366
+ const blocks = childrenToAstro(card, ctx);
1367
+ const inner = blocks
1188
1368
  || (card.inline ? inlineToTemplate(card.inline) : "")
1189
1369
  || (description ? escapeTemplate(description) : "");
1370
+ const descTag = blocks ? "div" : "p";
1190
1371
  // Icon renders inline before the title — picks up Lucide
1191
1372
  // SVG via the `icon` import. Resolved at build time; nothing
1192
1373
  // emitted when the name doesn't resolve (silent fallback).
@@ -1197,9 +1378,13 @@ function cardsToAstro(node, ctx) {
1197
1378
  iconHtml = cardIconHtml(icon, title, ctx);
1198
1379
  }
1199
1380
  const titleHtml = title ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>` : "";
1200
- const desc = inner
1201
- ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
1381
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1382
+ const lead = blocks && description
1383
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1202
1384
  : "";
1385
+ const desc = lead + (inner
1386
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1387
+ : "");
1203
1388
  const cardDefaultClass = href
1204
1389
  ? "p-4 hover:border-foreground/20 transition-colors"
1205
1390
  : "p-4";
@@ -1237,12 +1422,15 @@ function cardsToAstro(node, ctx) {
1237
1422
  function standaloneCardToAstro(node, ctx) {
1238
1423
  ctx.imports.add("card");
1239
1424
  const title = node.props?.title ?? "";
1240
- const href = node.props?.href ?? "";
1425
+ const href = safeHref(node.props?.href);
1241
1426
  const description = node.props?.description;
1242
1427
  const icon = node.props?.icon;
1243
- const inner = childrenToAstro(node, ctx)
1428
+ // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1429
+ const blocks = childrenToAstro(node, ctx);
1430
+ const inner = blocks
1244
1431
  || (node.inline ? inlineToTemplate(node.inline) : "")
1245
1432
  || (description ? escapeTemplate(description) : "");
1433
+ const descTag = blocks ? "div" : "p";
1246
1434
  // Icon renders inline before the title (same shape as the
1247
1435
  // cards-list path). Resolved at build time via @dogsbay/icons.
1248
1436
  let iconHtml = "";
@@ -1253,9 +1441,13 @@ function standaloneCardToAstro(node, ctx) {
1253
1441
  const titleHtml = title
1254
1442
  ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>`
1255
1443
  : "";
1256
- const desc = inner
1257
- ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
1444
+ // A description and a body are both shown (the description used to be dropped when a body existed).
1445
+ const lead = blocks && description
1446
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
1258
1447
  : "";
1448
+ const desc = lead + (inner
1449
+ ? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
1450
+ : "");
1259
1451
  const cardDefaultClass = href
1260
1452
  ? "p-4 hover:border-foreground/20 transition-colors"
1261
1453
  : "p-4";
@@ -1298,10 +1490,19 @@ function cardIconHtml(icon, title, ctx) {
1298
1490
  ctx.imports.add("icon");
1299
1491
  return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1300
1492
  }
1493
+ /**
1494
+ * A button's variant as `Button.astro` names it. The spec's `primary` is its
1495
+ * filled `default` style; `Button.astro` has no `primary`, so it rendered
1496
+ * with no fill at all.
1497
+ */
1498
+ function buttonVariant(variant) {
1499
+ return !variant || variant === "primary" ? "default" : variant;
1500
+ }
1301
1501
  function linkButtonToAstro(node, ctx) {
1302
1502
  ctx.imports.add("link-button");
1303
- const href = node.props?.href ?? "";
1304
- const variant = node.props?.variant;
1503
+ const href = safeHref(node.props?.href);
1504
+ const written = node.props?.variant;
1505
+ const variant = written === undefined ? undefined : buttonVariant(written);
1305
1506
  const classAttr = mergeClassAttr("", node.props?.class);
1306
1507
  const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
1307
1508
  // A button's label is phrasing content, so a lone paragraph child is
@@ -1314,6 +1515,11 @@ function linkButtonToAstro(node, ctx) {
1314
1515
  : leafContent(node, ctx) || childrenToAstro(node, ctx);
1315
1516
  return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
1316
1517
  }
1518
+ /** A card or button `href` from an importer, or "" (no link) when its
1519
+ * scheme could run script: these never passed markdown-it's link check. */
1520
+ function safeHref(href) {
1521
+ return typeof href === "string" && isSafeHref(href) ? href : "";
1522
+ }
1317
1523
  function linkCardToAstro(node, ctx) {
1318
1524
  const hasRichChildren = (node.children ?? []).length > 0;
1319
1525
  if (hasRichChildren) {
@@ -1322,15 +1528,17 @@ function linkCardToAstro(node, ctx) {
1322
1528
  ctx.imports.add("link-card");
1323
1529
  const title = node.props?.title ?? "";
1324
1530
  const description = node.props?.description;
1325
- const href = node.props?.href ?? "";
1531
+ const href = safeHref(node.props?.href);
1326
1532
  const titleAttr = componentAttr("title", title);
1327
1533
  const hrefAttr = componentAttr("href", href);
1328
1534
  const descAttr = description ? componentAttr("description", description) : "";
1535
+ const icon = node.props?.icon;
1536
+ const iconAttr = icon ? componentAttr("icon", icon) : "";
1329
1537
  const classAttr = mergeClassAttr("", node.props?.class);
1330
1538
  const passthrough = renderPassthroughAttrs(node.props, [
1331
1539
  "title", "description", "href", "icon", "class",
1332
1540
  ]);
1333
- return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
1541
+ return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${iconAttr}${classAttr}${passthrough} />`;
1334
1542
  }
1335
1543
  /**
1336
1544
  * Render an `accordion` TreeNode and its `accordion-item` children.
@@ -1469,31 +1677,25 @@ function childrenToAstro(node, ctx) {
1469
1677
  * icon usage on cards registers `icon` at the renderer call site;
1470
1678
  * this is the inline-only path. Returns true on first hit.
1471
1679
  */
1472
- function treeContainsInlineIcon(nodes) {
1473
- if (!nodes)
1474
- return false;
1475
- for (const node of nodes) {
1476
- if (node.inline) {
1477
- for (const inl of node.inline) {
1478
- if (inlineHasIcon(inl))
1479
- return true;
1480
- }
1481
- }
1482
- if (treeContainsInlineIcon(node.children))
1483
- return true;
1484
- }
1485
- return false;
1486
- }
1487
- function inlineHasIcon(node) {
1488
- if (node.type === "icon")
1489
- return true;
1490
- if ("children" in node && Array.isArray(node.children)) {
1491
- for (const child of node.children) {
1492
- if (inlineHasIcon(child))
1493
- return true;
1494
- }
1680
+ /** The component imports inline nodes need: `icon`, `badge`, and `link-button` for an inline `:button`. */
1681
+ function inlineComponentsIn(nodes, out = new Set()) {
1682
+ const visit = (node) => {
1683
+ if (node.type === "icon")
1684
+ out.add("icon");
1685
+ else if (node.type === "badge")
1686
+ out.add("badge");
1687
+ else if (node.type === "kbd")
1688
+ out.add("kbd");
1689
+ else if (node.type === "link" && node.button)
1690
+ out.add("link-button");
1691
+ if ("children" in node && Array.isArray(node.children))
1692
+ node.children.forEach(visit);
1693
+ };
1694
+ for (const node of nodes ?? []) {
1695
+ node.inline?.forEach(visit);
1696
+ inlineComponentsIn(node.children, out);
1495
1697
  }
1496
- return false;
1698
+ return out;
1497
1699
  }
1498
1700
  /** Indent, via the core's shared implementation (was a fourth local copy). */
1499
1701
  const indentStr = indent;