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