@dogsbay/format-astro 0.2.0-beta.127 → 0.2.0-beta.129

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.
@@ -27,8 +27,8 @@ export const ASTRO_CAPABILITIES = [
27
27
  { feature: "diagram", support: "full", rendersAs: "`<Diagram>` (source + pre-rendered SVG)" },
28
28
  { feature: "youtube", support: "full", rendersAs: "`<YouTube>`" },
29
29
  // ── Components ───────────────────────────────────────────
30
- { feature: "callout", support: "full", rendersAs: "`<Alert variant>` with the authored type kept as `data-type`" },
31
- { feature: "details", support: "full", rendersAs: "`<CollapsibleAlert variant title open icon>` (`title`, or its alias `summary`)" },
30
+ { feature: "callout", support: "full", rendersAs: "`<Alert variant icon>` with the authored type kept as `data-type`; `collapsed`/`collapsible` → `<CollapsibleAlert>` (a `<details>`), as render-html; the title is inline Markdown" },
31
+ { feature: "details", support: "full", rendersAs: "`<CollapsibleAlert variant title open icon>` (`title`, or its alias `summary`, inline Markdown in a `title` slot)" },
32
32
  { feature: "details-sheet", support: "full", rendersAs: "`<DetailsSheet>` around the CollapsibleAlert: `<db-details-sheet>` opens it as a right-hand native `<dialog>`; without the script it stays a collapsible block" },
33
33
  { feature: "theme", support: "full", rendersAs: "a `<div class=\"dark …\" data-theme>`: the theme re-declares its tokens on `.dark`, so the subtree turns dark" },
34
34
  { feature: "tabs-variant", support: "full", rendersAs: "TabsList/TabsTrigger given the variant's classes (TAB_VARIANTS), merged over their base" },
package/dist/serialize.js CHANGED
@@ -176,6 +176,7 @@ export function escapeTemplate(s) {
176
176
  export function treeToAstro(nodes, options) {
177
177
  const ctx = {
178
178
  imports: new Set(),
179
+ diagramIds: new Map(),
179
180
  indent: 0,
180
181
  scripts: new Set(),
181
182
  mode: options?.mode ?? "template",
@@ -495,6 +496,9 @@ function templateInlineEmitters() {
495
496
  strike: (s) => `<s>${s}</s>`,
496
497
  }),
497
498
  link: (node, ctx) => {
499
+ // Inside a whole-card link, a link is its text (wholeCardLink)
500
+ if (cardLinkDepth > 0)
501
+ return ctx.recurse(node.children);
498
502
  // A link node an importer built directly never passed markdown-it's
499
503
  // scheme check: a `javascript:` href keeps its text and loses the link.
500
504
  if (!isSafeHref(node.href)) {
@@ -565,6 +569,8 @@ function htmlModeInlineEmitters() {
565
569
  // EXPRESSION, inside a string that becomes a `set:html` value. Astro never
566
570
  // parses it as markup, so the reader saw the braces and quotes.
567
571
  link: (node, ctx) => {
572
+ if (cardLinkDepth > 0)
573
+ return ctx.recurse(node.children);
568
574
  if (!isSafeHref(node.href)) {
569
575
  ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
570
576
  return ctx.recurse(node.children);
@@ -869,13 +875,54 @@ function calloutToAstro(node, ctx) {
869
875
  "type", "variant", "title", "icon", "class",
870
876
  ]);
871
877
  const inner = childrenToAstro(node, ctx);
878
+ // An authored title is inline Markdown (`Before \`harbor init\``), as render-html reads it (MD-002);
879
+ // the default label is text
880
+ const titleNodes = explicitTitle && explicitTitle.length > 0 ? titleInline(title) : [{ type: "text", text: title }];
881
+ const plainTitle = isPlainCaption(titleNodes);
882
+ // `collapsed` / `collapsible`: a disclosure, as render-html draws it (MD-001)
883
+ if (isCollapsibleCallout(node.props)) {
884
+ ctx.imports.add("details");
885
+ const attrs = [`variant="${escapeAttr(collapsibleVariant(variant))}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
886
+ if (calloutStartsOpen(node.props))
887
+ attrs.push("open");
888
+ const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
889
+ const collapsiblePassthrough = renderPassthroughAttrs(node.props, ["type", "variant", "title", "icon", "class", "collapsed", "collapsible", "open"]);
890
+ return `<CollapsibleAlert ${attrs.join(" ")}${iconAttr}${classAttr}${collapsiblePassthrough}>${slot}
891
+ ${indentStr(inner, 2)}
892
+ </CollapsibleAlert>`;
893
+ }
872
894
  return `<Alert variant="${escapeAttr(variant)}"${iconAttr}${classAttr}${passthrough}>
873
- <AlertTitle>${escapeTemplate(title)}</AlertTitle>
895
+ <AlertTitle>${plainTitle ? escapeTemplate(captionText(titleNodes)) : renderInline(titleNodes, ctx)}</AlertTitle>
874
896
  <AlertDescription>
875
897
  ${indentStr(inner, 4)}
876
898
  </AlertDescription>
877
899
  </Alert>`;
878
900
  }
901
+ /**
902
+ * A presence flag: `open`, `open=""`, `open="true"` and `true` are on; absent, `false` and `"false"` are
903
+ * off. A bare `{open}` parses as "" (falsy), and "false" is truthy, so a plain truth test got both wrong
904
+ * (MD-003). render-html reads flags with the same rule (`flagOn`).
905
+ */
906
+ function flagOn(v) {
907
+ return v !== undefined && v !== null && v !== false && v !== "false";
908
+ }
909
+ /** CollapsibleAlert's variants; `plain` is chrome (no tint, icon or role). */
910
+ const COLLAPSIBLE_VARIANTS = new Set(["plain", "destructive", "note", "abstract", "info", "tip", "success", "question", "warning", "failure", "danger", "bug", "example", "quote"]);
911
+ /** A disclosure's variant: lower-cased, aliases mapped, anything CollapsibleAlert does not draw → note. */
912
+ function collapsibleVariant(raw) {
913
+ const v = raw.toLowerCase();
914
+ const mapped = CALLOUT_VARIANT_MAP[v] ?? v;
915
+ return COLLAPSIBLE_VARIANTS.has(mapped) ? mapped : "note";
916
+ }
917
+ /** A callout with `collapsible` on or any `collapsed` is a disclosure (render-html's `callout`). */
918
+ function isCollapsibleCallout(props) {
919
+ return flagOn(props?.collapsible) || (props?.collapsed !== undefined && props?.collapsed !== null);
920
+ }
921
+ /** It starts open when `collapsed` is off (`collapsed="false"`) or `open` is on. */
922
+ function calloutStartsOpen(props) {
923
+ const c = props?.collapsed;
924
+ return c === false || c === "false" || flagOn(props?.open);
925
+ }
879
926
  /**
880
927
  * Merge a default class string with an optional user-provided class string.
881
928
  * Concatenates (user classes appended) so utility frameworks like Tailwind
@@ -1016,13 +1063,17 @@ function genericContainerToAstro(node, ctx) {
1016
1063
  function detailsToAstro(node, ctx) {
1017
1064
  ctx.imports.add("details");
1018
1065
  // `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
1019
- const variant = node.props?.variant ?? node.props?.type ?? "note";
1066
+ // Aliases (`caution` → danger) and unknown names (→ note) as render-html maps them
1067
+ const variant = collapsibleVariant(node.props?.variant ?? node.props?.type ?? "note");
1020
1068
  // `summary` is dogsbay-md's alias for the title (`:::details{summary="…"}`)
1021
1069
  const title = node.props?.title ?? node.props?.summary ?? "Details";
1022
- const open = node.props?.open;
1070
+ const open = flagOn(node.props?.open);
1023
1071
  const icon = node.props?.icon;
1024
1072
  const inner = childrenToAstro(node, ctx);
1025
- const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", title).trimStart()];
1073
+ // The title is inline Markdown, as in render-html: plain text stays the `title` prop
1074
+ const titleNodes = titleInline(title);
1075
+ const plainTitle = isPlainCaption(titleNodes);
1076
+ const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
1026
1077
  if (open)
1027
1078
  attrs.push("open");
1028
1079
  if (icon)
@@ -1031,7 +1082,8 @@ function detailsToAstro(node, ctx) {
1031
1082
  const passthrough = renderPassthroughAttrs(node.props, [
1032
1083
  "variant", "type", "title", "open", "icon", "class", "summary", "sheet",
1033
1084
  ]);
1034
- const alert = `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>
1085
+ const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
1086
+ const alert = `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>${slot}
1035
1087
  ${indentStr(inner, 2)}
1036
1088
  </CollapsibleAlert>`;
1037
1089
  // `sheet`: the same details, opened as a side sheet by <db-details-sheet>
@@ -1117,10 +1169,25 @@ function captionInline(caption) {
1117
1169
  return parseInline(caption);
1118
1170
  }
1119
1171
  const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
1172
+ /**
1173
+ * Formatting tags with no attributes, the HTML a title keeps (a raw `<details><summary><code>x</code>`
1174
+ * becomes the title `<code>x</code>`). render-html has the same list.
1175
+ */
1176
+ const TITLE_TAG = /^<\/?(?:code|kbd|b|strong|i|em|s|del|mark|sub|sup|br)\s*\/?>$/i;
1177
+ /**
1178
+ * A callout or details title: inline Markdown, and any other raw HTML is TEXT. Importers write
1179
+ * plain-text titles with angle brackets (`Map<K, V>`, `List<String>`), which as HTML dropped words,
1180
+ * and a title with `<img onerror=…>` ran a script in the summary. render-html reads titles the same way.
1181
+ */
1182
+ function titleInline(title) {
1183
+ return captionInline(title).map((n) => (n.type === "html-inline" && !TITLE_TAG.test(n.html.trim()) ? { type: "text", text: n.html } : n));
1184
+ }
1185
+ /** A plain caption's text (escapes undone by the parse). */
1186
+ const captionText = (nodes) => nodes.map((n) => (n.type === "text" ? n.text : "")).join("");
1120
1187
  function captionToAstro(caption, ctx) {
1121
1188
  const nodes = captionInline(caption);
1122
1189
  return isPlainCaption(nodes)
1123
- ? escapeTemplate(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))
1190
+ ? escapeTemplate(captionText(nodes))
1124
1191
  : renderInline(nodes, ctx);
1125
1192
  }
1126
1193
  function tableToAstro(node, ctx) {
@@ -1141,8 +1208,18 @@ function diagramToAstro(node, ctx) {
1141
1208
  ctx.imports.add("diagram");
1142
1209
  const lang = node.props?.lang || "mermaid";
1143
1210
  const code = node.props?.code ?? "";
1144
- const svg = node.props?.svg ?? "";
1211
+ let svg = node.props?.svg ?? "";
1145
1212
  const title = node.props?.title;
1213
+ // A Pele SVG's ids (`<prefix>-title`, `<prefix>-desc`, for aria-labelledby) are unique within
1214
+ // its source page. A book view or an include can show the same diagram twice on one page, so
1215
+ // the later copies are renamed; the prefix appears nowhere else in the SVG.
1216
+ const prefix = /^<svg[^>]*\bclass="pele\b/.test(svg) ? /\sid="(dg-[0-9a-f]{12}(?:-\d+)?)-/.exec(svg)?.[1] : undefined;
1217
+ if (prefix) {
1218
+ const n = (ctx.diagramIds.get(prefix) ?? 0) + 1;
1219
+ ctx.diagramIds.set(prefix, n);
1220
+ if (n > 1)
1221
+ svg = svg.split(prefix).join(`${prefix}-p${n}`);
1222
+ }
1146
1223
  const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
1147
1224
  if (title)
1148
1225
  attrs.push(componentAttr("title", title).trimStart());
@@ -1364,6 +1441,25 @@ function endpointToAstro(node, ctx) {
1364
1441
  ` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
1365
1442
  `</ApiLayout>`);
1366
1443
  }
1444
+ /**
1445
+ * Whole-card links being emitted. Inside one, a link in the card's body is its text: a browser
1446
+ * ends the outer <a> at an inner one, so the rest of the card fell out of the link (and lost
1447
+ * not-prose). The serializer is synchronous, so a depth count is enough.
1448
+ */
1449
+ let cardLinkDepth = 0;
1450
+ function wholeCardLink(yes, fn) {
1451
+ if (!yes)
1452
+ return fn();
1453
+ cardLinkDepth++;
1454
+ try {
1455
+ return fn();
1456
+ }
1457
+ finally {
1458
+ cardLinkDepth--;
1459
+ }
1460
+ }
1461
+ /** A card's title: not-prose, or docs.css's h3 rule (1.25rem, 2rem above) applies inside the card. */
1462
+ const CARD_TITLE_CLASS = "not-prose m-0 border-0 p-0 text-base font-semibold";
1367
1463
  function cardsToAstro(node, ctx) {
1368
1464
  ctx.imports.add("grid");
1369
1465
  ctx.imports.add("card");
@@ -1382,9 +1478,9 @@ function cardsToAstro(node, ctx) {
1382
1478
  const icon = card.props?.icon;
1383
1479
  // Description sources: inline content, children, or props.description.
1384
1480
  // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1385
- const blocks = childrenToAstro(card, ctx);
1481
+ const blocks = wholeCardLink(!!href, () => childrenToAstro(card, ctx));
1386
1482
  const inner = blocks
1387
- || (card.inline ? inlineToTemplate(card.inline) : "")
1483
+ || (card.inline ? wholeCardLink(!!href, () => inlineToTemplate(card.inline)) : "")
1388
1484
  || (description ? escapeTemplate(description) : "");
1389
1485
  const descTag = blocks ? "div" : "p";
1390
1486
  // Icon renders inline before the title — picks up Lucide
@@ -1396,7 +1492,7 @@ function cardsToAstro(node, ctx) {
1396
1492
  if (icon) {
1397
1493
  iconHtml = cardIconHtml(icon, title, ctx);
1398
1494
  }
1399
- const titleHtml = title ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>` : "";
1495
+ const titleHtml = title ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>` : "";
1400
1496
  // A description and a body are both shown (the description used to be dropped when a body existed).
1401
1497
  const lead = blocks && description
1402
1498
  ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
@@ -1415,7 +1511,7 @@ function cardsToAstro(node, ctx) {
1415
1511
  "title", "href", "description", "icon", "variant", "class", "tone",
1416
1512
  ]);
1417
1513
  if (href) {
1418
- 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>`;
1514
+ return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="not-prose [text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
1419
1515
  }
1420
1516
  return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
1421
1517
  }).join("\n");
@@ -1448,9 +1544,9 @@ function standaloneCardToAstro(node, ctx) {
1448
1544
  const description = node.props?.description;
1449
1545
  const icon = node.props?.icon;
1450
1546
  // Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
1451
- const blocks = childrenToAstro(node, ctx);
1547
+ const blocks = wholeCardLink(!!href, () => childrenToAstro(node, ctx));
1452
1548
  const inner = blocks
1453
- || (node.inline ? inlineToTemplate(node.inline) : "")
1549
+ || (node.inline ? wholeCardLink(!!href, () => inlineToTemplate(node.inline)) : "")
1454
1550
  || (description ? escapeTemplate(description) : "");
1455
1551
  const descTag = blocks ? "div" : "p";
1456
1552
  // Icon renders inline before the title (same shape as the
@@ -1461,7 +1557,7 @@ function standaloneCardToAstro(node, ctx) {
1461
1557
  iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
1462
1558
  }
1463
1559
  const titleHtml = title
1464
- ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>`
1560
+ ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>`
1465
1561
  : "";
1466
1562
  // A description and a body are both shown (the description used to be dropped when a body existed).
1467
1563
  const lead = blocks && description
@@ -1480,7 +1576,7 @@ function standaloneCardToAstro(node, ctx) {
1480
1576
  "title", "href", "description", "icon", "variant", "class", "tone",
1481
1577
  ]);
1482
1578
  if (href) {
1483
- 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>`;
1579
+ return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="not-prose [text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
1484
1580
  }
1485
1581
  return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
1486
1582
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-astro",
3
- "version": "0.2.0-beta.127",
3
+ "version": "0.2.0-beta.129",
4
4
  "description": "Astro format plugin for Dogsbay — export TreeNode[] to Astro component markup",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,12 +32,12 @@
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
34
  "markdown-it": "^14.3.0",
35
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.127",
36
- "@dogsbay/serialize-core": "0.2.0-beta.127",
37
- "@dogsbay/site-files": "0.2.0-beta.127",
38
- "@dogsbay/render-html": "0.2.0-beta.127",
39
- "@dogsbay/types": "0.2.0-beta.127",
40
- "@dogsbay/ui": "0.2.0-beta.127"
35
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.129",
36
+ "@dogsbay/serialize-core": "0.2.0-beta.129",
37
+ "@dogsbay/render-html": "0.2.0-beta.129",
38
+ "@dogsbay/site-files": "0.2.0-beta.129",
39
+ "@dogsbay/types": "0.2.0-beta.129",
40
+ "@dogsbay/ui": "0.2.0-beta.129"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",