@dogsbay/format-astro 0.2.0-beta.12 → 0.2.0-beta.121
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/api-markdown.d.ts +8 -0
- package/dist/api-markdown.d.ts.map +1 -0
- package/dist/api-markdown.js +50 -0
- package/dist/api-markdown.js.map +1 -0
- package/dist/base-path.d.ts +93 -7
- package/dist/base-path.d.ts.map +1 -1
- package/dist/base-path.js +122 -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 +324 -0
- package/dist/blog.js.map +1 -0
- package/dist/browser.d.ts +14 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +14 -0
- package/dist/browser.js.map +1 -0
- package/dist/canonical.d.ts +34 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +107 -0
- package/dist/canonical.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/head-metadata.d.ts +26 -0
- package/dist/head-metadata.d.ts.map +1 -0
- package/dist/head-metadata.js +56 -0
- package/dist/head-metadata.js.map +1 -0
- package/dist/index.d.ts +24 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -3
- package/dist/index.js.map +1 -1
- package/dist/lead.d.ts +1 -38
- package/dist/lead.d.ts.map +1 -1
- package/dist/lead.js +3 -37
- package/dist/lead.js.map +1 -1
- package/dist/llms-txt.d.ts +53 -11
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +85 -222
- package/dist/llms-txt.js.map +1 -1
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +4 -2
- package/dist/plugins.js.map +1 -1
- package/dist/project.d.ts +377 -12
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +2263 -661
- package/dist/project.js.map +1 -1
- package/dist/redirects.d.ts +116 -0
- package/dist/redirects.d.ts.map +1 -0
- package/dist/redirects.js +224 -0
- package/dist/redirects.js.map +1 -0
- package/dist/serialize.d.ts +14 -0
- package/dist/serialize.d.ts.map +1 -1
- package/dist/serialize.js +643 -216
- package/dist/serialize.js.map +1 -1
- package/dist/sitemap.d.ts +108 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +127 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/taxonomy.d.ts +48 -1
- package/dist/taxonomy.d.ts.map +1 -1
- package/dist/taxonomy.js +46 -11
- package/dist/taxonomy.js.map +1 -1
- package/dist/theme-css.d.ts +26 -0
- package/dist/theme-css.d.ts.map +1 -0
- package/dist/theme-css.js +651 -0
- package/dist/theme-css.js.map +1 -0
- package/dist/tone-classes.d.ts +14 -0
- package/dist/tone-classes.d.ts.map +1 -0
- package/dist/tone-classes.js +30 -0
- package/dist/tone-classes.js.map +1 -0
- package/dist/write-if-changed.d.ts +21 -0
- package/dist/write-if-changed.d.ts.map +1 -0
- package/dist/write-if-changed.js +58 -0
- package/dist/write-if-changed.js.map +1 -0
- package/package.json +16 -7
package/dist/serialize.js
CHANGED
|
@@ -1,25 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
};
|
|
1
|
+
import { walkInline, applyTextFlags, renderLeaf, indent, isSafeHref, apiPageLayout, apiSymbolLevels, } from "@dogsbay/serialize-core";
|
|
2
|
+
import { parseInline } from "@dogsbay/format-dogsbay-md";
|
|
3
|
+
import { decorateDiff, hasDiffMarks } from "./diff-decoration.js";
|
|
4
|
+
import { TONE_CLASSES } from "./tone-classes.js";
|
|
5
|
+
import { apiBlockHtml, apiPropertiesHtml } from "./api-markdown.js";
|
|
6
|
+
export { TONE_CLASSES };
|
|
23
7
|
const COMPONENT_IMPORTS = {
|
|
24
8
|
"markdown-example": [
|
|
25
9
|
'import MarkdownExample from "@ui/markdown-example/MarkdownExample.astro";',
|
|
@@ -55,6 +39,9 @@ const COMPONENT_IMPORTS = {
|
|
|
55
39
|
],
|
|
56
40
|
diagram: ['import Diagram from "@ui/diagram/Diagram.astro";'],
|
|
57
41
|
youtube: ['import YouTube from "@ui/youtube/YouTube.astro";'],
|
|
42
|
+
badge: ['import Badge from "@ui/badge/Badge.astro";'],
|
|
43
|
+
kbd: ['import Kbd from "@ui/kbd/Kbd.astro";'],
|
|
44
|
+
files: ['import FileTree from "@ui/files/FileTree.astro";'],
|
|
58
45
|
"math-block": ['import MathKatex from "@ui/math/MathKatex.astro";'],
|
|
59
46
|
image: ['import BlockImage from "@ui/image/Image.astro";'],
|
|
60
47
|
"api-class": ['import ApiSymbol from "@ui/api-symbol/ApiSymbol.astro";'],
|
|
@@ -96,6 +83,9 @@ const COMPONENT_IMPORTS = {
|
|
|
96
83
|
"link-card": [
|
|
97
84
|
'import LinkCard from "@ui/link-card/LinkCard.astro";',
|
|
98
85
|
],
|
|
86
|
+
"link-button": [
|
|
87
|
+
'import Button from "@ui/button/Button.astro";',
|
|
88
|
+
],
|
|
99
89
|
avatar: [
|
|
100
90
|
'import Avatar from "@ui/avatar/Avatar.astro";',
|
|
101
91
|
],
|
|
@@ -109,12 +99,50 @@ export function escapeExpr(s) {
|
|
|
109
99
|
// JSON.stringify handles all special characters (quotes, backslashes, newlines, etc.)
|
|
110
100
|
// and produces a valid JS string literal. This avoids template literal edge cases
|
|
111
101
|
// where backtick + brace combinations create false expression boundaries.
|
|
112
|
-
|
|
102
|
+
//
|
|
103
|
+
// `<` becomes \u003c: Astro parses the template as HTML before the expression is
|
|
104
|
+
// JavaScript, so a `<script>` inside a string (a code block showing one, say) would
|
|
105
|
+
// be taken for a real script tag and its text compiled as JavaScript. Vite's
|
|
106
|
+
// dependency scan then fails to parse it and a production build breaks.
|
|
107
|
+
return JSON.stringify(s).replace(/</g, "\\u003c");
|
|
113
108
|
}
|
|
114
109
|
/** Escape for use in an HTML attribute value (double-quoted). */
|
|
115
110
|
export function escapeAttr(s) {
|
|
116
111
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
117
112
|
}
|
|
113
|
+
export function componentAttr(name, value) {
|
|
114
|
+
return /[&"<>]/.test(value)
|
|
115
|
+
? ` ${name}={${escapeExpr(value)}}`
|
|
116
|
+
: ` ${name}="${value}"`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Render extra attributes from an inline element's `attrs` field
|
|
120
|
+
* (links and images today; spans in a future phase) as ` key="val"`
|
|
121
|
+
* pairs prefixed with a leading space, suitable for splicing into an
|
|
122
|
+
* open tag immediately after the element's first-class attrs (href,
|
|
123
|
+
* src, etc.). `id`, `class`, and `style` are first-class; any other
|
|
124
|
+
* key passes through verbatim.
|
|
125
|
+
*/
|
|
126
|
+
/** `{…}` attributes on a COMPONENT (Badge, Button): `componentAttr`, which Astro does not entity-decode. */
|
|
127
|
+
function renderInlineComponentAttrs(attrs) {
|
|
128
|
+
if (!attrs)
|
|
129
|
+
return "";
|
|
130
|
+
return Object.entries(attrs)
|
|
131
|
+
.filter(([, value]) => value !== undefined)
|
|
132
|
+
.map(([key, value]) => componentAttr(key, String(value)))
|
|
133
|
+
.join("");
|
|
134
|
+
}
|
|
135
|
+
function renderInlineElementAttrs(attrs) {
|
|
136
|
+
if (!attrs)
|
|
137
|
+
return "";
|
|
138
|
+
const parts = [];
|
|
139
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
140
|
+
if (value === undefined)
|
|
141
|
+
continue;
|
|
142
|
+
parts.push(` ${key}="${escapeAttr(String(value))}"`);
|
|
143
|
+
}
|
|
144
|
+
return parts.join("");
|
|
145
|
+
}
|
|
118
146
|
/** Escape text for use directly in an Astro template (not inside an expression). */
|
|
119
147
|
export function escapeTemplate(s) {
|
|
120
148
|
let result = "";
|
|
@@ -149,6 +177,13 @@ export function treeToAstro(nodes, options) {
|
|
|
149
177
|
mode: options?.mode ?? "template",
|
|
150
178
|
imageOptimization: options?.imageOptimization ?? false,
|
|
151
179
|
codeBlockTitle: options?.codeBlockTitle ?? true,
|
|
180
|
+
imageCaptions: options?.imageCaptions ?? false,
|
|
181
|
+
combinedPrefix: options?.combinedPrefix ?? "",
|
|
182
|
+
// Mixed prose+endpoint pages render endpoints as embedded blocks;
|
|
183
|
+
// pages that are nothing but endpoints/headings keep the
|
|
184
|
+
// full-viewport operation layout (the OpenAPI page shape).
|
|
185
|
+
embeddedEndpoints: apiPageLayout(nodes).embeddedEndpoints,
|
|
186
|
+
apiSymbolLevels: apiSymbolLevels(nodes),
|
|
152
187
|
};
|
|
153
188
|
// Pre-scan for inline `icon` nodes anywhere in the tree.
|
|
154
189
|
// `inlineNodeToTemplate` is intentionally pure (no ctx) — it
|
|
@@ -157,9 +192,9 @@ export function treeToAstro(nodes, options) {
|
|
|
157
192
|
// (card.props.icon) registers `icon` directly at the call
|
|
158
193
|
// site; this pre-scan picks up the inline `:icon[name]`
|
|
159
194
|
// directives the writer can drop into prose.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
195
|
+
// Badges and inline buttons are components too, registered the same way.
|
|
196
|
+
for (const key of inlineComponentsIn(nodes))
|
|
197
|
+
ctx.imports.add(key);
|
|
163
198
|
const body = nodes.map((n) => nodeToAstro(n, ctx)).filter(Boolean).join("\n");
|
|
164
199
|
const imports = [];
|
|
165
200
|
const seen = new Set();
|
|
@@ -181,7 +216,18 @@ export function treeToAstro(nodes, options) {
|
|
|
181
216
|
};
|
|
182
217
|
}
|
|
183
218
|
// ─── Node dispatcher ──────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* Dispatch + diff decoration: nodes carrying DiffTree marks (from
|
|
221
|
+
* @dogsbay/tree-diff, read structurally) decorate the rendered
|
|
222
|
+
* snippet; unmarked nodes render byte-identically to before.
|
|
223
|
+
*/
|
|
184
224
|
function nodeToAstro(node, ctx) {
|
|
225
|
+
const rendered = nodeToAstroBase(node, ctx);
|
|
226
|
+
if (!rendered || !hasDiffMarks(node))
|
|
227
|
+
return rendered;
|
|
228
|
+
return decorateDiff(node, rendered, (peer) => nodeToAstroBase(peer, ctx));
|
|
229
|
+
}
|
|
230
|
+
function nodeToAstroBase(node, ctx) {
|
|
185
231
|
switch (node.type) {
|
|
186
232
|
case "prose":
|
|
187
233
|
return proseToAstro(node, ctx);
|
|
@@ -193,6 +239,10 @@ function nodeToAstro(node, ctx) {
|
|
|
193
239
|
return codeToAstro(node, ctx);
|
|
194
240
|
case "callout":
|
|
195
241
|
return calloutToAstro(node, ctx);
|
|
242
|
+
case "files":
|
|
243
|
+
return filesToAstro(node, ctx);
|
|
244
|
+
case "container":
|
|
245
|
+
return genericContainerToAstro(node, ctx);
|
|
196
246
|
case "details":
|
|
197
247
|
return detailsToAstro(node, ctx);
|
|
198
248
|
case "tabs":
|
|
@@ -212,14 +262,23 @@ function nodeToAstro(node, ctx) {
|
|
|
212
262
|
return wrapBlock("li", "", node, leafContent(node, ctx));
|
|
213
263
|
case "blockquote":
|
|
214
264
|
return wrapBlock("blockquote", "border-l-4 border-border pl-4 italic text-muted-foreground", node, childrenToAstro(node, ctx));
|
|
265
|
+
// A transparent grouping wrapper: renders its children inside a plain
|
|
266
|
+
// <div> and adds no semantics of its own. Exists so a caller can carry
|
|
267
|
+
// an attribute (a class, a data-* flag) for a whole run of blocks —
|
|
268
|
+
// the release comparison marks a wholly added/removed PAGE once, at
|
|
269
|
+
// the page level, instead of putting a bar on every block inside it.
|
|
270
|
+
case "group":
|
|
271
|
+
return wrapBlock("div", "", node, childrenToAstro(node, ctx));
|
|
215
272
|
case "hr": {
|
|
216
273
|
const classAttr = mergeClassAttr("my-8 border-border", node.props?.class);
|
|
217
274
|
const passthrough = renderPassthroughAttrs(node.props, ["class"]);
|
|
218
275
|
return `<hr${classAttr}${passthrough} />`;
|
|
219
276
|
}
|
|
220
277
|
case "html":
|
|
221
|
-
// Raw HTML always uses set:html — it may contain anything
|
|
222
|
-
|
|
278
|
+
// Raw HTML always uses set:html — it may contain anything. Callouts inside it
|
|
279
|
+
// (dogsbay-md's HTML table fallback writes `<div role="note" data-callout>`)
|
|
280
|
+
// render as real callouts
|
|
281
|
+
return htmlToAstro(node.html ?? "", ctx);
|
|
223
282
|
case "table":
|
|
224
283
|
return tableToAstro(node, ctx);
|
|
225
284
|
case "thead":
|
|
@@ -270,9 +329,20 @@ function nodeToAstro(node, ctx) {
|
|
|
270
329
|
return exampleToAstro(node, ctx);
|
|
271
330
|
case "figure": {
|
|
272
331
|
const caption = node.props?.caption;
|
|
332
|
+
// A figure of one code block: the caption goes in the code block's header bar,
|
|
333
|
+
// which then is the figure's caption
|
|
334
|
+
const only = node.children?.length === 1 ? node.children[0] : undefined;
|
|
335
|
+
if (caption && only?.type === "code") {
|
|
336
|
+
return codeToAstro(only, ctx, {
|
|
337
|
+
caption: captionToAstro(caption, ctx),
|
|
338
|
+
// The figure's attributes, except those the code block has itself (an id twice is invalid)
|
|
339
|
+
attrs: renderPassthroughAttrs(node.props, ["caption", "class", ...Object.keys(only.props ?? {})]),
|
|
340
|
+
classes: ["my-4", node.props?.class].filter(Boolean).join(" "),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
273
343
|
const inner = childrenToAstro(node, ctx);
|
|
274
344
|
const body = caption
|
|
275
|
-
? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${
|
|
345
|
+
? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${captionToAstro(caption, ctx)}</figcaption>`
|
|
276
346
|
: inner;
|
|
277
347
|
return wrapBlock("figure", "my-4", node, body, ["caption"]);
|
|
278
348
|
}
|
|
@@ -297,6 +367,8 @@ function nodeToAstro(node, ctx) {
|
|
|
297
367
|
return standaloneCardToAstro(node, ctx);
|
|
298
368
|
case "link-card":
|
|
299
369
|
return linkCardToAstro(node, ctx);
|
|
370
|
+
case "link-button":
|
|
371
|
+
return linkButtonToAstro(node, ctx);
|
|
300
372
|
case "accordion":
|
|
301
373
|
return accordionToAstro(node, ctx);
|
|
302
374
|
case "accordion-item":
|
|
@@ -351,6 +423,28 @@ ${childrenToAstro(node, ctx)}
|
|
|
351
423
|
${childrenToAstro(node, ctx)}
|
|
352
424
|
</GridItem>`;
|
|
353
425
|
}
|
|
426
|
+
case "related": {
|
|
427
|
+
// #384 R3: AsciiDoc [role="_additional-resources"] + .Title + list folds
|
|
428
|
+
// to this semantic node (maps to DITA <related-links>). Render a titled
|
|
429
|
+
// section, not a TOC heading. The title is a caption (block title), so a
|
|
430
|
+
// styled <p>, not an <h2>.
|
|
431
|
+
const title = node.props?.title;
|
|
432
|
+
const classAttr = mergeClassAttr("related-resources", node.props?.class);
|
|
433
|
+
const passthrough = renderPassthroughAttrs(node.props, ["title", "class"]);
|
|
434
|
+
const titleHtml = title
|
|
435
|
+
? `<p class="related-resources-title">${escapeTemplate(title)}</p>\n`
|
|
436
|
+
: "";
|
|
437
|
+
return `<section${classAttr}${passthrough}>\n${titleHtml}${childrenToAstro(node, ctx)}\n</section>`;
|
|
438
|
+
}
|
|
439
|
+
case "mdx-raw": {
|
|
440
|
+
// Preserved-but-unmapped MDX component (format-mdx never-drop; see
|
|
441
|
+
// docs-dev/format-mdx-adapters.md). Rendered as a visible source
|
|
442
|
+
// block — the content must not silently disappear from the HTML
|
|
443
|
+
// output. Map the component (adapter / mdx.components config) to
|
|
444
|
+
// upgrade it to a real widget.
|
|
445
|
+
const source = String(node.props?.source ?? "");
|
|
446
|
+
return `<pre class="mdx-raw my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code>{${escapeExpr(source)}}</code></pre>`;
|
|
447
|
+
}
|
|
354
448
|
default:
|
|
355
449
|
if (node.html)
|
|
356
450
|
return `<Fragment set:html={${escapeExpr(node.html)}} />`;
|
|
@@ -360,37 +454,74 @@ ${childrenToAstro(node, ctx)}
|
|
|
360
454
|
}
|
|
361
455
|
}
|
|
362
456
|
// ─── Inline rendering ─────────────────────────────────────────────
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
457
|
+
/**
|
|
458
|
+
* Astro's two inline dialects, as emitters over serialize-core's shared walk.
|
|
459
|
+
*
|
|
460
|
+
* These were the THIRD and FOURTH copies of the 11-variant switch (obsidian and
|
|
461
|
+
* dogsbay-md's html fallback were the other two), and the duplication is
|
|
462
|
+
* precisely why `highlight` was missing from BOTH of them: adding an arm meant
|
|
463
|
+
* remembering four places, so `:highlight[…]` rendered as nothing at all on
|
|
464
|
+
* every Astro page. The walk is now shared, so a variant is handled in one
|
|
465
|
+
* place per format instead of being silently skippable.
|
|
466
|
+
*
|
|
467
|
+
* Only the leaf syntax differs between the two maps: template mode escapes for
|
|
468
|
+
* Astro's `{}` and emits real components (`<Icon>`, `<Fragment set:html>`),
|
|
469
|
+
* hybrid mode escapes for plain HTML and emits inert markup.
|
|
470
|
+
*/
|
|
471
|
+
/** Canonical highlight styling — keep in sync with InlineRenderer.astro. */
|
|
472
|
+
const HIGHLIGHT_CLASS = "rounded-sm bg-yellow-200/60 px-0.5 dark:bg-yellow-500/30";
|
|
473
|
+
function templateInlineEmitters() {
|
|
474
|
+
return {
|
|
475
|
+
text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
|
|
476
|
+
bold: (s) => `<strong>${s}</strong>`,
|
|
477
|
+
italic: (s) => `<em>${s}</em>`,
|
|
478
|
+
strike: (s) => `<s>${s}</s>`,
|
|
479
|
+
}),
|
|
480
|
+
link: (node, ctx) => {
|
|
481
|
+
// A link node an importer built directly never passed markdown-it's
|
|
482
|
+
// scheme check: a `javascript:` href keeps its text and loses the link.
|
|
483
|
+
if (!isSafeHref(node.href)) {
|
|
484
|
+
ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
|
|
485
|
+
return ctx.recurse(node.children);
|
|
486
|
+
}
|
|
487
|
+
const titleAttr = node.title ? componentAttr("title", node.title) : "";
|
|
488
|
+
const extraAttrs = renderInlineElementAttrs(node.attrs);
|
|
489
|
+
// An inline `:button`: the Button component, small, keeping its variant ("primary" is the filled default).
|
|
490
|
+
if (node.button) {
|
|
491
|
+
const variant = buttonVariant(node.button.variant);
|
|
492
|
+
return `<Button${componentAttr("href", node.href)}${componentAttr("variant", variant)} size="sm"${titleAttr}${renderInlineComponentAttrs(node.attrs)}>${ctx.recurse(node.children)}</Button>`;
|
|
493
|
+
}
|
|
494
|
+
return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
|
|
495
|
+
},
|
|
496
|
+
badge: (node, ctx) => `<Badge${componentAttr("variant", node.variant ?? "default")}${renderInlineComponentAttrs(node.attrs)}>${ctx.escape(node.text)}</Badge>`,
|
|
497
|
+
image: (node, ctx) => {
|
|
498
|
+
if (!isSafeHref(node.src, "image")) {
|
|
499
|
+
ctx.report("image", `unsafe image source dropped (${node.src.slice(0, 40)})`);
|
|
500
|
+
return ctx.escape(node.alt ?? "");
|
|
501
|
+
}
|
|
502
|
+
const extraAttrs = renderInlineElementAttrs(node.attrs);
|
|
503
|
+
return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy"${extraAttrs} />`;
|
|
504
|
+
},
|
|
505
|
+
// Code inherits the emphasis around it (`*using the \`dita\` command*`): the parser
|
|
506
|
+
// flags the code span, and dropping the flags split the italics around the code
|
|
507
|
+
code: (node, ctx) => applyTextFlags(`<code>${ctx.escape(node.text)}</code>`, node, {
|
|
508
|
+
bold: (s) => `<strong>${s}</strong>`,
|
|
509
|
+
italic: (s) => `<em>${s}</em>`,
|
|
510
|
+
strike: (s) => `<s>${s}</s>`,
|
|
511
|
+
}),
|
|
512
|
+
// The arm that did not exist — `:highlight[…]` / `:mark[…]` rendered as
|
|
513
|
+
// nothing at all. The class list mirrors the canonical rendering in
|
|
514
|
+
// `packages/ui/src/content-renderer/InlineRenderer.astro`, so a highlight
|
|
515
|
+
// looks the same however a page is produced. A BARE `<mark>` would have
|
|
516
|
+
// been "fixed" but unstyled: the generated `.docs-prose` stylesheet had no
|
|
517
|
+
// `mark` rule, so browsers paint their default yellow — including on dark
|
|
518
|
+
// pages, where nothing declares `color-scheme`.
|
|
519
|
+
highlight: (node, ctx) => `<mark class="${HIGHLIGHT_CLASS}">${ctx.recurse(node.children)}</mark>`,
|
|
520
|
+
"footnote-ref": (node, ctx) => `<sup><a href="#fn-${escapeAttr(node.label)}" id="fnref-${escapeAttr(node.label)}" class="text-primary hover:underline">[${ctx.escape(node.label)}]</a></sup>`,
|
|
521
|
+
// The Kbd component (keys styled as keycaps); plain <kbd> had no style beyond a monospace font.
|
|
522
|
+
kbd: (node, ctx) => node.keys.map((k) => `<Kbd>${ctx.escape(k)}</Kbd>`).join("+"),
|
|
523
|
+
math: (node, ctx) => `<code class="math-inline">${ctx.escape(node.latex)}</code>`,
|
|
524
|
+
icon: (node) => {
|
|
394
525
|
// Inline icon — emits the platform `<Icon>` component which
|
|
395
526
|
// resolves at build time against Lucide (default) plus any
|
|
396
527
|
// other Iconify pack via the `pack:name` shorthand. Caller
|
|
@@ -404,47 +535,37 @@ function inlineNodeToTemplate(node) {
|
|
|
404
535
|
// to Lucide for everything.
|
|
405
536
|
const fullName = node.library ? `${node.library}:${node.name}` : node.name;
|
|
406
537
|
return `<Icon name="${escapeAttr(fullName)}" class="inline-block size-[1em] align-[-0.125em]" />`;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return "<br />";
|
|
413
|
-
default:
|
|
414
|
-
return "";
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
/** Render inline nodes as HTML string (for hybrid mode set:html). */
|
|
418
|
-
function inlineToHtml(nodes) {
|
|
419
|
-
return nodes.map(inlineNodeToHtml).join("");
|
|
538
|
+
},
|
|
539
|
+
// Raw HTML inline — must use set:html to avoid brace issues
|
|
540
|
+
"html-inline": (node) => `<Fragment set:html={${escapeExpr(node.html)}} />`,
|
|
541
|
+
break: () => "<br />",
|
|
542
|
+
};
|
|
420
543
|
}
|
|
421
|
-
function
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
case "link": {
|
|
544
|
+
function htmlModeInlineEmitters() {
|
|
545
|
+
return {
|
|
546
|
+
...templateInlineEmitters(),
|
|
547
|
+
// Inherited from the template map, this emitted `title={"…"}` — an
|
|
548
|
+
// EXPRESSION, inside a string that becomes a `set:html` value. Astro never
|
|
549
|
+
// parses it as markup, so the reader saw the braces and quotes.
|
|
550
|
+
link: (node, ctx) => {
|
|
551
|
+
if (!isSafeHref(node.href)) {
|
|
552
|
+
ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
|
|
553
|
+
return ctx.recurse(node.children);
|
|
554
|
+
}
|
|
434
555
|
const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
return `<
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
556
|
+
const extraAttrs = renderInlineElementAttrs(node.attrs);
|
|
557
|
+
// No components in HTML mode: a marked link (and a marked span for a badge below).
|
|
558
|
+
const button = node.button ? ` data-button data-variant="${escapeAttr(buttonVariant(node.button.variant))}"` : "";
|
|
559
|
+
return `<a href="${escapeAttr(node.href)}"${titleAttr}${button}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
|
|
560
|
+
},
|
|
561
|
+
kbd: (node, ctx) => node.keys.map((k) => `<kbd>${ctx.escape(k)}</kbd>`).join("+"),
|
|
562
|
+
badge: (node, ctx) => `<span data-badge data-variant="${escapeAttr(node.variant ?? "default")}"${renderInlineElementAttrs(node.attrs)}>${ctx.escape(node.text)}</span>`,
|
|
563
|
+
text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
|
|
564
|
+
bold: (s) => `<strong>${s}</strong>`,
|
|
565
|
+
italic: (s) => `<em>${s}</em>`,
|
|
566
|
+
strike: (s) => `<s>${s}</s>`,
|
|
567
|
+
}),
|
|
568
|
+
icon: (node) => {
|
|
448
569
|
// Hybrid-mode HTML rendering — fall back to a textual
|
|
449
570
|
// shortcode reference when we can't inline the SVG. Real
|
|
450
571
|
// resolution happens in template mode via the <Icon>
|
|
@@ -452,14 +573,19 @@ function inlineNodeToHtml(node) {
|
|
|
452
573
|
// production output.
|
|
453
574
|
const fullName = node.library ? `${node.library}:${node.name}` : node.name;
|
|
454
575
|
return `<span class="dogsbay-icon" data-icon="${escapeAttr(fullName)}"></span>`;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
576
|
+
},
|
|
577
|
+
"html-inline": (node) => node.html,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const TEMPLATE_INLINE = templateInlineEmitters();
|
|
581
|
+
const HTML_INLINE = htmlModeInlineEmitters();
|
|
582
|
+
/** Render inline nodes as Astro template markup (clean, editable). */
|
|
583
|
+
function inlineToTemplate(nodes) {
|
|
584
|
+
return walkInline(nodes, { emitters: TEMPLATE_INLINE, escape: escapeTemplate });
|
|
585
|
+
}
|
|
586
|
+
/** Render inline nodes as HTML string (for hybrid mode set:html). */
|
|
587
|
+
function inlineToHtml(nodes) {
|
|
588
|
+
return walkInline(nodes, { emitters: HTML_INLINE, escape: escapeHtml });
|
|
463
589
|
}
|
|
464
590
|
/** Render inline content in the current mode. */
|
|
465
591
|
function renderInline(nodes, ctx) {
|
|
@@ -467,6 +593,8 @@ function renderInline(nodes, ctx) {
|
|
|
467
593
|
const html = inlineToHtml(nodes);
|
|
468
594
|
return `<Fragment set:html={${escapeExpr(html)}} />`;
|
|
469
595
|
}
|
|
596
|
+
// Inline parsed from a prop (a caption) is not in the page-level pre-scan; its components are imported here.
|
|
597
|
+
inlineComponentsIn([{ type: "paragraph", inline: nodes }], ctx.imports);
|
|
470
598
|
return inlineToTemplate(nodes);
|
|
471
599
|
}
|
|
472
600
|
/** Render an HTML string in the current mode. */
|
|
@@ -487,18 +615,24 @@ function headingToAstro(node, ctx) {
|
|
|
487
615
|
// `id` takes precedence over `slug` (slug is auto-generated, id is explicit user override)
|
|
488
616
|
const id = node.props?.id ?? node.props?.slug ?? "";
|
|
489
617
|
const anchor = id
|
|
490
|
-
? `<a href="#${escapeAttr(id)}" class="ml-2 text-muted-foreground opacity-0 group-hover:opacity-100 no-underline" aria-hidden="true" tabindex="-1">¶</a>`
|
|
618
|
+
? `<a href="#${escapeAttr(id)}" class="ml-2 text-muted-foreground opacity-0 group-hover:opacity-100 no-underline" aria-hidden="true" tabindex="-1" data-pagefind-ignore>¶</a>`
|
|
491
619
|
: "";
|
|
492
620
|
const idAttr = id ? ` id="${escapeAttr(id)}"` : "";
|
|
493
|
-
const
|
|
494
|
-
const
|
|
495
|
-
"level", "slug", "id", "class", "text",
|
|
496
|
-
]);
|
|
621
|
+
const consumed = ["level", "slug", "id", "class", "text"];
|
|
622
|
+
const userClass = node.props?.class;
|
|
497
623
|
if (ctx.mode === "template" && node.inline) {
|
|
624
|
+
// Real Astro markup: expressions are parsed.
|
|
625
|
+
const classAttr = mergeClassAttr("group scroll-mt-20", userClass);
|
|
626
|
+
const passthrough = renderPassthroughAttrs(node.props, consumed);
|
|
498
627
|
const text = inlineToTemplate(node.inline);
|
|
499
628
|
return `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
|
|
500
629
|
}
|
|
501
|
-
// Hybrid or no inline —
|
|
630
|
+
// Hybrid or no inline — the whole heading becomes a set:html STRING, where an
|
|
631
|
+
// expression would be literal text. Every MDX/Mintlify heading takes this
|
|
632
|
+
// branch (that importer emits `props.text` + children, never `inline`), so
|
|
633
|
+
// this is the common path, not an edge case.
|
|
634
|
+
const classAttr = mergeClassAttr("group scroll-mt-20", userClass, "html");
|
|
635
|
+
const passthrough = renderPassthroughAttrs(node.props, consumed, "html");
|
|
502
636
|
const text = node.inline ? inlineToHtml(node.inline) : node.props?.text ?? "";
|
|
503
637
|
const html = `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
|
|
504
638
|
return `<Fragment set:html={${escapeExpr(html)}} />`;
|
|
@@ -515,15 +649,28 @@ function paragraphToAstro(node, ctx) {
|
|
|
515
649
|
if (meaningful.length === 1 && meaningful[0].type === "image") {
|
|
516
650
|
const img = meaningful[0];
|
|
517
651
|
ctx.imports.add("image");
|
|
652
|
+
// imageMap is keyed by the unprefixed publicPath built from
|
|
653
|
+
// `import.meta.glob("/src/assets/**", ...)` at runtime, so
|
|
654
|
+
// strip the URL prefix back off if rewriteTreeImageSrcs has
|
|
655
|
+
// already applied it. Keeps the optimized-image lookup working
|
|
656
|
+
// alongside the prefix-on-emit fix.
|
|
657
|
+
const lookupKey = ctx.combinedPrefix && img.src.startsWith(`${ctx.combinedPrefix}/`)
|
|
658
|
+
? img.src.slice(ctx.combinedPrefix.length)
|
|
659
|
+
: img.src;
|
|
518
660
|
const imgDataAttr = ctx.imageOptimization
|
|
519
|
-
? ` imageData={imageMap[${escapeExpr(
|
|
661
|
+
? ` imageData={imageMap[${escapeExpr(lookupKey)}]}`
|
|
520
662
|
: "";
|
|
521
663
|
// Forward arbitrary `data-*` / `aria-*` attrs that plugins
|
|
522
664
|
// may have attached to the wrapping paragraph (e.g.
|
|
523
665
|
// @dogsbay/plugin-image-zoom tags `data-zoomable="true"` so
|
|
524
666
|
// the runtime can attach handlers without scanning the DOM).
|
|
525
667
|
const passthrough = renderPassthroughAttrs(node.props, ["class"]);
|
|
526
|
-
|
|
668
|
+
// Author-supplied {.class #id ...} on the image itself
|
|
669
|
+
// (Phase 1.5 of plans/inline-attrs.md).
|
|
670
|
+
const imageAttrs = renderInlineElementAttrs(img.attrs);
|
|
671
|
+
// `content.imageCaptions: title`: the figure form of BlockImage, captioned by the title (as dogsbay-ssg).
|
|
672
|
+
const figure = ctx.imageCaptions && img.title ? ` block${componentAttr("title", img.title)}` : "";
|
|
673
|
+
return `<BlockImage${componentAttr("src", img.src)}${componentAttr("alt", img.alt ?? "")}${figure}${imageAttrs}${passthrough}${imgDataAttr} />`;
|
|
527
674
|
}
|
|
528
675
|
}
|
|
529
676
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
@@ -550,7 +697,7 @@ function paragraphToAstro(node, ctx) {
|
|
|
550
697
|
}).join("");
|
|
551
698
|
return `<p${classAttr}${passthrough} set:html={${escapeExpr(html)}} />`;
|
|
552
699
|
}
|
|
553
|
-
function codeToAstro(node, ctx) {
|
|
700
|
+
function codeToAstro(node, ctx, figure) {
|
|
554
701
|
// Code content may live on props.code (Starlight convention) or node.html
|
|
555
702
|
// (dogsbay-md convention). Same for lang — Starlight uses props.lang,
|
|
556
703
|
// dogsbay-md uses props.language.
|
|
@@ -561,34 +708,48 @@ function codeToAstro(node, ctx) {
|
|
|
561
708
|
?? node.html
|
|
562
709
|
?? "";
|
|
563
710
|
const title = node.props?.title;
|
|
564
|
-
|
|
711
|
+
// The fence's documented `mark`, `ins` and `del` are CodeRich's highlights, diffAdd and diffRemove.
|
|
712
|
+
const lineSpec = (...values) => values.filter((v) => typeof v === "string" || typeof v === "number").map(String).filter(Boolean).join(",") || undefined;
|
|
713
|
+
const highlights = lineSpec(node.props?.highlights, node.props?.mark);
|
|
565
714
|
const lineNumbers = node.props?.lineNumbers;
|
|
566
|
-
const
|
|
567
|
-
const
|
|
715
|
+
const copyText = node.props?.copyText;
|
|
716
|
+
const diffAdd = lineSpec(node.props?.diffAdd, node.props?.ins);
|
|
717
|
+
const diffRemove = lineSpec(node.props?.diffRemove, node.props?.del);
|
|
718
|
+
const isRich = lineNumbers || highlights || code.includes("[!code") || Boolean(diffAdd || diffRemove);
|
|
719
|
+
const classAttr = mergeClassAttr(figure?.classes ?? "", node.props?.class);
|
|
720
|
+
const figureAttrs = figure?.attrs ?? "";
|
|
721
|
+
// The component with its caption slot filled, or self-closing
|
|
722
|
+
const close = (tag, open) => figure ? `${open}>\n<Fragment slot="caption">${figure.caption}</Fragment>\n</${tag}>` : `${open} />`;
|
|
568
723
|
const consumed = [
|
|
569
724
|
"lang", "language", "code", "title", "highlights", "lineNumbers",
|
|
570
|
-
"class", "ins", "del", "mark", "collapse",
|
|
725
|
+
"class", "ins", "del", "mark", "collapse", "copyText", "diffAdd", "diffRemove",
|
|
571
726
|
];
|
|
572
727
|
const passthrough = renderPassthroughAttrs(node.props, consumed);
|
|
573
728
|
if (isRich) {
|
|
574
729
|
ctx.imports.add("code-rich");
|
|
575
730
|
const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
|
|
576
731
|
if (title)
|
|
577
|
-
attrs.push(
|
|
732
|
+
attrs.push(componentAttr("title", title).trimStart());
|
|
578
733
|
if (lineNumbers)
|
|
579
734
|
attrs.push("lineNumbers");
|
|
580
735
|
if (highlights)
|
|
581
736
|
attrs.push(`highlights="${escapeAttr(highlights)}"`);
|
|
582
|
-
|
|
737
|
+
if (copyText !== undefined)
|
|
738
|
+
attrs.push(`copyText={${escapeExpr(copyText)}}`);
|
|
739
|
+
if (diffAdd)
|
|
740
|
+
attrs.push(`diffAdd="${escapeAttr(diffAdd)}"`);
|
|
741
|
+
if (diffRemove)
|
|
742
|
+
attrs.push(`diffRemove="${escapeAttr(diffRemove)}"`);
|
|
743
|
+
return close("CodeRich", `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
|
|
583
744
|
}
|
|
584
745
|
ctx.imports.add("code");
|
|
585
746
|
const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
|
|
586
747
|
if (title)
|
|
587
|
-
attrs.push(
|
|
748
|
+
attrs.push(componentAttr("title", title).trimStart());
|
|
588
749
|
if (ctx.codeBlockTitle !== true) {
|
|
589
750
|
attrs.push(`showTitle="${ctx.codeBlockTitle}"`);
|
|
590
751
|
}
|
|
591
|
-
return `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough}
|
|
752
|
+
return close("CodeBlock", `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
|
|
592
753
|
}
|
|
593
754
|
// Map GitHub alert names (and other aliases) to Alert component variants.
|
|
594
755
|
// The Alert variant palette follows MkDocs Material; not every GitHub name
|
|
@@ -600,6 +761,71 @@ const CALLOUT_VARIANT_MAP = {
|
|
|
600
761
|
hint: "tip",
|
|
601
762
|
attention: "warning",
|
|
602
763
|
};
|
|
764
|
+
// Default title shown when a callout has no explicit one, keyed by the type
|
|
765
|
+
// the author wrote: aliases share a look but keep their own name (`:::info` is
|
|
766
|
+
// "Info", not the "Important" of the variant it shares). The same labels as
|
|
767
|
+
// dogsbay-ssg (src/render/icons.ts CALLOUTS). Anything else: the type, capitalised.
|
|
768
|
+
const CALLOUT_LABELS = {
|
|
769
|
+
note: "Note",
|
|
770
|
+
info: "Info",
|
|
771
|
+
tip: "Tip",
|
|
772
|
+
hint: "Hint",
|
|
773
|
+
success: "Success",
|
|
774
|
+
important: "Important",
|
|
775
|
+
warning: "Warning",
|
|
776
|
+
attention: "Attention",
|
|
777
|
+
caution: "Caution",
|
|
778
|
+
danger: "Danger",
|
|
779
|
+
error: "Error",
|
|
780
|
+
failure: "Failure",
|
|
781
|
+
question: "Question",
|
|
782
|
+
bug: "Bug",
|
|
783
|
+
example: "Example",
|
|
784
|
+
abstract: "Abstract",
|
|
785
|
+
quote: "Quote",
|
|
786
|
+
};
|
|
787
|
+
const CALLOUT_OPEN = /<div role="note" data-callout="([^"]*)"(?: data-title="([^"]*)")?>/g;
|
|
788
|
+
/**
|
|
789
|
+
* Raw HTML as set:html fragments, with each marked callout div rendered by
|
|
790
|
+
* calloutToAstro. The fragments around a callout may hold unbalanced tags
|
|
791
|
+
* (`<table><tr><td>` before, `</td></tr></table>` after); set:html writes
|
|
792
|
+
* them verbatim, so the page's HTML is whole again.
|
|
793
|
+
*/
|
|
794
|
+
function htmlToAstro(html, ctx) {
|
|
795
|
+
const raw = (s) => (s ? `<Fragment set:html={${escapeExpr(s)}} />` : "");
|
|
796
|
+
const out = [];
|
|
797
|
+
let rest = html;
|
|
798
|
+
for (;;) {
|
|
799
|
+
CALLOUT_OPEN.lastIndex = 0;
|
|
800
|
+
const open = CALLOUT_OPEN.exec(rest);
|
|
801
|
+
if (!open)
|
|
802
|
+
break;
|
|
803
|
+
// The callout ends at its matching </div>
|
|
804
|
+
const divs = /<div\b|<\/div>/g;
|
|
805
|
+
divs.lastIndex = open.index + open[0].length;
|
|
806
|
+
let depth = 1;
|
|
807
|
+
let end = -1;
|
|
808
|
+
for (let m = divs.exec(rest); m; m = divs.exec(rest)) {
|
|
809
|
+
depth += m[0] === "</div>" ? -1 : 1;
|
|
810
|
+
if (depth === 0) {
|
|
811
|
+
end = m.index;
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (end < 0)
|
|
816
|
+
break;
|
|
817
|
+
const unescape = (s) => s.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
818
|
+
out.push(raw(rest.slice(0, open.index)));
|
|
819
|
+
out.push(calloutToAstro({
|
|
820
|
+
type: "callout",
|
|
821
|
+
props: { type: unescape(open[1]), ...(open[2] !== undefined ? { title: unescape(open[2]) } : {}) },
|
|
822
|
+
children: [{ type: "html", html: rest.slice(open.index + open[0].length, end) }],
|
|
823
|
+
}, ctx));
|
|
824
|
+
rest = rest.slice(end + "</div>".length);
|
|
825
|
+
}
|
|
826
|
+
out.push(raw(rest));
|
|
827
|
+
return out.filter(Boolean).join("\n") || raw(html);
|
|
828
|
+
}
|
|
603
829
|
function calloutToAstro(node, ctx) {
|
|
604
830
|
ctx.imports.add("callout");
|
|
605
831
|
// Callout variant may live on props.variant (Starlight / MkDocs importers)
|
|
@@ -608,9 +834,18 @@ function calloutToAstro(node, ctx) {
|
|
|
608
834
|
?? node.props?.type
|
|
609
835
|
?? "note").toLowerCase();
|
|
610
836
|
const variant = CALLOUT_VARIANT_MAP[rawVariant] ?? rawVariant;
|
|
611
|
-
|
|
837
|
+
// When the source gives no explicit title, fall back to the type label
|
|
838
|
+
// (Note / Warning / …) instead of an empty <AlertTitle> — AsciiDoc
|
|
839
|
+
// admonitions and GitHub-style alerts both show the type next to the icon,
|
|
840
|
+
// and an empty title leaves the icon standing alone. Issue #004.
|
|
841
|
+
const explicitTitle = node.props?.title;
|
|
842
|
+
// The default title names the authored type, not the variant it maps to.
|
|
843
|
+
const title = explicitTitle && explicitTitle.length > 0
|
|
844
|
+
? explicitTitle
|
|
845
|
+
: CALLOUT_LABELS[rawVariant] ?? rawVariant.charAt(0).toUpperCase() + rawVariant.slice(1);
|
|
612
846
|
const icon = node.props?.icon;
|
|
613
|
-
|
|
847
|
+
// The authored type (`hint`, `caution`) beside the variant it shares, as dogsbay-ssg writes it.
|
|
848
|
+
const iconAttr = (icon ? ` icon="${escapeAttr(icon)}"` : "") + (node.props?.["data-type"] === undefined ? ` data-type="${escapeAttr(rawVariant)}"` : "");
|
|
614
849
|
// User classes and passthrough attributes (id, data-*, aria-*, style)
|
|
615
850
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
616
851
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
@@ -629,15 +864,19 @@ ${indentStr(inner, 4)}
|
|
|
629
864
|
* Concatenates (user classes appended) so utility frameworks like Tailwind
|
|
630
865
|
* resolve conflicts via component-internal class-variance-authority / tv().
|
|
631
866
|
*/
|
|
632
|
-
function mergeClassAttr(defaults, userClass) {
|
|
867
|
+
function mergeClassAttr(defaults, userClass, target = "astro") {
|
|
633
868
|
const combined = [defaults, userClass].filter(Boolean).join(" ").trim();
|
|
634
|
-
|
|
869
|
+
if (!combined)
|
|
870
|
+
return "";
|
|
871
|
+
return target === "html"
|
|
872
|
+
? ` class="${escapeAttr(combined)}"`
|
|
873
|
+
: componentAttr("class", combined);
|
|
635
874
|
}
|
|
636
875
|
/**
|
|
637
876
|
* Render attributes not consumed as component props — `id`, `data-*`, `aria-*`,
|
|
638
877
|
* and `style`. These pass through to the rendered element.
|
|
639
878
|
*/
|
|
640
|
-
function renderPassthroughAttrs(props, consumed) {
|
|
879
|
+
function renderPassthroughAttrs(props, consumed, target = "astro") {
|
|
641
880
|
if (!props)
|
|
642
881
|
return "";
|
|
643
882
|
const skip = new Set([...consumed, "source", "children"]);
|
|
@@ -652,9 +891,12 @@ function renderPassthroughAttrs(props, consumed) {
|
|
|
652
891
|
if (value === true) {
|
|
653
892
|
parts.push(key);
|
|
654
893
|
}
|
|
655
|
-
else {
|
|
894
|
+
else if (target === "html") {
|
|
656
895
|
parts.push(`${key}="${escapeAttr(String(value))}"`);
|
|
657
896
|
}
|
|
897
|
+
else {
|
|
898
|
+
parts.push(componentAttr(key, String(value)).trimStart());
|
|
899
|
+
}
|
|
658
900
|
}
|
|
659
901
|
return parts.length ? " " + parts.join(" ") : "";
|
|
660
902
|
}
|
|
@@ -717,21 +959,59 @@ function wrapBlock(tag, defaultClass, node, content, consumed = []) {
|
|
|
717
959
|
return `<${tag}${classAttr}${passthrough}>${sep}${content}${sep}</${tag}>`;
|
|
718
960
|
void needsNewline;
|
|
719
961
|
}
|
|
962
|
+
/**
|
|
963
|
+
* `:::files` around a nested bullet list: FileTree, whose markup (files/markup.ts) dogsbay-ssg
|
|
964
|
+
* shares. An entry is a folder when it has entries or its name ends in "/".
|
|
965
|
+
*/
|
|
966
|
+
function filesToAstro(node, ctx) {
|
|
967
|
+
ctx.imports.add("files");
|
|
968
|
+
const isList = (n) => n.type === "unordered-list" || n.type === "ordered-list";
|
|
969
|
+
const items = (list) => (list?.children ?? [])
|
|
970
|
+
.filter((li) => li.type === "list-item")
|
|
971
|
+
.map((li) => {
|
|
972
|
+
const inline = li.inline ?? (li.children ?? []).find((c) => c.type === "paragraph")?.inline ?? [];
|
|
973
|
+
const sub = (li.children ?? []).find(isList);
|
|
974
|
+
const text = inline.map((n) => ("text" in n && typeof n.text === "string" ? n.text : "")).join("");
|
|
975
|
+
return { label: inlineToHtml(inline), folder: !!sub || /\/\s*$/.test(text), children: items(sub) };
|
|
976
|
+
});
|
|
977
|
+
const classAttr = mergeClassAttr("", node.props?.class);
|
|
978
|
+
// Each list is a tree; anything else in the block (an intro, a note) renders as it is.
|
|
979
|
+
// `<` is escaped in the JSON: a label may hold inline HTML, and a literal `</script>` in an
|
|
980
|
+
// expression breaks Vite's dependency scan (see escapeExpr).
|
|
981
|
+
return (node.children ?? [])
|
|
982
|
+
.map((child) => isList(child)
|
|
983
|
+
? `<FileTree items={${JSON.stringify(items(child)).replace(/</g, "\\u003c")}}${classAttr} />`
|
|
984
|
+
: nodeToAstro(child, ctx))
|
|
985
|
+
.filter(Boolean)
|
|
986
|
+
.join("\n");
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* A `:::name` block with a name no renderer knows (a generic container): its content in a
|
|
990
|
+
* marked <div>, the title (text after the name) first.
|
|
991
|
+
*/
|
|
992
|
+
function genericContainerToAstro(node, ctx) {
|
|
993
|
+
const name = typeof node.meta?.name === "string" ? node.meta.name : "container";
|
|
994
|
+
const title = typeof node.meta?.title === "string" ? `<p class="font-semibold">${escapeTemplate(node.meta.title)}</p>\n` : "";
|
|
995
|
+
const classAttr = mergeClassAttr("", node.props?.class);
|
|
996
|
+
const passthrough = renderPassthroughAttrs(node.props, ["class"]);
|
|
997
|
+
return `<div data-directive="${escapeAttr(name)}"${classAttr}${passthrough}>\n${title}${childrenToAstro(node, ctx)}\n</div>`;
|
|
998
|
+
}
|
|
720
999
|
function detailsToAstro(node, ctx) {
|
|
721
1000
|
ctx.imports.add("details");
|
|
722
|
-
|
|
1001
|
+
// `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
|
|
1002
|
+
const variant = node.props?.variant ?? node.props?.type ?? "note";
|
|
723
1003
|
const title = node.props?.title ?? "Details";
|
|
724
1004
|
const open = node.props?.open;
|
|
725
1005
|
const icon = node.props?.icon;
|
|
726
1006
|
const inner = childrenToAstro(node, ctx);
|
|
727
|
-
const attrs = [`variant="${escapeAttr(variant)}"`,
|
|
1007
|
+
const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", title).trimStart()];
|
|
728
1008
|
if (open)
|
|
729
1009
|
attrs.push("open");
|
|
730
1010
|
if (icon)
|
|
731
1011
|
attrs.push(`icon="${escapeAttr(icon)}"`);
|
|
732
1012
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
733
1013
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
734
|
-
"variant", "title", "open", "icon", "class", "summary",
|
|
1014
|
+
"variant", "type", "title", "open", "icon", "class", "summary",
|
|
735
1015
|
]);
|
|
736
1016
|
return `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>
|
|
737
1017
|
${indentStr(inner, 2)}
|
|
@@ -749,29 +1029,98 @@ function tabsToAstro(node, ctx) {
|
|
|
749
1029
|
return { value, label, node: tab };
|
|
750
1030
|
});
|
|
751
1031
|
const defaultValue = tabItems[0].value;
|
|
1032
|
+
// Diff-trigger indicator: a marked tab panel is invisible until
|
|
1033
|
+
// opened, so its TRIGGER carries a data-diff-tab attribute (styled
|
|
1034
|
+
// as a colored dot by DIFF_CSS) telling the reader WHICH tab to
|
|
1035
|
+
// open. Structural read of @dogsbay/tree-diff marks, same as
|
|
1036
|
+
// diff-decoration; unmarked trees are unaffected.
|
|
1037
|
+
const tabDiffSignal = (tab) => {
|
|
1038
|
+
const own = tab.diff;
|
|
1039
|
+
if (own)
|
|
1040
|
+
return own;
|
|
1041
|
+
const deep = (n) => Boolean(n.diff || n.diffInline) || (n.children ?? []).some(deep);
|
|
1042
|
+
return (tab.children ?? []).some(deep) ? "changed" : undefined;
|
|
1043
|
+
};
|
|
1044
|
+
// The indicator must not live in COLOUR alone (WCAG 1.4.1), and a
|
|
1045
|
+
// background-tinted dot disappears in forced-colors mode. So it is a
|
|
1046
|
+
// GLYPH (shape carries the meaning) plus visually-hidden text, so the
|
|
1047
|
+
// trigger announces "Astro, changed in this comparison" rather than
|
|
1048
|
+
// relying on a `title` screen readers may never speak.
|
|
1049
|
+
const TAB_GLYPH = {
|
|
1050
|
+
added: "+",
|
|
1051
|
+
changed: "•",
|
|
1052
|
+
removed: "−",
|
|
1053
|
+
moved: "→",
|
|
1054
|
+
};
|
|
752
1055
|
const triggers = tabItems
|
|
753
|
-
.map((t) =>
|
|
1056
|
+
.map((t) => {
|
|
1057
|
+
const signal = tabDiffSignal(t.node);
|
|
1058
|
+
if (!signal) {
|
|
1059
|
+
return ` <TabsTrigger${componentAttr("value", t.value)}>${escapeTemplate(t.label)}</TabsTrigger>`;
|
|
1060
|
+
}
|
|
1061
|
+
const glyph = TAB_GLYPH[signal] ?? "•";
|
|
1062
|
+
// The glyph and its announced text are ONE element: "Hide
|
|
1063
|
+
// highlights" hides `.db-tab-mark`, and a sibling sr-only span
|
|
1064
|
+
// would survive that — leaving screen-reader users still hearing
|
|
1065
|
+
// "changed in this comparison" on every tab while the visible
|
|
1066
|
+
// marker is gone (code review, 2026-07-13).
|
|
1067
|
+
const marker = `<span class="db-tab-mark" data-diff-tab="${escapeAttr(signal)}">` +
|
|
1068
|
+
`<span aria-hidden="true">${glyph}</span>` +
|
|
1069
|
+
`<span class="sr-only">${escapeTemplate(signal)} in this comparison</span>` +
|
|
1070
|
+
`</span>`;
|
|
1071
|
+
// `value` MUST match TabsContent's, which is a componentAttr expression:
|
|
1072
|
+
// a title containing `&` was emitted `&` here and `&` there, so the
|
|
1073
|
+
// trigger and its panel no longer paired and the tab rendered empty.
|
|
1074
|
+
// `data-diff-tab` is a plain data attribute — entity escaping is right.
|
|
1075
|
+
return ` <TabsTrigger${componentAttr("value", t.value)} data-diff-tab="${escapeAttr(signal)}">${escapeTemplate(t.label)}<Fragment set:html={${escapeExpr(marker)}} /></TabsTrigger>`;
|
|
1076
|
+
})
|
|
754
1077
|
.join("\n");
|
|
755
1078
|
const contents = tabItems
|
|
756
1079
|
.map((t) => {
|
|
757
1080
|
const inner = childrenToAstro(t.node, ctx);
|
|
758
|
-
return ` <TabsContent
|
|
1081
|
+
return ` <TabsContent${componentAttr("value", t.value)}>\n${indentStr(inner, 4)}\n </TabsContent>`;
|
|
759
1082
|
})
|
|
760
1083
|
.join("\n");
|
|
761
1084
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
762
1085
|
const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
|
|
763
|
-
|
|
1086
|
+
// Groups with the same key switch together (db-tabs). Written `syncKey`, `sync-key` or `group`, as dogsbay-ssg reads it.
|
|
1087
|
+
const syncKey = [node.props?.syncKey, node.props?.["sync-key"], node.props?.group].find((v) => typeof v === "string" && v !== "");
|
|
1088
|
+
const syncAttr = syncKey ? componentAttr("syncKey", syncKey) : "";
|
|
1089
|
+
// Same encoding as the trigger/content `value` it selects — see above.
|
|
1090
|
+
return `<Tabs${componentAttr("defaultValue", defaultValue)}${syncAttr}${classAttr}${passthrough}>
|
|
764
1091
|
<TabsList>
|
|
765
1092
|
${triggers}
|
|
766
1093
|
</TabsList>
|
|
767
1094
|
${contents}
|
|
768
1095
|
</Tabs>`;
|
|
769
1096
|
}
|
|
1097
|
+
/**
|
|
1098
|
+
* A caption is inline Markdown. Plain text renders exactly as before;
|
|
1099
|
+
* formatting (`Sample \`<plugin>\` file`) renders as inline markup.
|
|
1100
|
+
*/
|
|
1101
|
+
function captionInline(caption) {
|
|
1102
|
+
return parseInline(caption);
|
|
1103
|
+
}
|
|
1104
|
+
const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
|
|
1105
|
+
function captionToAstro(caption, ctx) {
|
|
1106
|
+
const nodes = captionInline(caption);
|
|
1107
|
+
return isPlainCaption(nodes)
|
|
1108
|
+
? escapeTemplate(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))
|
|
1109
|
+
: renderInline(nodes, ctx);
|
|
1110
|
+
}
|
|
770
1111
|
function tableToAstro(node, ctx) {
|
|
771
1112
|
ctx.imports.add("table");
|
|
772
1113
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
773
|
-
const
|
|
774
|
-
|
|
1114
|
+
const caption = node.props?.caption;
|
|
1115
|
+
const nodes = caption ? captionInline(caption) : [];
|
|
1116
|
+
const plain = isPlainCaption(nodes);
|
|
1117
|
+
// A plain caption stays the `caption` prop; a formatted one fills the table's caption slot
|
|
1118
|
+
const captionAttr = caption && plain
|
|
1119
|
+
? ` caption={${escapeExpr(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))}}`
|
|
1120
|
+
: "";
|
|
1121
|
+
const captionSlot = caption && !plain ? `<Fragment slot="caption">${renderInline(nodes, ctx)}</Fragment>\n` : "";
|
|
1122
|
+
const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
|
|
1123
|
+
return `<Table${classAttr}${captionAttr}${passthrough}>\n${captionSlot}${childrenToAstro(node, ctx)}\n</Table>`;
|
|
775
1124
|
}
|
|
776
1125
|
function diagramToAstro(node, ctx) {
|
|
777
1126
|
ctx.imports.add("diagram");
|
|
@@ -781,7 +1130,7 @@ function diagramToAstro(node, ctx) {
|
|
|
781
1130
|
const title = node.props?.title;
|
|
782
1131
|
const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
|
|
783
1132
|
if (title)
|
|
784
|
-
attrs.push(
|
|
1133
|
+
attrs.push(componentAttr("title", title).trimStart());
|
|
785
1134
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
786
1135
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
787
1136
|
"lang", "code", "svg", "title", "class",
|
|
@@ -794,7 +1143,7 @@ function youtubeToAstro(node, ctx) {
|
|
|
794
1143
|
const title = node.props?.title ?? "";
|
|
795
1144
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
796
1145
|
const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
|
|
797
|
-
return `<YouTube
|
|
1146
|
+
return `<YouTube${componentAttr("id", id)}${componentAttr("title", title)}${classAttr}${passthrough} />`;
|
|
798
1147
|
}
|
|
799
1148
|
function mathBlockToAstro(node, ctx) {
|
|
800
1149
|
ctx.imports.add("math-block");
|
|
@@ -837,6 +1186,11 @@ function footnoteListToAstro(node, ctx) {
|
|
|
837
1186
|
function footnoteDefToAstro(node, ctx) {
|
|
838
1187
|
const id = node.props?.id ?? "";
|
|
839
1188
|
const backrefId = node.props?.backrefId ?? "";
|
|
1189
|
+
// Block content (a list, code): the blocks, with the backref after them
|
|
1190
|
+
if (node.children?.length) {
|
|
1191
|
+
const backref = backrefId ? `\n<a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
|
|
1192
|
+
return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">\n${childrenToAstro(node, ctx)}${backref}\n</li>`;
|
|
1193
|
+
}
|
|
840
1194
|
if (ctx.mode === "template" && node.inline) {
|
|
841
1195
|
const content = inlineToTemplate(node.inline);
|
|
842
1196
|
const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
|
|
@@ -852,8 +1206,14 @@ function apiSymbolToAstro(node, ctx) {
|
|
|
852
1206
|
const kind = node.props?.kind ?? "func";
|
|
853
1207
|
const signature = node.props?.signature;
|
|
854
1208
|
const bases = node.props?.bases || [];
|
|
855
|
-
|
|
856
|
-
|
|
1209
|
+
// By position, not by type (apiSymbolLevels): a module-level function was
|
|
1210
|
+
// an h3 straight under the page's h1, and a class inside a class an h2.
|
|
1211
|
+
const level = ctx.apiSymbolLevels.get(node) ?? (node.type === "api-class" ? 2 : 3);
|
|
1212
|
+
const attrs = [
|
|
1213
|
+
componentAttr("name", name).trimStart(),
|
|
1214
|
+
componentAttr("kind", kind).trimStart(),
|
|
1215
|
+
`level={${level}}`,
|
|
1216
|
+
];
|
|
857
1217
|
if (signature)
|
|
858
1218
|
attrs.push(`signature={${escapeExpr(signature)}}`);
|
|
859
1219
|
if (bases.length > 0)
|
|
@@ -902,11 +1262,17 @@ function endpointToAstro(node, ctx) {
|
|
|
902
1262
|
const path = props.path ?? "";
|
|
903
1263
|
const baseUrl = props.baseUrl ?? "";
|
|
904
1264
|
const summary = props.summary;
|
|
905
|
-
|
|
1265
|
+
// Descriptions are CommonMark, rendered with raw HTML off: the components
|
|
1266
|
+
// put them in the page with set:html (api-markdown.ts).
|
|
1267
|
+
// (Compared as text first: the card hides a description equal to the
|
|
1268
|
+
// summary, and the rendered HTML never equals it.)
|
|
1269
|
+
const description = typeof props.description === "string" && props.description !== props.summary
|
|
1270
|
+
? apiBlockHtml(props.description)
|
|
1271
|
+
: undefined;
|
|
906
1272
|
const deprecated = props.deprecated === true;
|
|
907
|
-
const parameters = props.parameters ?? [];
|
|
908
|
-
const requestBody = props.requestBody ?? [];
|
|
909
|
-
const responses = props.responses ?? [];
|
|
1273
|
+
const parameters = apiPropertiesHtml(props.parameters) ?? [];
|
|
1274
|
+
const requestBody = apiPropertiesHtml(props.requestBody) ?? [];
|
|
1275
|
+
const responses = (props.responses ?? []).map((r) => r && Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r);
|
|
910
1276
|
const codeSamples = props.codeSamples ?? [];
|
|
911
1277
|
const requestBodyExample = props.requestBodyExample;
|
|
912
1278
|
const cardAttrs = [
|
|
@@ -946,7 +1312,9 @@ function endpointToAstro(node, ctx) {
|
|
|
946
1312
|
const sectionsBody = sections.length > 0
|
|
947
1313
|
? `\n${indentStr(sections.join("\n"), 4)}\n `
|
|
948
1314
|
: "";
|
|
949
|
-
// Code column —
|
|
1315
|
+
// Code column — its titles are labels (<p>), not headings: an operation
|
|
1316
|
+
// page has only its h1 above them, so an h3 skipped a level.
|
|
1317
|
+
// Wrapped in <ApiCodePanel> for consistent vertical
|
|
950
1318
|
// rhythm. Code samples first, then a request body example, then one
|
|
951
1319
|
// ExampleBlock per response status that carries a concrete example.
|
|
952
1320
|
// Each ExampleBlock auto-detects language from the response's
|
|
@@ -955,7 +1323,7 @@ function endpointToAstro(node, ctx) {
|
|
|
955
1323
|
const codePanels = [];
|
|
956
1324
|
if (codeSamples.length > 0) {
|
|
957
1325
|
codePanels.push(`<div>\n` +
|
|
958
|
-
` <
|
|
1326
|
+
` <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</p>\n` +
|
|
959
1327
|
` <CodeSamples samples={${JSON.stringify(codeSamples)}} />\n` +
|
|
960
1328
|
`</div>`);
|
|
961
1329
|
}
|
|
@@ -975,7 +1343,8 @@ function endpointToAstro(node, ctx) {
|
|
|
975
1343
|
const codeBody = codePanels.length > 0
|
|
976
1344
|
? `\n${indentStr(codePanels.join("\n"), 4)}\n `
|
|
977
1345
|
: "";
|
|
978
|
-
|
|
1346
|
+
const layoutAttrs = ctx.embeddedEndpoints ? ' variant="embedded"' : "";
|
|
1347
|
+
return (`<ApiLayout${layoutAttrs}>\n` +
|
|
979
1348
|
` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
|
|
980
1349
|
` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
|
|
981
1350
|
`</ApiLayout>`);
|
|
@@ -993,13 +1362,16 @@ function cardsToAstro(node, ctx) {
|
|
|
993
1362
|
if (cardNodes.length > 0) {
|
|
994
1363
|
const cards = cardNodes.map((card) => {
|
|
995
1364
|
const title = card.props?.title ?? "";
|
|
996
|
-
const href = card.props?.href
|
|
1365
|
+
const href = safeHref(card.props?.href);
|
|
997
1366
|
const description = card.props?.description;
|
|
998
1367
|
const icon = card.props?.icon;
|
|
999
1368
|
// Description sources: inline content, children, or props.description.
|
|
1000
|
-
|
|
1369
|
+
// Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
|
|
1370
|
+
const blocks = childrenToAstro(card, ctx);
|
|
1371
|
+
const inner = blocks
|
|
1001
1372
|
|| (card.inline ? inlineToTemplate(card.inline) : "")
|
|
1002
1373
|
|| (description ? escapeTemplate(description) : "");
|
|
1374
|
+
const descTag = blocks ? "div" : "p";
|
|
1003
1375
|
// Icon renders inline before the title — picks up Lucide
|
|
1004
1376
|
// SVG via the `icon` import. Resolved at build time; nothing
|
|
1005
1377
|
// emitted when the name doesn't resolve (silent fallback).
|
|
@@ -1007,13 +1379,16 @@ function cardsToAstro(node, ctx) {
|
|
|
1007
1379
|
// top of the page.
|
|
1008
1380
|
let iconHtml = "";
|
|
1009
1381
|
if (icon) {
|
|
1010
|
-
|
|
1011
|
-
iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
|
|
1382
|
+
iconHtml = cardIconHtml(icon, title, ctx);
|
|
1012
1383
|
}
|
|
1013
1384
|
const titleHtml = title ? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>` : "";
|
|
1014
|
-
|
|
1015
|
-
|
|
1385
|
+
// A description and a body are both shown (the description used to be dropped when a body existed).
|
|
1386
|
+
const lead = blocks && description
|
|
1387
|
+
? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
|
|
1016
1388
|
: "";
|
|
1389
|
+
const desc = lead + (inner
|
|
1390
|
+
? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
|
|
1391
|
+
: "");
|
|
1017
1392
|
const cardDefaultClass = href
|
|
1018
1393
|
? "p-4 hover:border-foreground/20 transition-colors"
|
|
1019
1394
|
: "p-4";
|
|
@@ -1051,12 +1426,15 @@ function cardsToAstro(node, ctx) {
|
|
|
1051
1426
|
function standaloneCardToAstro(node, ctx) {
|
|
1052
1427
|
ctx.imports.add("card");
|
|
1053
1428
|
const title = node.props?.title ?? "";
|
|
1054
|
-
const href = node.props?.href
|
|
1429
|
+
const href = safeHref(node.props?.href);
|
|
1055
1430
|
const description = node.props?.description;
|
|
1056
1431
|
const icon = node.props?.icon;
|
|
1057
|
-
|
|
1432
|
+
// Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
|
|
1433
|
+
const blocks = childrenToAstro(node, ctx);
|
|
1434
|
+
const inner = blocks
|
|
1058
1435
|
|| (node.inline ? inlineToTemplate(node.inline) : "")
|
|
1059
1436
|
|| (description ? escapeTemplate(description) : "");
|
|
1437
|
+
const descTag = blocks ? "div" : "p";
|
|
1060
1438
|
// Icon renders inline before the title (same shape as the
|
|
1061
1439
|
// cards-list path). Resolved at build time via @dogsbay/icons.
|
|
1062
1440
|
let iconHtml = "";
|
|
@@ -1067,9 +1445,13 @@ function standaloneCardToAstro(node, ctx) {
|
|
|
1067
1445
|
const titleHtml = title
|
|
1068
1446
|
? `<h3 class="text-base font-semibold">${escapeTemplate(title)}</h3>`
|
|
1069
1447
|
: "";
|
|
1070
|
-
|
|
1071
|
-
|
|
1448
|
+
// A description and a body are both shown (the description used to be dropped when a body existed).
|
|
1449
|
+
const lead = blocks && description
|
|
1450
|
+
? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
|
|
1072
1451
|
: "";
|
|
1452
|
+
const desc = lead + (inner
|
|
1453
|
+
? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
|
|
1454
|
+
: "");
|
|
1073
1455
|
const cardDefaultClass = href
|
|
1074
1456
|
? "p-4 hover:border-foreground/20 transition-colors"
|
|
1075
1457
|
: "p-4";
|
|
@@ -1089,6 +1471,59 @@ function standaloneCardToAstro(node, ctx) {
|
|
|
1089
1471
|
* If the parser kept rich body children instead of folding into description,
|
|
1090
1472
|
* we fall back to the standalone-card path which can hold arbitrary content.
|
|
1091
1473
|
*/
|
|
1474
|
+
/**
|
|
1475
|
+
* `link-button` — a call-to-action link styled as a button.
|
|
1476
|
+
*
|
|
1477
|
+
* There was no case for this at all, so it fell through to rendering the
|
|
1478
|
+
* node's CHILDREN: `<p>Get started</p>`. The label survived, the href did not,
|
|
1479
|
+
* and nothing warned — the reader saw a stray sentence where the page's
|
|
1480
|
+
* primary call to action should be. Every importer that produces one was
|
|
1481
|
+
* affected; `Button.astro` has taken an `href` all along.
|
|
1482
|
+
*/
|
|
1483
|
+
/**
|
|
1484
|
+
* A card icon is either a registry NAME or a path to an image file.
|
|
1485
|
+
*
|
|
1486
|
+
* `<Icon name>` resolves names and renders NOTHING when the name does not
|
|
1487
|
+
* resolve — so a path handed to it disappeared silently, which is how every
|
|
1488
|
+
* "Related products" card lost its logo without anyone noticing.
|
|
1489
|
+
*/
|
|
1490
|
+
function cardIconHtml(icon, title, ctx) {
|
|
1491
|
+
if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon)) {
|
|
1492
|
+
return `<img src="${escapeAttr(icon)}" alt="" class="size-5 mb-2" loading="lazy" />`;
|
|
1493
|
+
}
|
|
1494
|
+
ctx.imports.add("icon");
|
|
1495
|
+
return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* A button's variant as `Button.astro` names it. The spec's `primary` is its
|
|
1499
|
+
* filled `default` style; `Button.astro` has no `primary`, so it rendered
|
|
1500
|
+
* with no fill at all.
|
|
1501
|
+
*/
|
|
1502
|
+
function buttonVariant(variant) {
|
|
1503
|
+
return !variant || variant === "primary" ? "default" : variant;
|
|
1504
|
+
}
|
|
1505
|
+
function linkButtonToAstro(node, ctx) {
|
|
1506
|
+
ctx.imports.add("link-button");
|
|
1507
|
+
const href = safeHref(node.props?.href);
|
|
1508
|
+
const written = node.props?.variant;
|
|
1509
|
+
const variant = written === undefined ? undefined : buttonVariant(written);
|
|
1510
|
+
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1511
|
+
const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
|
|
1512
|
+
// A button's label is phrasing content, so a lone paragraph child is
|
|
1513
|
+
// unwrapped — `<Button><p>Get started</p></Button>` is invalid markup and
|
|
1514
|
+
// renders with the paragraph's block spacing inside the button.
|
|
1515
|
+
const kids = node.children ?? [];
|
|
1516
|
+
const soleParagraph = kids.length === 1 && kids[0].type === "paragraph" ? kids[0] : undefined;
|
|
1517
|
+
const label = soleParagraph
|
|
1518
|
+
? leafContent(soleParagraph, ctx)
|
|
1519
|
+
: leafContent(node, ctx) || childrenToAstro(node, ctx);
|
|
1520
|
+
return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
|
|
1521
|
+
}
|
|
1522
|
+
/** A card or button `href` from an importer, or "" (no link) when its
|
|
1523
|
+
* scheme could run script: these never passed markdown-it's link check. */
|
|
1524
|
+
function safeHref(href) {
|
|
1525
|
+
return typeof href === "string" && isSafeHref(href) ? href : "";
|
|
1526
|
+
}
|
|
1092
1527
|
function linkCardToAstro(node, ctx) {
|
|
1093
1528
|
const hasRichChildren = (node.children ?? []).length > 0;
|
|
1094
1529
|
if (hasRichChildren) {
|
|
@@ -1097,15 +1532,17 @@ function linkCardToAstro(node, ctx) {
|
|
|
1097
1532
|
ctx.imports.add("link-card");
|
|
1098
1533
|
const title = node.props?.title ?? "";
|
|
1099
1534
|
const description = node.props?.description;
|
|
1100
|
-
const href = node.props?.href
|
|
1101
|
-
const titleAttr =
|
|
1102
|
-
const hrefAttr =
|
|
1103
|
-
const descAttr = description ?
|
|
1535
|
+
const href = safeHref(node.props?.href);
|
|
1536
|
+
const titleAttr = componentAttr("title", title);
|
|
1537
|
+
const hrefAttr = componentAttr("href", href);
|
|
1538
|
+
const descAttr = description ? componentAttr("description", description) : "";
|
|
1539
|
+
const icon = node.props?.icon;
|
|
1540
|
+
const iconAttr = icon ? componentAttr("icon", icon) : "";
|
|
1104
1541
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1105
1542
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1106
1543
|
"title", "description", "href", "icon", "class",
|
|
1107
1544
|
]);
|
|
1108
|
-
return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
|
|
1545
|
+
return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${iconAttr}${classAttr}${passthrough} />`;
|
|
1109
1546
|
}
|
|
1110
1547
|
/**
|
|
1111
1548
|
* Render an `accordion` TreeNode and its `accordion-item` children.
|
|
@@ -1124,9 +1561,9 @@ function accordionToAstro(node, ctx) {
|
|
|
1124
1561
|
const defaultValue = node.props?.defaultValue;
|
|
1125
1562
|
const typeAttr = ` type="${escapeAttr(type)}"`;
|
|
1126
1563
|
const defaultAttr = defaultValue !== undefined
|
|
1127
|
-
?
|
|
1128
|
-
?
|
|
1129
|
-
: `{${JSON.stringify(defaultValue)}}`
|
|
1564
|
+
? typeof defaultValue === "string"
|
|
1565
|
+
? componentAttr("defaultValue", defaultValue)
|
|
1566
|
+
: ` defaultValue={${JSON.stringify(defaultValue)}}`
|
|
1130
1567
|
: "";
|
|
1131
1568
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1132
1569
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
@@ -1142,7 +1579,7 @@ function accordionItemToAstro(node, ctx, standalone) {
|
|
|
1142
1579
|
ctx.imports.add("accordion");
|
|
1143
1580
|
const value = node.props?.value ?? "item-1";
|
|
1144
1581
|
const label = node.props?.label ?? node.props?.title ?? "Item";
|
|
1145
|
-
const valueAttr =
|
|
1582
|
+
const valueAttr = componentAttr("value", value);
|
|
1146
1583
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1147
1584
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1148
1585
|
"value", "label", "title", "class",
|
|
@@ -1169,9 +1606,9 @@ function avatarToAstro(node, ctx) {
|
|
|
1169
1606
|
const src = node.props?.src ?? "";
|
|
1170
1607
|
const alt = node.props?.alt ?? "";
|
|
1171
1608
|
const fallback = node.props?.fallback;
|
|
1172
|
-
const srcAttr = src ?
|
|
1173
|
-
const altAttr =
|
|
1174
|
-
const fbAttr = fallback ?
|
|
1609
|
+
const srcAttr = src ? componentAttr("src", src) : "";
|
|
1610
|
+
const altAttr = componentAttr("alt", alt);
|
|
1611
|
+
const fbAttr = fallback ? componentAttr("fallback", fallback) : "";
|
|
1175
1612
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1176
1613
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1177
1614
|
"src", "alt", "fallback", "class",
|
|
@@ -1192,7 +1629,7 @@ function exampleToAstro(node, ctx) {
|
|
|
1192
1629
|
const source = String(node.props?.source ?? "");
|
|
1193
1630
|
const title = node.props?.title;
|
|
1194
1631
|
const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
|
|
1195
|
-
const titleAttr = title ?
|
|
1632
|
+
const titleAttr = title ? componentAttr("title", title) : "";
|
|
1196
1633
|
const fallbackAttr = showFallback ? " showFallback" : "";
|
|
1197
1634
|
const inner = childrenToAstro(node, ctx);
|
|
1198
1635
|
return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
|
|
@@ -1212,24 +1649,25 @@ function htmlContainerToAstro(node, ctx) {
|
|
|
1212
1649
|
}
|
|
1213
1650
|
// ─── Utilities ────────────────────────────────────────────────────
|
|
1214
1651
|
/**
|
|
1215
|
-
* Render a node's content
|
|
1216
|
-
* parser
|
|
1217
|
-
*
|
|
1652
|
+
* Render a node's content across all THREE TreeNode shapes: flat `inline`
|
|
1653
|
+
* (dogsbay-md parser), wrapped `children[{prose, inline}]` (Starlight
|
|
1654
|
+
* importer), and a node carrying only `html` (MDX/Starlight/MkDocs). Inline
|
|
1655
|
+
* comes before children when both are present.
|
|
1218
1656
|
*
|
|
1219
1657
|
* Used for list-item, table cells, step, dt, dd — types that commonly
|
|
1220
1658
|
* carry leaf text content directly on the node.
|
|
1659
|
+
*
|
|
1660
|
+
* This function is where serialize-core's `renderLeaf` was extracted FROM, so
|
|
1661
|
+
* delegating to it closes the loop: the wrapper-dedup and html-shape handling
|
|
1662
|
+
* the core grew afterwards (from the Docusaurus dogfooding bug) now flow back
|
|
1663
|
+
* here instead of the two implementations continuing to drift apart.
|
|
1221
1664
|
*/
|
|
1222
1665
|
function leafContent(node, ctx) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
const rendered = childrenToAstro(node, ctx);
|
|
1229
|
-
if (rendered)
|
|
1230
|
-
parts.push(rendered);
|
|
1231
|
-
}
|
|
1232
|
-
return parts.join("\n");
|
|
1666
|
+
return renderLeaf(node, {
|
|
1667
|
+
inline: inlineToTemplate,
|
|
1668
|
+
children: () => childrenToAstro(node, ctx),
|
|
1669
|
+
html: (html) => `<Fragment set:html={${escapeExpr(html)}} />`,
|
|
1670
|
+
});
|
|
1233
1671
|
}
|
|
1234
1672
|
function childrenToAstro(node, ctx) {
|
|
1235
1673
|
if (!node.children || node.children.length === 0)
|
|
@@ -1243,39 +1681,28 @@ function childrenToAstro(node, ctx) {
|
|
|
1243
1681
|
* icon usage on cards registers `icon` at the renderer call site;
|
|
1244
1682
|
* this is the inline-only path. Returns true on first hit.
|
|
1245
1683
|
*/
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
return true;
|
|
1264
|
-
if ("children" in node && Array.isArray(node.children)) {
|
|
1265
|
-
for (const child of node.children) {
|
|
1266
|
-
if (inlineHasIcon(child))
|
|
1267
|
-
return true;
|
|
1268
|
-
}
|
|
1684
|
+
/** The component imports inline nodes need: `icon`, `badge`, and `link-button` for an inline `:button`. */
|
|
1685
|
+
function inlineComponentsIn(nodes, out = new Set()) {
|
|
1686
|
+
const visit = (node) => {
|
|
1687
|
+
if (node.type === "icon")
|
|
1688
|
+
out.add("icon");
|
|
1689
|
+
else if (node.type === "badge")
|
|
1690
|
+
out.add("badge");
|
|
1691
|
+
else if (node.type === "kbd")
|
|
1692
|
+
out.add("kbd");
|
|
1693
|
+
else if (node.type === "link" && node.button)
|
|
1694
|
+
out.add("link-button");
|
|
1695
|
+
if ("children" in node && Array.isArray(node.children))
|
|
1696
|
+
node.children.forEach(visit);
|
|
1697
|
+
};
|
|
1698
|
+
for (const node of nodes ?? []) {
|
|
1699
|
+
node.inline?.forEach(visit);
|
|
1700
|
+
inlineComponentsIn(node.children, out);
|
|
1269
1701
|
}
|
|
1270
|
-
return
|
|
1271
|
-
}
|
|
1272
|
-
function indentStr(text, spaces) {
|
|
1273
|
-
const prefix = " ".repeat(spaces);
|
|
1274
|
-
return text
|
|
1275
|
-
.split("\n")
|
|
1276
|
-
.map((line) => (line ? `${prefix}${line}` : line))
|
|
1277
|
-
.join("\n");
|
|
1702
|
+
return out;
|
|
1278
1703
|
}
|
|
1704
|
+
/** Indent, via the core's shared implementation (was a fourth local copy). */
|
|
1705
|
+
const indentStr = indent;
|
|
1279
1706
|
function escapeHtml(s) {
|
|
1280
1707
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1281
1708
|
}
|