@dogsbay/format-astro 0.2.0-beta.0

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.
@@ -0,0 +1,1197 @@
1
+ /**
2
+ * Tone palette for `:::grid-item{label="..." tone="..."}` cells.
3
+ * Each tone is a `bg + text` Tailwind class pair. Used to make grid demos
4
+ * declarative — authors specify a tone instead of writing the styling.
5
+ */
6
+ const TONE_CLASSES = {
7
+ // Primary scale (intensity)
8
+ "primary": "bg-primary text-primary-foreground",
9
+ "primary-strong": "bg-primary/80 text-primary-foreground",
10
+ "primary-medium": "bg-primary/60 text-primary-foreground",
11
+ "primary-half": "bg-primary/50 text-primary-foreground",
12
+ "primary-light": "bg-primary/20 text-primary",
13
+ "primary-soft": "bg-primary/10 text-primary",
14
+ // Neutral
15
+ "muted": "bg-muted text-muted-foreground",
16
+ // Named colors (for layout demos with distinct cells)
17
+ "blue": "bg-blue-500/15 text-blue-700 dark:text-blue-300",
18
+ "amber": "bg-amber-500/15 text-amber-700 dark:text-amber-300",
19
+ "green": "bg-green-500/15 text-green-700 dark:text-green-300",
20
+ "purple": "bg-purple-500/15 text-purple-700 dark:text-purple-300",
21
+ "red": "bg-red-500/15 text-red-700 dark:text-red-300",
22
+ };
23
+ const COMPONENT_IMPORTS = {
24
+ "markdown-example": [
25
+ 'import MarkdownExample from "@ui/markdown-example/MarkdownExample.astro";',
26
+ ],
27
+ callout: [
28
+ 'import Alert from "@ui/alert/Alert.astro";',
29
+ 'import AlertTitle from "@ui/alert/AlertTitle.astro";',
30
+ 'import AlertDescription from "@ui/alert/AlertDescription.astro";',
31
+ ],
32
+ details: [
33
+ 'import CollapsibleAlert from "@ui/alert/CollapsibleAlert.astro";',
34
+ ],
35
+ code: [
36
+ 'import CodeBlock from "@ui/code-block/CodeBlock.astro";',
37
+ ],
38
+ "code-rich": [
39
+ 'import CodeRich from "@ui/code-rich/CodeRich.astro";',
40
+ 'import "@ui/code-rich/code-rich.css";',
41
+ ],
42
+ tabs: [
43
+ 'import Tabs from "@ui/tabs/Tabs.astro";',
44
+ 'import TabsList from "@ui/tabs/TabsList.astro";',
45
+ 'import TabsTrigger from "@ui/tabs/TabsTrigger.astro";',
46
+ 'import TabsContent from "@ui/tabs/TabsContent.astro";',
47
+ ],
48
+ table: [
49
+ 'import Table from "@ui/table/Table.astro";',
50
+ 'import TableHeader from "@ui/table/TableHeader.astro";',
51
+ 'import TableBody from "@ui/table/TableBody.astro";',
52
+ 'import TableRow from "@ui/table/TableRow.astro";',
53
+ 'import TableHead from "@ui/table/TableHead.astro";',
54
+ 'import TableCell from "@ui/table/TableCell.astro";',
55
+ ],
56
+ diagram: ['import Diagram from "@ui/diagram/Diagram.astro";'],
57
+ youtube: ['import YouTube from "@ui/youtube/YouTube.astro";'],
58
+ "math-block": ['import MathKatex from "@ui/math/MathKatex.astro";'],
59
+ image: ['import BlockImage from "@ui/image/Image.astro";'],
60
+ "api-class": ['import ApiSymbol from "@ui/api-symbol/ApiSymbol.astro";'],
61
+ "api-doc": ['import ApiDoc from "@ui/api-doc/ApiDoc.astro";'],
62
+ "api-params": ['import ApiParams from "@ui/api-params/ApiParams.astro";'],
63
+ "api-source": ['import ApiSource from "@ui/api-source/ApiSource.astro";'],
64
+ endpoint: [
65
+ 'import ApiLayout from "@ui/api-layout/ApiLayout.astro";',
66
+ 'import ApiCodePanel from "@ui/api-layout/ApiCodePanel.astro";',
67
+ 'import ExampleBlock from "@ui/api-layout/ExampleBlock.astro";',
68
+ 'import EndpointCard from "@ui/endpoint-card/EndpointCard.astro";',
69
+ 'import EndpointSection from "@ui/endpoint-card/EndpointSection.astro";',
70
+ 'import PropertyList from "@ui/property-list/PropertyList.astro";',
71
+ 'import ResponseTabs from "@ui/response-tabs/ResponseTabs.astro";',
72
+ 'import SchemaViewer from "@ui/schema-viewer/SchemaViewer.astro";',
73
+ 'import CodeSamples from "@ui/code-samples/CodeSamples.astro";',
74
+ ],
75
+ steps: [
76
+ 'import Steps from "@ui/steps/Steps.astro";',
77
+ 'import Step from "@ui/steps/Step.astro";',
78
+ ],
79
+ substeps: [
80
+ 'import SubSteps from "@ui/steps/SubSteps.astro";',
81
+ ],
82
+ grid: [
83
+ 'import Grid from "@ui/grid/Grid.astro";',
84
+ 'import GridItem from "@ui/grid/GridItem.astro";',
85
+ ],
86
+ card: [
87
+ 'import Card from "@ui/card/Card.astro";',
88
+ 'import CardContent from "@ui/card/CardContent.astro";',
89
+ ],
90
+ accordion: [
91
+ 'import Accordion from "@ui/accordion/Accordion.astro";',
92
+ 'import AccordionItem from "@ui/accordion/AccordionItem.astro";',
93
+ 'import AccordionTrigger from "@ui/accordion/AccordionTrigger.astro";',
94
+ 'import AccordionContent from "@ui/accordion/AccordionContent.astro";',
95
+ ],
96
+ "link-card": [
97
+ 'import LinkCard from "@ui/link-card/LinkCard.astro";',
98
+ ],
99
+ avatar: [
100
+ 'import Avatar from "@ui/avatar/Avatar.astro";',
101
+ ],
102
+ };
103
+ // ─── Escaping helpers ─────────────────────────────────────────────
104
+ /** Escape for use inside an Astro {expression}. Uses JSON.stringify for safety. */
105
+ export function escapeExpr(s) {
106
+ // JSON.stringify handles all special characters (quotes, backslashes, newlines, etc.)
107
+ // and produces a valid JS string literal. This avoids template literal edge cases
108
+ // where backtick + brace combinations create false expression boundaries.
109
+ return JSON.stringify(s);
110
+ }
111
+ /** Escape for use in an HTML attribute value (double-quoted). */
112
+ export function escapeAttr(s) {
113
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
114
+ }
115
+ /** Escape text for use directly in an Astro template (not inside an expression). */
116
+ export function escapeTemplate(s) {
117
+ let result = "";
118
+ for (const ch of s) {
119
+ switch (ch) {
120
+ case "&":
121
+ result += "&amp;";
122
+ break;
123
+ case "<":
124
+ result += "&lt;";
125
+ break;
126
+ case ">":
127
+ result += "&gt;";
128
+ break;
129
+ case "{":
130
+ result += '{"{"}';
131
+ break;
132
+ case "}":
133
+ result += '{"}"}';
134
+ break;
135
+ default: result += ch;
136
+ }
137
+ }
138
+ return result;
139
+ }
140
+ // ─── Main entry point ─────────────────────────────────────────────
141
+ export function treeToAstro(nodes, options) {
142
+ const ctx = {
143
+ imports: new Set(),
144
+ indent: 0,
145
+ scripts: new Set(),
146
+ mode: options?.mode ?? "template",
147
+ imageOptimization: options?.imageOptimization ?? false,
148
+ codeBlockTitle: options?.codeBlockTitle ?? true,
149
+ };
150
+ const body = nodes.map((n) => nodeToAstro(n, ctx)).filter(Boolean).join("\n");
151
+ const imports = [];
152
+ const seen = new Set();
153
+ for (const key of ctx.imports) {
154
+ const stmts = COMPONENT_IMPORTS[key];
155
+ if (stmts) {
156
+ for (const stmt of stmts) {
157
+ if (!seen.has(stmt)) {
158
+ seen.add(stmt);
159
+ imports.push(stmt);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return {
165
+ body,
166
+ imports: imports.sort(),
167
+ scripts: [...ctx.scripts],
168
+ };
169
+ }
170
+ // ─── Node dispatcher ──────────────────────────────────────────────
171
+ function nodeToAstro(node, ctx) {
172
+ switch (node.type) {
173
+ case "prose":
174
+ return proseToAstro(node, ctx);
175
+ case "heading":
176
+ return headingToAstro(node, ctx);
177
+ case "paragraph":
178
+ return paragraphToAstro(node, ctx);
179
+ case "code":
180
+ return codeToAstro(node, ctx);
181
+ case "callout":
182
+ return calloutToAstro(node, ctx);
183
+ case "details":
184
+ return detailsToAstro(node, ctx);
185
+ case "tabs":
186
+ return tabsToAstro(node, ctx);
187
+ case "ordered-list":
188
+ return wrapBlock("ol", "", node, childrenToAstro(node, ctx), ["start"])
189
+ .replace(/^<ol(\s[^>]*)?>/, (m) => {
190
+ const start = node.props?.start;
191
+ if (start && start !== 1) {
192
+ return m.replace(/^<ol/, `<ol start="${start}"`);
193
+ }
194
+ return m;
195
+ });
196
+ case "unordered-list":
197
+ return wrapBlock("ul", "", node, childrenToAstro(node, ctx));
198
+ case "list-item":
199
+ return wrapBlock("li", "", node, leafContent(node, ctx));
200
+ case "blockquote":
201
+ return wrapBlock("blockquote", "border-l-4 border-border pl-4 italic text-muted-foreground", node, childrenToAstro(node, ctx));
202
+ case "hr": {
203
+ const classAttr = mergeClassAttr("my-8 border-border", node.props?.class);
204
+ const passthrough = renderPassthroughAttrs(node.props, ["class"]);
205
+ return `<hr${classAttr}${passthrough} />`;
206
+ }
207
+ case "html":
208
+ // Raw HTML always uses set:html — it may contain anything
209
+ return `<Fragment set:html={${escapeExpr(node.html ?? "")}} />`;
210
+ case "table":
211
+ return tableToAstro(node, ctx);
212
+ case "thead":
213
+ ctx.imports.add("table");
214
+ return `<TableHeader>\n${childrenToAstro(node, ctx)}\n</TableHeader>`;
215
+ case "tbody":
216
+ ctx.imports.add("table");
217
+ return `<TableBody>\n${childrenToAstro(node, ctx)}\n</TableBody>`;
218
+ case "tr":
219
+ ctx.imports.add("table");
220
+ return `<TableRow>\n${childrenToAstro(node, ctx)}\n</TableRow>`;
221
+ case "th":
222
+ ctx.imports.add("table");
223
+ return `<TableHead>${leafContent(node, ctx)}</TableHead>`;
224
+ case "td":
225
+ ctx.imports.add("table");
226
+ return `<TableCell>${leafContent(node, ctx)}</TableCell>`;
227
+ case "deflist":
228
+ return wrapBlock("dl", "my-4 space-y-2", node, childrenToAstro(node, ctx));
229
+ case "dt":
230
+ return wrapBlock("dt", "font-semibold", node, leafContent(node, ctx));
231
+ case "dd":
232
+ return wrapBlock("dd", "ml-6 text-muted-foreground", node, leafContent(node, ctx));
233
+ case "diagram":
234
+ return diagramToAstro(node, ctx);
235
+ case "youtube":
236
+ return youtubeToAstro(node, ctx);
237
+ case "math-block":
238
+ return mathBlockToAstro(node, ctx);
239
+ case "footnote-list":
240
+ return footnoteListToAstro(node, ctx);
241
+ case "footnote-def":
242
+ return footnoteDefToAstro(node, ctx);
243
+ case "api-class":
244
+ case "api-member":
245
+ return apiSymbolToAstro(node, ctx);
246
+ case "api-doc":
247
+ return apiDocToAstro(node, ctx);
248
+ case "api-params":
249
+ return apiParamsToAstro(node, ctx);
250
+ case "api-source":
251
+ return apiSourceToAstro(node, ctx);
252
+ case "endpoint":
253
+ return endpointToAstro(node, ctx);
254
+ case "html-container":
255
+ return htmlContainerToAstro(node, ctx);
256
+ case "example":
257
+ return exampleToAstro(node, ctx);
258
+ case "figure": {
259
+ const caption = node.props?.caption;
260
+ const inner = childrenToAstro(node, ctx);
261
+ const body = caption
262
+ ? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${escapeTemplate(caption)}</figcaption>`
263
+ : inner;
264
+ return wrapBlock("figure", "my-4", node, body, ["caption"]);
265
+ }
266
+ case "steps":
267
+ ctx.imports.add("steps");
268
+ return `<Steps>\n${childrenToAstro(node, ctx)}\n</Steps>`;
269
+ case "step":
270
+ ctx.imports.add("steps");
271
+ return `<Step>\n${leafContent(node, ctx)}\n</Step>`;
272
+ case "substeps":
273
+ ctx.imports.add("substeps");
274
+ ctx.imports.add("steps");
275
+ return `<SubSteps>\n${childrenToAstro(node, ctx)}\n</SubSteps>`;
276
+ case "annotation-list":
277
+ return wrapBlock("div", "my-2 rounded-md border-l-4 border-info/50 bg-info/5 p-4", node, childrenToAstro(node, ctx));
278
+ case "cards":
279
+ return cardsToAstro(node, ctx);
280
+ case "card":
281
+ // Standalone card — outside :::cards. Renders as a single styled card,
282
+ // optionally clickable when href is present. Cards inside :::cards are
283
+ // handled by cardsToAstro and never reach this case.
284
+ return standaloneCardToAstro(node, ctx);
285
+ case "link-card":
286
+ return linkCardToAstro(node, ctx);
287
+ case "accordion":
288
+ return accordionToAstro(node, ctx);
289
+ case "accordion-item":
290
+ // Bare accordion-item outside an :::accordion wrapper. Wrap it in an
291
+ // Accordion shell so the output is still a valid widget.
292
+ return accordionItemToAstro(node, ctx, /*standalone*/ true);
293
+ case "avatar":
294
+ return avatarToAstro(node, ctx);
295
+ case "grid": {
296
+ ctx.imports.add("grid");
297
+ // Grid component takes cols/gap/rows as props. User class goes through
298
+ // as the outer class attribute. Numeric-looking string values (e.g.
299
+ // cols="3") are coerced to numbers for the JSX prop.
300
+ const cols = coerceNumericList(node.props?.cols, [1, 2]);
301
+ const gap = coerceNumeric(node.props?.gap, 4);
302
+ const rows = node.props?.rows;
303
+ const classAttr = mergeClassAttr("", node.props?.class);
304
+ const passthrough = renderPassthroughAttrs(node.props, [
305
+ "cols", "gap", "rows", "class",
306
+ ]);
307
+ const rowsAttr = rows !== undefined ? ` rows={${JSON.stringify(coerceNumeric(rows, 1))}}` : "";
308
+ return `<Grid cols={${JSON.stringify(cols)}} gap={${JSON.stringify(gap)}}${rowsAttr}${classAttr}${passthrough}>
309
+ ${childrenToAstro(node, ctx)}
310
+ </Grid>`;
311
+ }
312
+ case "grid-item": {
313
+ ctx.imports.add("grid");
314
+ const span = coerceNumeric(node.props?.span, 1);
315
+ const rowSpan = node.props?.rowSpan;
316
+ const start = node.props?.start;
317
+ const label = node.props?.label;
318
+ const tone = node.props?.tone ?? "primary-light";
319
+ const attrParts = [`span={${span}}`];
320
+ if (rowSpan !== undefined)
321
+ attrParts.push(`rowSpan={${coerceNumeric(rowSpan, 1)}}`);
322
+ if (start !== undefined)
323
+ attrParts.push(`start={${coerceNumeric(start, 1)}}`);
324
+ const classAttr = mergeClassAttr("", node.props?.class);
325
+ const passthrough = renderPassthroughAttrs(node.props, [
326
+ "span", "rowSpan", "start", "label", "tone", "class",
327
+ ]);
328
+ // When `label` is set, auto-render a styled placeholder cell. The tone
329
+ // controls the bg/text combo (see TONE_CLASSES). Authors can also pass
330
+ // their own `class` to override.
331
+ if (label !== undefined) {
332
+ const toneClasses = TONE_CLASSES[tone] ?? TONE_CLASSES["primary-light"];
333
+ return `<GridItem ${attrParts.join(" ")}${classAttr}${passthrough}>
334
+ <div class="flex h-full min-h-16 items-center justify-center rounded-lg font-mono text-sm font-medium ${toneClasses}">${escapeTemplate(label)}</div>
335
+ </GridItem>`;
336
+ }
337
+ return `<GridItem ${attrParts.join(" ")}${classAttr}${passthrough}>
338
+ ${childrenToAstro(node, ctx)}
339
+ </GridItem>`;
340
+ }
341
+ default:
342
+ if (node.html)
343
+ return `<Fragment set:html={${escapeExpr(node.html)}} />`;
344
+ if (node.children)
345
+ return childrenToAstro(node, ctx);
346
+ return "";
347
+ }
348
+ }
349
+ // ─── Inline rendering ─────────────────────────────────────────────
350
+ /** Render inline nodes as Astro template markup (clean, editable). */
351
+ function inlineToTemplate(nodes) {
352
+ return nodes.map(inlineNodeToTemplate).join("");
353
+ }
354
+ function inlineNodeToTemplate(node) {
355
+ switch (node.type) {
356
+ case "text": {
357
+ let text = escapeTemplate(node.text);
358
+ if (node.bold)
359
+ text = `<strong>${text}</strong>`;
360
+ if (node.italic)
361
+ text = `<em>${text}</em>`;
362
+ if (node.strikethrough)
363
+ text = `<s>${text}</s>`;
364
+ return text;
365
+ }
366
+ case "link": {
367
+ const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
368
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}>${inlineToTemplate(node.children)}</a>`;
369
+ }
370
+ case "image":
371
+ return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy" />`;
372
+ case "code":
373
+ return `<code>${escapeTemplate(node.text)}</code>`;
374
+ case "footnote-ref":
375
+ return `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${escapeTemplate(node.label)}]</a></sup>`;
376
+ case "kbd":
377
+ return node.keys.map((k) => `<kbd>${escapeTemplate(k)}</kbd>`).join("+");
378
+ case "math":
379
+ return `<code class="math-inline">${escapeTemplate(node.latex)}</code>`;
380
+ case "icon":
381
+ return `:${node.name}:`;
382
+ case "html-inline":
383
+ // Raw HTML inline — must use set:html to avoid brace issues
384
+ return `<Fragment set:html={${escapeExpr(node.html)}} />`;
385
+ case "break":
386
+ return "<br />";
387
+ default:
388
+ return "";
389
+ }
390
+ }
391
+ /** Render inline nodes as HTML string (for hybrid mode set:html). */
392
+ function inlineToHtml(nodes) {
393
+ return nodes.map(inlineNodeToHtml).join("");
394
+ }
395
+ function inlineNodeToHtml(node) {
396
+ switch (node.type) {
397
+ case "text": {
398
+ let text = escapeHtml(node.text);
399
+ if (node.bold)
400
+ text = `<strong>${text}</strong>`;
401
+ if (node.italic)
402
+ text = `<em>${text}</em>`;
403
+ if (node.strikethrough)
404
+ text = `<s>${text}</s>`;
405
+ return text;
406
+ }
407
+ case "link": {
408
+ const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
409
+ return `<a href="${escapeAttr(node.href)}"${titleAttr}>${inlineToHtml(node.children)}</a>`;
410
+ }
411
+ case "image":
412
+ return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy" />`;
413
+ case "code":
414
+ return `<code>${escapeHtml(node.text)}</code>`;
415
+ case "footnote-ref":
416
+ return `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${escapeHtml(node.label)}]</a></sup>`;
417
+ case "kbd":
418
+ return node.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join("+");
419
+ case "math":
420
+ return `<code class="math-inline">${escapeHtml(node.latex)}</code>`;
421
+ case "icon":
422
+ return `:${node.name}:`;
423
+ case "html-inline":
424
+ return node.html;
425
+ case "break":
426
+ return "<br />";
427
+ default:
428
+ return "";
429
+ }
430
+ }
431
+ /** Render inline content in the current mode. */
432
+ function renderInline(nodes, ctx) {
433
+ if (ctx.mode === "hybrid") {
434
+ const html = inlineToHtml(nodes);
435
+ return `<Fragment set:html={${escapeExpr(html)}} />`;
436
+ }
437
+ return inlineToTemplate(nodes);
438
+ }
439
+ /** Render an HTML string in the current mode. */
440
+ function renderHtml(html, ctx) {
441
+ // Always use set:html for pre-rendered HTML — it may contain anything
442
+ return `<Fragment set:html={${escapeExpr(html)}} />`;
443
+ }
444
+ // ─── Node type handlers ───────────────────────────────────────────
445
+ function proseToAstro(node, ctx) {
446
+ if (node.inline)
447
+ return renderInline(node.inline, ctx);
448
+ if (node.html)
449
+ return renderHtml(node.html, ctx);
450
+ return "";
451
+ }
452
+ function headingToAstro(node, ctx) {
453
+ const level = node.props?.level ?? 1;
454
+ // `id` takes precedence over `slug` (slug is auto-generated, id is explicit user override)
455
+ const id = node.props?.id ?? node.props?.slug ?? "";
456
+ const anchor = id
457
+ ? `<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>`
458
+ : "";
459
+ const idAttr = id ? ` id="${escapeAttr(id)}"` : "";
460
+ const classAttr = mergeClassAttr("group scroll-mt-20", node.props?.class);
461
+ const passthrough = renderPassthroughAttrs(node.props, [
462
+ "level", "slug", "id", "class", "text",
463
+ ]);
464
+ if (ctx.mode === "template" && node.inline) {
465
+ const text = inlineToTemplate(node.inline);
466
+ return `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
467
+ }
468
+ // Hybrid or no inline — use set:html for the whole heading
469
+ const text = node.inline ? inlineToHtml(node.inline) : node.props?.text ?? "";
470
+ const html = `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
471
+ return `<Fragment set:html={${escapeExpr(html)}} />`;
472
+ }
473
+ function paragraphToAstro(node, ctx) {
474
+ // Two shapes supported: paragraph.inline (flat) or paragraph.children[prose.inline] (Starlight)
475
+ const inlineNodes = node.inline
476
+ ?? (node.children?.length === 1 && node.children[0].type === "prose"
477
+ ? node.children[0].inline
478
+ : undefined);
479
+ // Block image detection
480
+ if (inlineNodes) {
481
+ const meaningful = inlineNodes.filter((n) => !(n.type === "text" && !n.text.trim()));
482
+ if (meaningful.length === 1 && meaningful[0].type === "image") {
483
+ const img = meaningful[0];
484
+ ctx.imports.add("image");
485
+ const imgDataAttr = ctx.imageOptimization
486
+ ? ` imageData={imageMap[${escapeExpr(img.src)}]}`
487
+ : "";
488
+ // Forward arbitrary `data-*` / `aria-*` attrs that plugins
489
+ // may have attached to the wrapping paragraph (e.g.
490
+ // @dogsbay/plugin-image-zoom tags `data-zoomable="true"` so
491
+ // the runtime can attach handlers without scanning the DOM).
492
+ const passthrough = renderPassthroughAttrs(node.props, ["class"]);
493
+ return `<BlockImage src="${escapeAttr(img.src)}" alt="${escapeAttr(img.alt ?? "")}"${passthrough}${imgDataAttr} />`;
494
+ }
495
+ }
496
+ const classAttr = mergeClassAttr("", node.props?.class);
497
+ const passthrough = renderPassthroughAttrs(node.props, ["class"]);
498
+ const hasPAttrs = classAttr || passthrough;
499
+ if (ctx.mode === "template") {
500
+ // If we have flat inline, render it directly; otherwise fall back to children dispatch
501
+ if (node.inline && node.inline.length > 0) {
502
+ return `<p${classAttr}${passthrough}>${inlineToTemplate(node.inline)}</p>`;
503
+ }
504
+ return `<p${classAttr}${passthrough}>${childrenToAstro(node, ctx)}</p>`;
505
+ }
506
+ // Hybrid mode
507
+ if (node.inline && node.inline.length > 0) {
508
+ if (hasPAttrs) {
509
+ return `<p${classAttr}${passthrough} set:html={${escapeExpr(inlineToHtml(node.inline))}} />`;
510
+ }
511
+ return `<p set:html={${escapeExpr(inlineToHtml(node.inline))}} />`;
512
+ }
513
+ const html = (node.children ?? []).map((c) => {
514
+ if (c.inline)
515
+ return inlineToHtml(c.inline);
516
+ return c.html ?? "";
517
+ }).join("");
518
+ return `<p${classAttr}${passthrough} set:html={${escapeExpr(html)}} />`;
519
+ }
520
+ function codeToAstro(node, ctx) {
521
+ // Code content may live on props.code (Starlight convention) or node.html
522
+ // (dogsbay-md convention). Same for lang — Starlight uses props.lang,
523
+ // dogsbay-md uses props.language.
524
+ const lang = node.props?.lang
525
+ || node.props?.language
526
+ || "plaintext";
527
+ const code = node.props?.code
528
+ ?? node.html
529
+ ?? "";
530
+ const title = node.props?.title;
531
+ const highlights = node.props?.highlights;
532
+ const lineNumbers = node.props?.lineNumbers;
533
+ const isRich = lineNumbers || highlights || code.includes("[!code");
534
+ const classAttr = mergeClassAttr("", node.props?.class);
535
+ const consumed = [
536
+ "lang", "language", "code", "title", "highlights", "lineNumbers",
537
+ "class", "ins", "del", "mark", "collapse",
538
+ ];
539
+ const passthrough = renderPassthroughAttrs(node.props, consumed);
540
+ if (isRich) {
541
+ ctx.imports.add("code-rich");
542
+ const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
543
+ if (title)
544
+ attrs.push(`title="${escapeAttr(title)}"`);
545
+ if (lineNumbers)
546
+ attrs.push("lineNumbers");
547
+ if (highlights)
548
+ attrs.push(`highlights="${escapeAttr(highlights)}"`);
549
+ return `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough} />`;
550
+ }
551
+ ctx.imports.add("code");
552
+ const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
553
+ if (title)
554
+ attrs.push(`title="${escapeAttr(title)}"`);
555
+ if (ctx.codeBlockTitle !== true) {
556
+ attrs.push(`showTitle="${ctx.codeBlockTitle}"`);
557
+ }
558
+ return `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough} />`;
559
+ }
560
+ // Map GitHub alert names (and other aliases) to Alert component variants.
561
+ // The Alert variant palette follows MkDocs Material; not every GitHub name
562
+ // maps 1:1, so we translate at the render boundary.
563
+ const CALLOUT_VARIANT_MAP = {
564
+ important: "info", // GitHub IMPORTANT (purple) → info (blue/purple accent)
565
+ caution: "danger", // GitHub CAUTION (red) → danger (red)
566
+ error: "failure",
567
+ hint: "tip",
568
+ attention: "warning",
569
+ };
570
+ function calloutToAstro(node, ctx) {
571
+ ctx.imports.add("callout");
572
+ // Callout variant may live on props.variant (Starlight / MkDocs importers)
573
+ // or props.type (dogsbay-md parser). Support both.
574
+ const rawVariant = (node.props?.variant
575
+ ?? node.props?.type
576
+ ?? "note").toLowerCase();
577
+ const variant = CALLOUT_VARIANT_MAP[rawVariant] ?? rawVariant;
578
+ const title = node.props?.title ?? "";
579
+ const icon = node.props?.icon;
580
+ const iconAttr = icon ? ` icon="${escapeAttr(icon)}"` : "";
581
+ // User classes and passthrough attributes (id, data-*, aria-*, style)
582
+ const classAttr = mergeClassAttr("my-4", node.props?.class);
583
+ const passthrough = renderPassthroughAttrs(node.props, [
584
+ "type", "variant", "title", "icon", "class",
585
+ ]);
586
+ const inner = childrenToAstro(node, ctx);
587
+ return `<Alert variant="${escapeAttr(variant)}"${iconAttr}${classAttr}${passthrough}>
588
+ <AlertTitle>${escapeTemplate(title)}</AlertTitle>
589
+ <AlertDescription>
590
+ ${indentStr(inner, 4)}
591
+ </AlertDescription>
592
+ </Alert>`;
593
+ }
594
+ /**
595
+ * Merge a default class string with an optional user-provided class string.
596
+ * Concatenates (user classes appended) so utility frameworks like Tailwind
597
+ * resolve conflicts via component-internal class-variance-authority / tv().
598
+ */
599
+ function mergeClassAttr(defaults, userClass) {
600
+ const combined = [defaults, userClass].filter(Boolean).join(" ").trim();
601
+ return combined ? ` class="${escapeAttr(combined)}"` : "";
602
+ }
603
+ /**
604
+ * Render attributes not consumed as component props — `id`, `data-*`, `aria-*`,
605
+ * and `style`. These pass through to the rendered element.
606
+ */
607
+ function renderPassthroughAttrs(props, consumed) {
608
+ if (!props)
609
+ return "";
610
+ const skip = new Set([...consumed, "source", "children"]);
611
+ const parts = [];
612
+ for (const [key, value] of Object.entries(props)) {
613
+ if (skip.has(key))
614
+ continue;
615
+ if (value === undefined || value === null || value === false)
616
+ continue;
617
+ if (!/^(id|data-[\w-]+|aria-[\w-]+|style|role|title)$/.test(key))
618
+ continue;
619
+ if (value === true) {
620
+ parts.push(key);
621
+ }
622
+ else {
623
+ parts.push(`${key}="${escapeAttr(String(value))}"`);
624
+ }
625
+ }
626
+ return parts.length ? " " + parts.join(" ") : "";
627
+ }
628
+ /**
629
+ * Coerce an attribute value to a number, falling back to default.
630
+ * Accepts: number, numeric string ("3"), or anything → fallback.
631
+ */
632
+ function coerceNumeric(value, fallback) {
633
+ if (typeof value === "number")
634
+ return value;
635
+ if (typeof value === "string") {
636
+ const n = Number(value);
637
+ if (!Number.isNaN(n))
638
+ return n;
639
+ }
640
+ return fallback;
641
+ }
642
+ /**
643
+ * Coerce a value to a numeric array.
644
+ * Accepts: number → [n], number[], numeric string → [n], JSON-array string,
645
+ * comma-separated string → split, otherwise fallback.
646
+ */
647
+ function coerceNumericList(value, fallback) {
648
+ if (Array.isArray(value))
649
+ return value.map((v) => coerceNumeric(v, 0));
650
+ if (typeof value === "number")
651
+ return [value];
652
+ if (typeof value === "string") {
653
+ const trimmed = value.trim();
654
+ if (trimmed.startsWith("[")) {
655
+ try {
656
+ const parsed = JSON.parse(trimmed);
657
+ if (Array.isArray(parsed))
658
+ return parsed.map((v) => coerceNumeric(v, 0));
659
+ }
660
+ catch {
661
+ // fallthrough
662
+ }
663
+ }
664
+ if (trimmed.includes(",")) {
665
+ return trimmed.split(",").map((s) => coerceNumeric(s.trim(), 0));
666
+ }
667
+ return [coerceNumeric(trimmed, fallback[0])];
668
+ }
669
+ return fallback;
670
+ }
671
+ /**
672
+ * Wrap content in an HTML tag with user-mergeable class + passthrough attrs.
673
+ * Use for simple block elements like `<ol>`, `<blockquote>`, `<figure>` where
674
+ * the default class is a utility string.
675
+ *
676
+ * `consumed` lists prop keys that are used elsewhere (as component props or
677
+ * semantic fields) and shouldn't leak into passthrough attrs.
678
+ */
679
+ function wrapBlock(tag, defaultClass, node, content, consumed = []) {
680
+ const classAttr = mergeClassAttr(defaultClass, node.props?.class);
681
+ const passthrough = renderPassthroughAttrs(node.props, ["class", ...consumed]);
682
+ const needsNewline = /<\/?[a-zA-Z]/.test(content) && !content.startsWith("<");
683
+ const sep = content.includes("\n") ? "\n" : "";
684
+ return `<${tag}${classAttr}${passthrough}>${sep}${content}${sep}</${tag}>`;
685
+ void needsNewline;
686
+ }
687
+ function detailsToAstro(node, ctx) {
688
+ ctx.imports.add("details");
689
+ const variant = node.props?.variant ?? "note";
690
+ const title = node.props?.title ?? "Details";
691
+ const open = node.props?.open;
692
+ const icon = node.props?.icon;
693
+ const inner = childrenToAstro(node, ctx);
694
+ const attrs = [`variant="${escapeAttr(variant)}"`, `title="${escapeAttr(title)}"`];
695
+ if (open)
696
+ attrs.push("open");
697
+ if (icon)
698
+ attrs.push(`icon="${escapeAttr(icon)}"`);
699
+ const classAttr = mergeClassAttr("my-4", node.props?.class);
700
+ const passthrough = renderPassthroughAttrs(node.props, [
701
+ "variant", "title", "open", "icon", "class", "summary",
702
+ ]);
703
+ return `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>
704
+ ${indentStr(inner, 2)}
705
+ </CollapsibleAlert>`;
706
+ }
707
+ function tabsToAstro(node, ctx) {
708
+ ctx.imports.add("tabs");
709
+ const tabs = node.children ?? [];
710
+ if (tabs.length === 0)
711
+ return "";
712
+ const tabItems = tabs.map((tab, i) => {
713
+ const title = tab.props?.title || tab.props?.label || tab.props?.value || `Tab ${i + 1}`;
714
+ const value = tab.props?.value || title;
715
+ const label = title;
716
+ return { value, label, node: tab };
717
+ });
718
+ const defaultValue = tabItems[0].value;
719
+ const triggers = tabItems
720
+ .map((t) => ` <TabsTrigger value="${escapeAttr(t.value)}">${escapeTemplate(t.label)}</TabsTrigger>`)
721
+ .join("\n");
722
+ const contents = tabItems
723
+ .map((t) => {
724
+ const inner = childrenToAstro(t.node, ctx);
725
+ return ` <TabsContent value="${escapeAttr(t.value)}">\n${indentStr(inner, 4)}\n </TabsContent>`;
726
+ })
727
+ .join("\n");
728
+ const classAttr = mergeClassAttr("my-4", node.props?.class);
729
+ const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
730
+ return `<Tabs defaultValue="${escapeAttr(defaultValue)}"${classAttr}${passthrough}>
731
+ <TabsList>
732
+ ${triggers}
733
+ </TabsList>
734
+ ${contents}
735
+ </Tabs>`;
736
+ }
737
+ function tableToAstro(node, ctx) {
738
+ ctx.imports.add("table");
739
+ const classAttr = mergeClassAttr("my-4", node.props?.class);
740
+ const passthrough = renderPassthroughAttrs(node.props, ["class"]);
741
+ return `<Table${classAttr}${passthrough}>\n${childrenToAstro(node, ctx)}\n</Table>`;
742
+ }
743
+ function diagramToAstro(node, ctx) {
744
+ ctx.imports.add("diagram");
745
+ const lang = node.props?.lang || "mermaid";
746
+ const code = node.props?.code ?? "";
747
+ const svg = node.props?.svg ?? "";
748
+ const title = node.props?.title;
749
+ const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
750
+ if (title)
751
+ attrs.push(`title="${escapeAttr(title)}"`);
752
+ const classAttr = mergeClassAttr("", node.props?.class);
753
+ const passthrough = renderPassthroughAttrs(node.props, [
754
+ "lang", "code", "svg", "title", "class",
755
+ ]);
756
+ return `<Diagram ${attrs.join(" ")}${classAttr}${passthrough} />`;
757
+ }
758
+ function youtubeToAstro(node, ctx) {
759
+ ctx.imports.add("youtube");
760
+ const id = node.props?.id ?? "";
761
+ const title = node.props?.title ?? "";
762
+ const classAttr = mergeClassAttr("my-4", node.props?.class);
763
+ const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
764
+ return `<YouTube id="${escapeAttr(id)}" title="${escapeAttr(title)}"${classAttr}${passthrough} />`;
765
+ }
766
+ function mathBlockToAstro(node, ctx) {
767
+ ctx.imports.add("math-block");
768
+ const latex = node.props?.latex ?? "";
769
+ const classAttr = mergeClassAttr("", node.props?.class);
770
+ const passthrough = renderPassthroughAttrs(node.props, ["latex", "class"]);
771
+ return `<MathKatex latex={${escapeExpr(latex)}} display${classAttr}${passthrough} />`;
772
+ }
773
+ /**
774
+ * Render a footnote list as a GitHub-style `<section>` with a
775
+ * separator rule, an accessibility-only heading, and the ordered
776
+ * list of definitions. Without the wrapper, the bare `<ol>` reads
777
+ * as a continuation of whatever bulleted/numbered list precedes
778
+ * it — which we hit on /docs/ccms/heretto/ where the "Related"
779
+ * section flowed straight into the footnotes with no break.
780
+ *
781
+ * Structure follows GitHub's convention:
782
+ * <hr> separator (visible)
783
+ * <section data-footnotes aria-labelledby="footnote-label">
784
+ * <h2 id="footnote-label" class="sr-only">Footnotes</h2>
785
+ * <ol data-footnote-list>...</ol>
786
+ * </section>
787
+ *
788
+ * The `sr-only` h2 is announced by screen readers as a section
789
+ * heading without adding visible chrome — writers who want a
790
+ * visible "Sources" / "References" heading still author it
791
+ * manually above their `[^N]:` definitions.
792
+ */
793
+ function footnoteListToAstro(node, ctx) {
794
+ return [
795
+ `<hr class="my-8 border-t border-border" data-footnotes-separator />`,
796
+ `<section class="footnotes" data-footnotes aria-labelledby="footnote-label">`,
797
+ ` <h2 id="footnote-label" class="sr-only">Footnotes</h2>`,
798
+ ` <ol class="list-decimal pl-6 space-y-1 text-sm text-muted-foreground" data-footnote-list>`,
799
+ childrenToAstro(node, ctx),
800
+ ` </ol>`,
801
+ `</section>`,
802
+ ].join("\n");
803
+ }
804
+ function footnoteDefToAstro(node, ctx) {
805
+ const id = node.props?.id ?? "";
806
+ const backrefId = node.props?.backrefId ?? "";
807
+ if (ctx.mode === "template" && node.inline) {
808
+ const content = inlineToTemplate(node.inline);
809
+ const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
810
+ return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">${content}${backref}</li>`;
811
+ }
812
+ const content = node.inline ? inlineToHtml(node.inline) : (node.html ?? "");
813
+ const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
814
+ return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground" set:html={${escapeExpr(content + backref)}} />`;
815
+ }
816
+ function apiSymbolToAstro(node, ctx) {
817
+ ctx.imports.add("api-class");
818
+ const name = node.props?.name ?? "";
819
+ const kind = node.props?.kind ?? "func";
820
+ const signature = node.props?.signature;
821
+ const bases = node.props?.bases || [];
822
+ const level = node.type === "api-class" ? 2 : 3;
823
+ const attrs = [`name="${escapeAttr(name)}"`, `kind="${escapeAttr(kind)}"`, `level={${level}}`];
824
+ if (signature)
825
+ attrs.push(`signature={${escapeExpr(signature)}}`);
826
+ if (bases.length > 0)
827
+ attrs.push(`bases={${JSON.stringify(bases)}}`);
828
+ const inner = childrenToAstro(node, ctx);
829
+ return `<ApiSymbol ${attrs.join(" ")}>\n${indentStr(inner, 2)}\n</ApiSymbol>`;
830
+ }
831
+ function apiDocToAstro(node, ctx) {
832
+ ctx.imports.add("api-doc");
833
+ const html = node.props?.html ?? "";
834
+ return `<ApiDoc html={${escapeExpr(html)}} />`;
835
+ }
836
+ function apiParamsToAstro(node, ctx) {
837
+ ctx.imports.add("api-params");
838
+ const params = node.props?.params ?? [];
839
+ return `<ApiParams params={${JSON.stringify(params)}} />`;
840
+ }
841
+ function apiSourceToAstro(node, ctx) {
842
+ ctx.imports.add("api-source");
843
+ const code = node.props?.code ?? "";
844
+ const file = node.props?.file;
845
+ const line = node.props?.line;
846
+ const attrs = [`code={${escapeExpr(code)}}`];
847
+ if (file)
848
+ attrs.push(`file="${escapeAttr(file)}"`);
849
+ if (line)
850
+ attrs.push(`line={${line}}`);
851
+ return `<ApiSource ${attrs.join(" ")} />`;
852
+ }
853
+ /**
854
+ * Render an OpenAPI operation as `<ApiLayout>` with two slots:
855
+ * - `description` — `<EndpointCard>` containing collapsible
856
+ * `<EndpointSection>`s for parameters / request body / responses,
857
+ * using `<ParameterTable>` / `<SchemaViewer>` / `<ResponseTabs>`.
858
+ * - `code` — `<CodeSamples>` and (when present) request / response
859
+ * example panels.
860
+ *
861
+ * All structured data (parameters, schemas, samples) is JSON-stringified
862
+ * and passed as Astro props — the components handle the rendering. Only
863
+ * top-level summary / description text is interpolated into the markup.
864
+ */
865
+ function endpointToAstro(node, ctx) {
866
+ ctx.imports.add("endpoint");
867
+ const props = (node.props ?? {});
868
+ const method = (props.method || "get").toLowerCase();
869
+ const path = props.path ?? "";
870
+ const baseUrl = props.baseUrl ?? "";
871
+ const summary = props.summary;
872
+ const description = props.description;
873
+ const deprecated = props.deprecated === true;
874
+ const parameters = props.parameters ?? [];
875
+ const requestBody = props.requestBody ?? [];
876
+ const responses = props.responses ?? [];
877
+ const codeSamples = props.codeSamples ?? [];
878
+ const requestBodyExample = props.requestBodyExample;
879
+ const cardAttrs = [
880
+ 'slot="description"',
881
+ `method="${escapeAttr(method)}"`,
882
+ `path={${escapeExpr(path)}}`,
883
+ ];
884
+ if (baseUrl)
885
+ cardAttrs.push(`baseUrl={${escapeExpr(baseUrl)}}`);
886
+ if (summary)
887
+ cardAttrs.push(`summary={${escapeExpr(summary)}}`);
888
+ if (description)
889
+ cardAttrs.push(`description={${escapeExpr(description)}}`);
890
+ if (deprecated)
891
+ cardAttrs.push("deprecated");
892
+ // Description column — collapsible sections for parameters, request
893
+ // body schema, and response schemas. PropertyList (Stripe-style,
894
+ // grouped by location with per-type colour badges) reads better than
895
+ // ParameterTable at every viewport width — see
896
+ // packages/ui/src/property-list/PropertyList.astro.
897
+ const sections = [];
898
+ if (parameters.length > 0) {
899
+ sections.push(`<EndpointSection title="Parameters">\n` +
900
+ ` <PropertyList parameters={${JSON.stringify(parameters)}} />\n` +
901
+ `</EndpointSection>`);
902
+ }
903
+ if (requestBody.length > 0) {
904
+ sections.push(`<EndpointSection title="Request body">\n` +
905
+ ` <SchemaViewer properties={${JSON.stringify(requestBody)}} />\n` +
906
+ `</EndpointSection>`);
907
+ }
908
+ if (responses.length > 0) {
909
+ sections.push(`<EndpointSection title="Responses">\n` +
910
+ ` <ResponseTabs responses={${JSON.stringify(responses)}} />\n` +
911
+ `</EndpointSection>`);
912
+ }
913
+ const sectionsBody = sections.length > 0
914
+ ? `\n${indentStr(sections.join("\n"), 4)}\n `
915
+ : "";
916
+ // Code column — wrapped in <ApiCodePanel> for consistent vertical
917
+ // rhythm. Code samples first, then a request body example, then one
918
+ // ExampleBlock per response status that carries a concrete example.
919
+ // Each ExampleBlock auto-detects language from the response's
920
+ // contentType (application/json → "json", application/xml → "xml",
921
+ // etc.).
922
+ const codePanels = [];
923
+ if (codeSamples.length > 0) {
924
+ codePanels.push(`<div>\n` +
925
+ ` <h3 class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</h3>\n` +
926
+ ` <CodeSamples samples={${JSON.stringify(codeSamples)}} />\n` +
927
+ `</div>`);
928
+ }
929
+ if (requestBodyExample !== undefined) {
930
+ const json = JSON.stringify(requestBodyExample, null, 2);
931
+ codePanels.push(`<ExampleBlock title="Request body" code={${escapeExpr(json)}} lang="json" />`);
932
+ }
933
+ for (const r of responses) {
934
+ if (r.example === undefined)
935
+ continue;
936
+ const status = String(r.status ?? "");
937
+ const contentType = r.contentType;
938
+ const exampleJson = JSON.stringify(r.example, null, 2);
939
+ const ctAttr = contentType ? ` contentType={${escapeExpr(contentType)}}` : "";
940
+ codePanels.push(`<ExampleBlock title="Response ${escapeAttr(status)}" code={${escapeExpr(exampleJson)}}${ctAttr} />`);
941
+ }
942
+ const codeBody = codePanels.length > 0
943
+ ? `\n${indentStr(codePanels.join("\n"), 4)}\n `
944
+ : "";
945
+ return (`<ApiLayout>\n` +
946
+ ` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
947
+ ` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
948
+ `</ApiLayout>`);
949
+ }
950
+ function cardsToAstro(node, ctx) {
951
+ ctx.imports.add("grid");
952
+ ctx.imports.add("card");
953
+ // Grid-level attributes on the :::cards container
954
+ const outerClass = mergeClassAttr("", node.props?.class);
955
+ const outerPass = renderPassthroughAttrs(node.props, [
956
+ "cols", "gap", "rows", "class",
957
+ ]);
958
+ // MDX pattern: children are card nodes with title/href/icon props
959
+ const cardNodes = (node.children ?? []).filter((c) => c.type === "card");
960
+ if (cardNodes.length > 0) {
961
+ const cards = cardNodes.map((card) => {
962
+ const title = card.props?.title ?? "";
963
+ const href = card.props?.href ?? "";
964
+ const description = card.props?.description;
965
+ // Description sources: inline content, children, or props.description.
966
+ const inner = childrenToAstro(card, ctx)
967
+ || (card.inline ? inlineToTemplate(card.inline) : "")
968
+ || (description ? escapeTemplate(description) : "");
969
+ const titleHtml = title ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>` : "";
970
+ const desc = inner
971
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
972
+ : "";
973
+ const cardDefaultClass = href
974
+ ? "p-4 hover:border-foreground/20 transition-colors"
975
+ : "p-4";
976
+ const cardClassAttr = mergeClassAttr(cardDefaultClass, card.props?.class);
977
+ const cardPass = renderPassthroughAttrs(card.props, [
978
+ "title", "href", "description", "icon", "variant", "class",
979
+ ]);
980
+ if (href) {
981
+ return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="[text-decoration:none] text-foreground">\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
982
+ }
983
+ return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
984
+ }).join("\n");
985
+ return `<Grid cols={[1, 2]} gap={4}${outerClass}${outerPass}>\n${cards}\n</Grid>`;
986
+ }
987
+ // MkDocs pattern: children are unordered-list items
988
+ const items = (node.children ?? [])
989
+ .filter((c) => c.type === "unordered-list")
990
+ .flatMap((ul) => ul.children ?? []);
991
+ if (items.length > 0) {
992
+ const cards = items
993
+ .map((item) => `<Card>\n<CardContent>\n${childrenToAstro(item, ctx)}\n</CardContent>\n</Card>`)
994
+ .join("\n");
995
+ return `<Grid cols={[1, 2]} gap={4}>\n${cards}\n</Grid>`;
996
+ }
997
+ // Fallback: render children directly in grid
998
+ return `<Grid cols={[1, 2]} gap={4}>\n${childrenToAstro(node, ctx)}\n</Grid>`;
999
+ }
1000
+ /**
1001
+ * Render a standalone `card` TreeNode (outside `:::cards`).
1002
+ *
1003
+ * Same shape as the cards-list path uses for each card, but emitted as a
1004
+ * single block. Title becomes an h3; description is the body. When href is
1005
+ * present the whole card becomes a link target.
1006
+ */
1007
+ function standaloneCardToAstro(node, ctx) {
1008
+ ctx.imports.add("card");
1009
+ const title = node.props?.title ?? "";
1010
+ const href = node.props?.href ?? "";
1011
+ const description = node.props?.description;
1012
+ const inner = childrenToAstro(node, ctx)
1013
+ || (node.inline ? inlineToTemplate(node.inline) : "")
1014
+ || (description ? escapeTemplate(description) : "");
1015
+ const titleHtml = title
1016
+ ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>`
1017
+ : "";
1018
+ const desc = inner
1019
+ ? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</p>`
1020
+ : "";
1021
+ const cardDefaultClass = href
1022
+ ? "p-4 hover:border-foreground/20 transition-colors"
1023
+ : "p-4";
1024
+ const classAttr = mergeClassAttr(cardDefaultClass, node.props?.class);
1025
+ const passthrough = renderPassthroughAttrs(node.props, [
1026
+ "title", "href", "description", "icon", "variant", "class",
1027
+ ]);
1028
+ if (href) {
1029
+ return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="[text-decoration:none] text-foreground">\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
1030
+ }
1031
+ return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
1032
+ }
1033
+ /**
1034
+ * Render a `link-card` TreeNode as the dedicated `<LinkCard>` component.
1035
+ *
1036
+ * LinkCard takes title + description + href as props (no slot for body).
1037
+ * If the parser kept rich body children instead of folding into description,
1038
+ * we fall back to the standalone-card path which can hold arbitrary content.
1039
+ */
1040
+ function linkCardToAstro(node, ctx) {
1041
+ const hasRichChildren = (node.children ?? []).length > 0;
1042
+ if (hasRichChildren) {
1043
+ return standaloneCardToAstro(node, ctx);
1044
+ }
1045
+ ctx.imports.add("link-card");
1046
+ const title = node.props?.title ?? "";
1047
+ const description = node.props?.description;
1048
+ const href = node.props?.href ?? "";
1049
+ const titleAttr = ` title="${escapeAttr(title)}"`;
1050
+ const hrefAttr = ` href="${escapeAttr(href)}"`;
1051
+ const descAttr = description ? ` description="${escapeAttr(description)}"` : "";
1052
+ const classAttr = mergeClassAttr("", node.props?.class);
1053
+ const passthrough = renderPassthroughAttrs(node.props, [
1054
+ "title", "description", "href", "icon", "class",
1055
+ ]);
1056
+ return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
1057
+ }
1058
+ /**
1059
+ * Render an `accordion` TreeNode and its `accordion-item` children.
1060
+ *
1061
+ * Output mirrors the canonical Starwind/Dogsbay accordion shape:
1062
+ * <Accordion type defaultValue>
1063
+ * <AccordionItem value>
1064
+ * <AccordionTrigger>label</AccordionTrigger>
1065
+ * <AccordionContent>body</AccordionContent>
1066
+ * </AccordionItem>
1067
+ * </Accordion>
1068
+ */
1069
+ function accordionToAstro(node, ctx) {
1070
+ ctx.imports.add("accordion");
1071
+ const type = node.props?.type ?? "single";
1072
+ const defaultValue = node.props?.defaultValue;
1073
+ const typeAttr = ` type="${escapeAttr(type)}"`;
1074
+ const defaultAttr = defaultValue !== undefined
1075
+ ? ` defaultValue=${typeof defaultValue === "string"
1076
+ ? `"${escapeAttr(defaultValue)}"`
1077
+ : `{${JSON.stringify(defaultValue)}}`}`
1078
+ : "";
1079
+ const classAttr = mergeClassAttr("", node.props?.class);
1080
+ const passthrough = renderPassthroughAttrs(node.props, [
1081
+ "type", "defaultValue", "class",
1082
+ ]);
1083
+ const items = (node.children ?? [])
1084
+ .filter((c) => c.type === "accordion-item")
1085
+ .map((item) => accordionItemToAstro(item, ctx, /*standalone*/ false))
1086
+ .join("\n");
1087
+ return `<Accordion${typeAttr}${defaultAttr}${classAttr}${passthrough}>\n${items}\n</Accordion>`;
1088
+ }
1089
+ function accordionItemToAstro(node, ctx, standalone) {
1090
+ ctx.imports.add("accordion");
1091
+ const value = node.props?.value ?? "item-1";
1092
+ const label = node.props?.label ?? node.props?.title ?? "Item";
1093
+ const valueAttr = ` value="${escapeAttr(value)}"`;
1094
+ const classAttr = mergeClassAttr("", node.props?.class);
1095
+ const passthrough = renderPassthroughAttrs(node.props, [
1096
+ "value", "label", "title", "class",
1097
+ ]);
1098
+ const body = childrenToAstro(node, ctx)
1099
+ || (node.inline ? inlineToTemplate(node.inline) : "");
1100
+ const itemMarkup = `<AccordionItem${valueAttr}${classAttr}${passthrough}>
1101
+ <AccordionTrigger>${escapeTemplate(label)}</AccordionTrigger>
1102
+ <AccordionContent>
1103
+ ${body}
1104
+ </AccordionContent>
1105
+ </AccordionItem>`;
1106
+ // Bare accordion-item: wrap so the output is still a working widget.
1107
+ if (standalone) {
1108
+ return `<Accordion type="single">\n${itemMarkup}\n</Accordion>`;
1109
+ }
1110
+ return itemMarkup;
1111
+ }
1112
+ /**
1113
+ * Render an `avatar` TreeNode as the `<Avatar>` component.
1114
+ */
1115
+ function avatarToAstro(node, ctx) {
1116
+ ctx.imports.add("avatar");
1117
+ const src = node.props?.src ?? "";
1118
+ const alt = node.props?.alt ?? "";
1119
+ const fallback = node.props?.fallback;
1120
+ const srcAttr = src ? ` src="${escapeAttr(src)}"` : "";
1121
+ const altAttr = ` alt="${escapeAttr(alt)}"`;
1122
+ const fbAttr = fallback ? ` fallback="${escapeAttr(fallback)}"` : "";
1123
+ const classAttr = mergeClassAttr("", node.props?.class);
1124
+ const passthrough = renderPassthroughAttrs(node.props, [
1125
+ "src", "alt", "fallback", "class",
1126
+ ]);
1127
+ return `<Avatar${srcAttr}${altAttr}${fbAttr}${classAttr}${passthrough} />`;
1128
+ }
1129
+ /**
1130
+ * Render a :::example directive as a MarkdownExample component.
1131
+ *
1132
+ * The parser captured the inner content as `node.props.source` (re-serialized
1133
+ * back to Dogsbay MD text) and kept `node.children` for live rendering.
1134
+ *
1135
+ * The consuming app is expected to provide a `MarkdownExample` component via
1136
+ * its imports (see apps/md-docs/ for a reference implementation).
1137
+ */
1138
+ function exampleToAstro(node, ctx) {
1139
+ ctx.imports.add("markdown-example");
1140
+ const source = String(node.props?.source ?? "");
1141
+ const title = node.props?.title;
1142
+ const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
1143
+ const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
1144
+ const fallbackAttr = showFallback ? " showFallback" : "";
1145
+ const inner = childrenToAstro(node, ctx);
1146
+ return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
1147
+ }
1148
+ function htmlContainerToAstro(node, ctx) {
1149
+ const attrs = node.props?.attrs ?? {};
1150
+ const classes = attrs.class ?? "";
1151
+ if (classes.includes("grid") && classes.includes("cards")) {
1152
+ return cardsToAstro(node, ctx);
1153
+ }
1154
+ if (classes.includes("mdx-columns")) {
1155
+ const inner = childrenToAstro(node, ctx);
1156
+ return `<div class="columns-1 sm:columns-2 gap-4 [&_ul]:m-0">\n${inner}\n</div>`;
1157
+ }
1158
+ const inner = childrenToAstro(node, ctx);
1159
+ return `<div>\n${inner}\n</div>`;
1160
+ }
1161
+ // ─── Utilities ────────────────────────────────────────────────────
1162
+ /**
1163
+ * Render a node's content whether it's attached as flat inline (dogsbay-md
1164
+ * parser shape) or as wrapped children (Starlight importer shape). Both
1165
+ * shapes supported; inline comes before children if both present.
1166
+ *
1167
+ * Used for list-item, table cells, step, dt, dd — types that commonly
1168
+ * carry leaf text content directly on the node.
1169
+ */
1170
+ function leafContent(node, ctx) {
1171
+ const parts = [];
1172
+ if (node.inline && node.inline.length > 0) {
1173
+ parts.push(inlineToTemplate(node.inline));
1174
+ }
1175
+ if (node.children && node.children.length > 0) {
1176
+ const rendered = childrenToAstro(node, ctx);
1177
+ if (rendered)
1178
+ parts.push(rendered);
1179
+ }
1180
+ return parts.join("\n");
1181
+ }
1182
+ function childrenToAstro(node, ctx) {
1183
+ if (!node.children || node.children.length === 0)
1184
+ return "";
1185
+ return node.children.map((n) => nodeToAstro(n, ctx)).filter(Boolean).join("\n");
1186
+ }
1187
+ function indentStr(text, spaces) {
1188
+ const prefix = " ".repeat(spaces);
1189
+ return text
1190
+ .split("\n")
1191
+ .map((line) => (line ? `${prefix}${line}` : line))
1192
+ .join("\n");
1193
+ }
1194
+ function escapeHtml(s) {
1195
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1196
+ }
1197
+ //# sourceMappingURL=serialize.js.map