@dogsbay/format-astro 0.2.0-beta.15 → 0.2.0-beta.151
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 +7 -0
- package/dist/api-markdown.js +49 -0
- package/dist/base-path.d.ts +93 -8
- package/dist/base-path.js +122 -9
- package/dist/blog.d.ts +133 -0
- package/dist/blog.js +325 -0
- package/dist/browser.d.ts +13 -0
- package/dist/browser.js +13 -0
- package/dist/canonical.d.ts +35 -0
- package/dist/canonical.js +110 -0
- package/dist/capabilities.d.ts +28 -0
- package/dist/capabilities.js +94 -0
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +3 -1
- package/dist/diff-decoration.d.ts +35 -0
- package/dist/diff-decoration.js +101 -0
- package/dist/granularity.d.ts +82 -0
- package/dist/granularity.js +246 -0
- package/dist/head-metadata.d.ts +25 -0
- package/dist/head-metadata.js +55 -0
- package/dist/index.d.ts +26 -6
- package/dist/index.js +34 -5
- package/dist/lead.d.ts +1 -39
- package/dist/lead.js +3 -38
- package/dist/llms-txt.d.ts +41 -11
- package/dist/llms-txt.js +82 -274
- package/dist/plugins.d.ts +0 -1
- package/dist/plugins.js +4 -3
- package/dist/project.d.ts +461 -13
- package/dist/project.js +2343 -649
- package/dist/redirects.d.ts +117 -0
- package/dist/redirects.js +223 -0
- package/dist/serialize.d.ts +14 -1
- package/dist/serialize.js +857 -255
- package/dist/site-chrome.d.ts +62 -0
- package/dist/site-chrome.js +56 -0
- package/dist/sitemap.d.ts +62 -4
- package/dist/sitemap.js +63 -56
- package/dist/taxonomy.d.ts +48 -2
- package/dist/taxonomy.js +50 -12
- package/dist/theme-css.d.ts +33 -0
- package/dist/theme-css.js +683 -0
- package/dist/tone-classes.d.ts +52 -0
- package/dist/tone-classes.js +84 -0
- package/dist/write-if-changed.d.ts +20 -0
- package/dist/write-if-changed.js +57 -0
- package/package.json +17 -7
- package/dist/base-path.d.ts.map +0 -1
- package/dist/base-path.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lead.d.ts.map +0 -1
- package/dist/lead.js.map +0 -1
- package/dist/llms-txt.d.ts.map +0 -1
- package/dist/llms-txt.js.map +0 -1
- package/dist/plugins.d.ts.map +0 -1
- package/dist/plugins.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/serialize.d.ts.map +0 -1
- package/dist/serialize.js.map +0 -1
- package/dist/sitemap.d.ts.map +0 -1
- package/dist/sitemap.js.map +0 -1
- package/dist/taxonomy.d.ts.map +0 -1
- package/dist/taxonomy.js.map +0 -1
package/dist/serialize.js
CHANGED
|
@@ -1,25 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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, renderAroundGroup, isDetailsSheet, stepLead, stepTitle, } from "@dogsbay/serialize-core";
|
|
2
|
+
import { parseInline } from "@dogsbay/format-dogsbay-md";
|
|
3
|
+
import { decorateDiff, hasDiffMarks } from "./diff-decoration.js";
|
|
4
|
+
import { TAB_GLYPH, tabDiffSignal } from "@dogsbay/render-html/diff";
|
|
5
|
+
import { TONE_CLASSES, CARD_TONE_CLASSES, TAB_VARIANTS, THEME_SCOPES, lookup } from "./tone-classes.js";
|
|
6
|
+
import { apiBlockHtml, apiPropertiesHtml } from "./api-markdown.js";
|
|
7
|
+
export { TONE_CLASSES };
|
|
23
8
|
const COMPONENT_IMPORTS = {
|
|
24
9
|
"markdown-example": [
|
|
25
10
|
'import MarkdownExample from "@ui/markdown-example/MarkdownExample.astro";',
|
|
@@ -32,6 +17,9 @@ const COMPONENT_IMPORTS = {
|
|
|
32
17
|
details: [
|
|
33
18
|
'import CollapsibleAlert from "@ui/alert/CollapsibleAlert.astro";',
|
|
34
19
|
],
|
|
20
|
+
"details-sheet": [
|
|
21
|
+
'import DetailsSheet from "@ui/details-sheet/DetailsSheet.astro";',
|
|
22
|
+
],
|
|
35
23
|
code: [
|
|
36
24
|
'import CodeBlock from "@ui/code-block/CodeBlock.astro";',
|
|
37
25
|
],
|
|
@@ -55,6 +43,9 @@ const COMPONENT_IMPORTS = {
|
|
|
55
43
|
],
|
|
56
44
|
diagram: ['import Diagram from "@ui/diagram/Diagram.astro";'],
|
|
57
45
|
youtube: ['import YouTube from "@ui/youtube/YouTube.astro";'],
|
|
46
|
+
badge: ['import Badge from "@ui/badge/Badge.astro";'],
|
|
47
|
+
kbd: ['import Kbd from "@ui/kbd/Kbd.astro";'],
|
|
48
|
+
files: ['import FileTree from "@ui/files/FileTree.astro";'],
|
|
58
49
|
"math-block": ['import MathKatex from "@ui/math/MathKatex.astro";'],
|
|
59
50
|
image: ['import BlockImage from "@ui/image/Image.astro";'],
|
|
60
51
|
"api-class": ['import ApiSymbol from "@ui/api-symbol/ApiSymbol.astro";'],
|
|
@@ -96,6 +87,9 @@ const COMPONENT_IMPORTS = {
|
|
|
96
87
|
"link-card": [
|
|
97
88
|
'import LinkCard from "@ui/link-card/LinkCard.astro";',
|
|
98
89
|
],
|
|
90
|
+
"link-button": [
|
|
91
|
+
'import Button from "@ui/button/Button.astro";',
|
|
92
|
+
],
|
|
99
93
|
avatar: [
|
|
100
94
|
'import Avatar from "@ui/avatar/Avatar.astro";',
|
|
101
95
|
],
|
|
@@ -109,12 +103,50 @@ export function escapeExpr(s) {
|
|
|
109
103
|
// JSON.stringify handles all special characters (quotes, backslashes, newlines, etc.)
|
|
110
104
|
// and produces a valid JS string literal. This avoids template literal edge cases
|
|
111
105
|
// where backtick + brace combinations create false expression boundaries.
|
|
112
|
-
|
|
106
|
+
//
|
|
107
|
+
// `<` becomes \u003c: Astro parses the template as HTML before the expression is
|
|
108
|
+
// JavaScript, so a `<script>` inside a string (a code block showing one, say) would
|
|
109
|
+
// be taken for a real script tag and its text compiled as JavaScript. Vite's
|
|
110
|
+
// dependency scan then fails to parse it and a production build breaks.
|
|
111
|
+
return JSON.stringify(s).replace(/</g, "\\u003c");
|
|
113
112
|
}
|
|
114
113
|
/** Escape for use in an HTML attribute value (double-quoted). */
|
|
115
114
|
export function escapeAttr(s) {
|
|
116
115
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
117
116
|
}
|
|
117
|
+
export function componentAttr(name, value) {
|
|
118
|
+
return /[&"<>]/.test(value)
|
|
119
|
+
? ` ${name}={${escapeExpr(value)}}`
|
|
120
|
+
: ` ${name}="${value}"`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Render extra attributes from an inline element's `attrs` field
|
|
124
|
+
* (links and images today; spans in a future phase) as ` key="val"`
|
|
125
|
+
* pairs prefixed with a leading space, suitable for splicing into an
|
|
126
|
+
* open tag immediately after the element's first-class attrs (href,
|
|
127
|
+
* src, etc.). `id`, `class`, and `style` are first-class; any other
|
|
128
|
+
* key passes through verbatim.
|
|
129
|
+
*/
|
|
130
|
+
/** `{…}` attributes on a COMPONENT (Badge, Button): `componentAttr`, which Astro does not entity-decode. */
|
|
131
|
+
function renderInlineComponentAttrs(attrs) {
|
|
132
|
+
if (!attrs)
|
|
133
|
+
return "";
|
|
134
|
+
return Object.entries(attrs)
|
|
135
|
+
.filter(([, value]) => value !== undefined)
|
|
136
|
+
.map(([key, value]) => componentAttr(key, String(value)))
|
|
137
|
+
.join("");
|
|
138
|
+
}
|
|
139
|
+
function renderInlineElementAttrs(attrs) {
|
|
140
|
+
if (!attrs)
|
|
141
|
+
return "";
|
|
142
|
+
const parts = [];
|
|
143
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
144
|
+
if (value === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
parts.push(` ${key}="${escapeAttr(String(value))}"`);
|
|
147
|
+
}
|
|
148
|
+
return parts.join("");
|
|
149
|
+
}
|
|
118
150
|
/** Escape text for use directly in an Astro template (not inside an expression). */
|
|
119
151
|
export function escapeTemplate(s) {
|
|
120
152
|
let result = "";
|
|
@@ -144,11 +176,19 @@ export function escapeTemplate(s) {
|
|
|
144
176
|
export function treeToAstro(nodes, options) {
|
|
145
177
|
const ctx = {
|
|
146
178
|
imports: new Set(),
|
|
179
|
+
diagramIds: new Map(),
|
|
147
180
|
indent: 0,
|
|
148
181
|
scripts: new Set(),
|
|
149
182
|
mode: options?.mode ?? "template",
|
|
150
183
|
imageOptimization: options?.imageOptimization ?? false,
|
|
151
184
|
codeBlockTitle: options?.codeBlockTitle ?? true,
|
|
185
|
+
imageCaptions: options?.imageCaptions ?? false,
|
|
186
|
+
combinedPrefix: options?.combinedPrefix ?? "",
|
|
187
|
+
// Mixed prose+endpoint pages render endpoints as embedded blocks;
|
|
188
|
+
// pages that are nothing but endpoints/headings keep the
|
|
189
|
+
// full-viewport operation layout (the OpenAPI page shape).
|
|
190
|
+
embeddedEndpoints: apiPageLayout(nodes).embeddedEndpoints,
|
|
191
|
+
apiSymbolLevels: apiSymbolLevels(nodes),
|
|
152
192
|
};
|
|
153
193
|
// Pre-scan for inline `icon` nodes anywhere in the tree.
|
|
154
194
|
// `inlineNodeToTemplate` is intentionally pure (no ctx) — it
|
|
@@ -157,9 +197,9 @@ export function treeToAstro(nodes, options) {
|
|
|
157
197
|
// (card.props.icon) registers `icon` directly at the call
|
|
158
198
|
// site; this pre-scan picks up the inline `:icon[name]`
|
|
159
199
|
// directives the writer can drop into prose.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
200
|
+
// Badges and inline buttons are components too, registered the same way.
|
|
201
|
+
for (const key of inlineComponentsIn(nodes))
|
|
202
|
+
ctx.imports.add(key);
|
|
163
203
|
const body = nodes.map((n) => nodeToAstro(n, ctx)).filter(Boolean).join("\n");
|
|
164
204
|
const imports = [];
|
|
165
205
|
const seen = new Set();
|
|
@@ -181,7 +221,18 @@ export function treeToAstro(nodes, options) {
|
|
|
181
221
|
};
|
|
182
222
|
}
|
|
183
223
|
// ─── Node dispatcher ──────────────────────────────────────────────
|
|
224
|
+
/**
|
|
225
|
+
* Dispatch + diff decoration: nodes carrying DiffTree marks (from
|
|
226
|
+
* @dogsbay/tree-diff, read structurally) decorate the rendered
|
|
227
|
+
* snippet; unmarked nodes render byte-identically to before.
|
|
228
|
+
*/
|
|
184
229
|
function nodeToAstro(node, ctx) {
|
|
230
|
+
const rendered = nodeToAstroBase(node, ctx);
|
|
231
|
+
if (!rendered || !hasDiffMarks(node))
|
|
232
|
+
return rendered;
|
|
233
|
+
return decorateDiff(node, rendered, (peer) => nodeToAstroBase(peer, ctx));
|
|
234
|
+
}
|
|
235
|
+
function nodeToAstroBase(node, ctx) {
|
|
185
236
|
switch (node.type) {
|
|
186
237
|
case "prose":
|
|
187
238
|
return proseToAstro(node, ctx);
|
|
@@ -193,10 +244,27 @@ function nodeToAstro(node, ctx) {
|
|
|
193
244
|
return codeToAstro(node, ctx);
|
|
194
245
|
case "callout":
|
|
195
246
|
return calloutToAstro(node, ctx);
|
|
247
|
+
case "files":
|
|
248
|
+
return filesToAstro(node, ctx);
|
|
249
|
+
case "container":
|
|
250
|
+
return genericContainerToAstro(node, ctx);
|
|
196
251
|
case "details":
|
|
197
252
|
return detailsToAstro(node, ctx);
|
|
253
|
+
case "theme": {
|
|
254
|
+
// A scoped theme: `.dark` re-declares the tokens for its subtree, so a
|
|
255
|
+
// plain wrapper is enough (THEME_SCOPES); an unknown name keeps the content
|
|
256
|
+
const name = node.props?.name;
|
|
257
|
+
const scope = lookup(THEME_SCOPES, name);
|
|
258
|
+
const inner = childrenToAstro(node, ctx);
|
|
259
|
+
if (!scope)
|
|
260
|
+
return inner;
|
|
261
|
+
const passthrough = renderPassthroughAttrs(node.props, ["name", "class", "data-theme"]);
|
|
262
|
+
// The default (expression) form: an entity-escaped `[&>…]` is not a
|
|
263
|
+
// class Tailwind's scanner recognises, so the rule was never generated
|
|
264
|
+
return `<div${mergeClassAttr(scope, node.props?.class)} data-theme="${escapeAttr(name)}"${passthrough}>\n${indentStr(inner, 2)}\n</div>`;
|
|
265
|
+
}
|
|
198
266
|
case "tabs":
|
|
199
|
-
return
|
|
267
|
+
return renderAroundGroup(node, "tab", (n) => tabsToAstro(n, ctx), (c) => nodeToAstro(c, ctx), "\n");
|
|
200
268
|
case "ordered-list":
|
|
201
269
|
return wrapBlock("ol", "", node, childrenToAstro(node, ctx), ["start"])
|
|
202
270
|
.replace(/^<ol(\s[^>]*)?>/, (m) => {
|
|
@@ -212,14 +280,23 @@ function nodeToAstro(node, ctx) {
|
|
|
212
280
|
return wrapBlock("li", "", node, leafContent(node, ctx));
|
|
213
281
|
case "blockquote":
|
|
214
282
|
return wrapBlock("blockquote", "border-l-4 border-border pl-4 italic text-muted-foreground", node, childrenToAstro(node, ctx));
|
|
283
|
+
// A transparent grouping wrapper: renders its children inside a plain
|
|
284
|
+
// <div> and adds no semantics of its own. Exists so a caller can carry
|
|
285
|
+
// an attribute (a class, a data-* flag) for a whole run of blocks —
|
|
286
|
+
// the release comparison marks a wholly added/removed PAGE once, at
|
|
287
|
+
// the page level, instead of putting a bar on every block inside it.
|
|
288
|
+
case "group":
|
|
289
|
+
return wrapBlock("div", "", node, childrenToAstro(node, ctx));
|
|
215
290
|
case "hr": {
|
|
216
291
|
const classAttr = mergeClassAttr("my-8 border-border", node.props?.class);
|
|
217
292
|
const passthrough = renderPassthroughAttrs(node.props, ["class"]);
|
|
218
293
|
return `<hr${classAttr}${passthrough} />`;
|
|
219
294
|
}
|
|
220
295
|
case "html":
|
|
221
|
-
// Raw HTML always uses set:html — it may contain anything
|
|
222
|
-
|
|
296
|
+
// Raw HTML always uses set:html — it may contain anything. Callouts inside it
|
|
297
|
+
// (dogsbay-md's HTML table fallback writes `<div role="note" data-callout>`)
|
|
298
|
+
// render as real callouts
|
|
299
|
+
return htmlToAstro(node.html ?? "", ctx);
|
|
223
300
|
case "table":
|
|
224
301
|
return tableToAstro(node, ctx);
|
|
225
302
|
case "thead":
|
|
@@ -270,18 +347,36 @@ function nodeToAstro(node, ctx) {
|
|
|
270
347
|
return exampleToAstro(node, ctx);
|
|
271
348
|
case "figure": {
|
|
272
349
|
const caption = node.props?.caption;
|
|
350
|
+
// A figure of one code block: the caption goes in the code block's header bar,
|
|
351
|
+
// which then is the figure's caption
|
|
352
|
+
const only = node.children?.length === 1 ? node.children[0] : undefined;
|
|
353
|
+
if (caption && only?.type === "code") {
|
|
354
|
+
return codeToAstro(only, ctx, {
|
|
355
|
+
caption: captionToAstro(caption, ctx),
|
|
356
|
+
// The figure's attributes, except those the code block has itself (an id twice is invalid)
|
|
357
|
+
attrs: renderPassthroughAttrs(node.props, ["caption", "class", ...Object.keys(only.props ?? {})]),
|
|
358
|
+
classes: ["my-4", node.props?.class].filter(Boolean).join(" "),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
273
361
|
const inner = childrenToAstro(node, ctx);
|
|
274
362
|
const body = caption
|
|
275
|
-
? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${
|
|
363
|
+
? `${inner}\n<figcaption class="text-center text-sm text-muted-foreground mt-2">${captionToAstro(caption, ctx)}</figcaption>`
|
|
276
364
|
: inner;
|
|
277
365
|
return wrapBlock("figure", "my-4", node, body, ["caption"]);
|
|
278
366
|
}
|
|
279
367
|
case "steps":
|
|
280
368
|
ctx.imports.add("steps");
|
|
281
369
|
return `<Steps>\n${childrenToAstro(node, ctx)}\n</Steps>`;
|
|
282
|
-
case "step":
|
|
370
|
+
case "step": {
|
|
283
371
|
ctx.imports.add("steps");
|
|
284
|
-
|
|
372
|
+
// A `title` prop (Mintlify `<Step title>`) leads the step in bold, as render-html draws it
|
|
373
|
+
// The title on its own line (Steps.astro: `.step-title` is a block): from the prop, or the
|
|
374
|
+
// step's leading bold run (stepLead), which Markdown otherwise runs into the detail
|
|
375
|
+
const title = stepTitle(node);
|
|
376
|
+
const split = title ? undefined : stepLead(node.inline);
|
|
377
|
+
const lead = title ? `<strong class="step-title">${escapeTemplate(title)}</strong>\n` : split ? `<strong class="step-title">${renderInline(split.title, ctx)}</strong>\n` : "";
|
|
378
|
+
return `<Step>\n${lead}${leafContent(split ? { ...node, inline: split.rest } : node, ctx)}\n</Step>`;
|
|
379
|
+
}
|
|
285
380
|
case "substeps":
|
|
286
381
|
ctx.imports.add("substeps");
|
|
287
382
|
ctx.imports.add("steps");
|
|
@@ -297,8 +392,10 @@ function nodeToAstro(node, ctx) {
|
|
|
297
392
|
return standaloneCardToAstro(node, ctx);
|
|
298
393
|
case "link-card":
|
|
299
394
|
return linkCardToAstro(node, ctx);
|
|
395
|
+
case "link-button":
|
|
396
|
+
return linkButtonToAstro(node, ctx);
|
|
300
397
|
case "accordion":
|
|
301
|
-
return
|
|
398
|
+
return renderAroundGroup(node, "accordion-item", (n) => accordionToAstro(n, ctx), (c) => nodeToAstro(c, ctx), "\n");
|
|
302
399
|
case "accordion-item":
|
|
303
400
|
// Bare accordion-item outside an :::accordion wrapper. Wrap it in an
|
|
304
401
|
// Accordion shell so the output is still a valid widget.
|
|
@@ -342,7 +439,7 @@ ${childrenToAstro(node, ctx)}
|
|
|
342
439
|
// controls the bg/text combo (see TONE_CLASSES). Authors can also pass
|
|
343
440
|
// their own `class` to override.
|
|
344
441
|
if (label !== undefined) {
|
|
345
|
-
const toneClasses = TONE_CLASSES
|
|
442
|
+
const toneClasses = lookup(TONE_CLASSES, tone) ?? TONE_CLASSES["primary-light"];
|
|
346
443
|
return `<GridItem ${attrParts.join(" ")}${classAttr}${passthrough}>
|
|
347
444
|
<div class="flex h-full min-h-16 items-center justify-center rounded-lg font-mono text-sm font-medium ${toneClasses}">${escapeTemplate(label)}</div>
|
|
348
445
|
</GridItem>`;
|
|
@@ -351,6 +448,28 @@ ${childrenToAstro(node, ctx)}
|
|
|
351
448
|
${childrenToAstro(node, ctx)}
|
|
352
449
|
</GridItem>`;
|
|
353
450
|
}
|
|
451
|
+
case "related": {
|
|
452
|
+
// #384 R3: AsciiDoc [role="_additional-resources"] + .Title + list folds
|
|
453
|
+
// to this semantic node (maps to DITA <related-links>). Render a titled
|
|
454
|
+
// section, not a TOC heading. The title is a caption (block title), so a
|
|
455
|
+
// styled <p>, not an <h2>.
|
|
456
|
+
const title = node.props?.title;
|
|
457
|
+
const classAttr = mergeClassAttr("related-resources", node.props?.class);
|
|
458
|
+
const passthrough = renderPassthroughAttrs(node.props, ["title", "class"]);
|
|
459
|
+
const titleHtml = title
|
|
460
|
+
? `<p class="related-resources-title">${escapeTemplate(title)}</p>\n`
|
|
461
|
+
: "";
|
|
462
|
+
return `<section${classAttr}${passthrough}>\n${titleHtml}${childrenToAstro(node, ctx)}\n</section>`;
|
|
463
|
+
}
|
|
464
|
+
case "mdx-raw": {
|
|
465
|
+
// Preserved-but-unmapped MDX component (format-mdx never-drop; see
|
|
466
|
+
// docs-dev/format-mdx-adapters.md). Rendered as a visible source
|
|
467
|
+
// block — the content must not silently disappear from the HTML
|
|
468
|
+
// output. Map the component (adapter / mdx.components config) to
|
|
469
|
+
// upgrade it to a real widget.
|
|
470
|
+
const source = String(node.props?.source ?? "");
|
|
471
|
+
return `<pre class="mdx-raw my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code>{${escapeExpr(source)}}</code></pre>`;
|
|
472
|
+
}
|
|
354
473
|
default:
|
|
355
474
|
if (node.html)
|
|
356
475
|
return `<Fragment set:html={${escapeExpr(node.html)}} />`;
|
|
@@ -360,37 +479,77 @@ ${childrenToAstro(node, ctx)}
|
|
|
360
479
|
}
|
|
361
480
|
}
|
|
362
481
|
// ─── 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
|
-
|
|
482
|
+
/**
|
|
483
|
+
* Astro's two inline dialects, as emitters over serialize-core's shared walk.
|
|
484
|
+
*
|
|
485
|
+
* These were the THIRD and FOURTH copies of the 11-variant switch (obsidian and
|
|
486
|
+
* dogsbay-md's html fallback were the other two), and the duplication is
|
|
487
|
+
* precisely why `highlight` was missing from BOTH of them: adding an arm meant
|
|
488
|
+
* remembering four places, so `:highlight[…]` rendered as nothing at all on
|
|
489
|
+
* every Astro page. The walk is now shared, so a variant is handled in one
|
|
490
|
+
* place per format instead of being silently skippable.
|
|
491
|
+
*
|
|
492
|
+
* Only the leaf syntax differs between the two maps: template mode escapes for
|
|
493
|
+
* Astro's `{}` and emits real components (`<Icon>`, `<Fragment set:html>`),
|
|
494
|
+
* hybrid mode escapes for plain HTML and emits inert markup.
|
|
495
|
+
*/
|
|
496
|
+
/** Canonical highlight styling — keep in sync with InlineRenderer.astro. */
|
|
497
|
+
const HIGHLIGHT_CLASS = "rounded-sm bg-yellow-200/60 px-0.5 dark:bg-yellow-500/30";
|
|
498
|
+
function templateInlineEmitters() {
|
|
499
|
+
return {
|
|
500
|
+
text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
|
|
501
|
+
bold: (s) => `<strong>${s}</strong>`,
|
|
502
|
+
italic: (s) => `<em>${s}</em>`,
|
|
503
|
+
strike: (s) => `<s>${s}</s>`,
|
|
504
|
+
}),
|
|
505
|
+
link: (node, ctx) => {
|
|
506
|
+
// Inside a whole-card link, a link is its text (wholeCardLink)
|
|
507
|
+
if (cardLinkDepth > 0)
|
|
508
|
+
return ctx.recurse(node.children);
|
|
509
|
+
// A link node an importer built directly never passed markdown-it's
|
|
510
|
+
// scheme check: a `javascript:` href keeps its text and loses the link.
|
|
511
|
+
if (!isSafeHref(node.href)) {
|
|
512
|
+
ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
|
|
513
|
+
return ctx.recurse(node.children);
|
|
514
|
+
}
|
|
515
|
+
const titleAttr = node.title ? componentAttr("title", node.title) : "";
|
|
516
|
+
const extraAttrs = renderInlineElementAttrs(node.attrs);
|
|
517
|
+
// An inline `:button`: the Button component, small, keeping its variant ("primary" is the filled default).
|
|
518
|
+
if (node.button) {
|
|
519
|
+
const variant = buttonVariant(node.button.variant);
|
|
520
|
+
return `<Button${componentAttr("href", node.href)}${componentAttr("variant", variant)} size="sm"${titleAttr}${renderInlineComponentAttrs(node.attrs)}>${ctx.recurse(node.children)}</Button>`;
|
|
521
|
+
}
|
|
522
|
+
return `<a href="${escapeAttr(node.href)}"${titleAttr}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
|
|
523
|
+
},
|
|
524
|
+
badge: (node, ctx) => `<Badge${componentAttr("variant", node.variant ?? "default")}${renderInlineComponentAttrs(node.attrs)}>${ctx.escape(node.text)}</Badge>`,
|
|
525
|
+
image: (node, ctx) => {
|
|
526
|
+
if (!isSafeHref(node.src, "image")) {
|
|
527
|
+
ctx.report("image", `unsafe image source dropped (${node.src.slice(0, 40)})`);
|
|
528
|
+
return ctx.escape(node.alt ?? "");
|
|
529
|
+
}
|
|
530
|
+
const extraAttrs = renderInlineElementAttrs(node.attrs);
|
|
531
|
+
return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt ?? "")}" loading="lazy"${extraAttrs} />`;
|
|
532
|
+
},
|
|
533
|
+
// Code inherits the emphasis around it (`*using the \`dita\` command*`): the parser
|
|
534
|
+
// flags the code span, and dropping the flags split the italics around the code
|
|
535
|
+
code: (node, ctx) => applyTextFlags(`<code>${ctx.escape(node.text)}</code>`, node, {
|
|
536
|
+
bold: (s) => `<strong>${s}</strong>`,
|
|
537
|
+
italic: (s) => `<em>${s}</em>`,
|
|
538
|
+
strike: (s) => `<s>${s}</s>`,
|
|
539
|
+
}),
|
|
540
|
+
// The arm that did not exist — `:highlight[…]` / `:mark[…]` rendered as
|
|
541
|
+
// nothing at all. The class list mirrors the canonical rendering in
|
|
542
|
+
// `packages/ui/src/content-renderer/InlineRenderer.astro`, so a highlight
|
|
543
|
+
// looks the same however a page is produced. A BARE `<mark>` would have
|
|
544
|
+
// been "fixed" but unstyled: the generated `.docs-prose` stylesheet had no
|
|
545
|
+
// `mark` rule, so browsers paint their default yellow — including on dark
|
|
546
|
+
// pages, where nothing declares `color-scheme`.
|
|
547
|
+
highlight: (node, ctx) => `<mark class="${HIGHLIGHT_CLASS}">${ctx.recurse(node.children)}</mark>`,
|
|
548
|
+
"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>`,
|
|
549
|
+
// The Kbd component (keys styled as keycaps); plain <kbd> had no style beyond a monospace font.
|
|
550
|
+
kbd: (node, ctx) => node.keys.map((k) => `<Kbd>${ctx.escape(k)}</Kbd>`).join("+"),
|
|
551
|
+
math: (node, ctx) => `<code class="math-inline">${ctx.escape(node.latex)}</code>`,
|
|
552
|
+
icon: (node) => {
|
|
394
553
|
// Inline icon — emits the platform `<Icon>` component which
|
|
395
554
|
// resolves at build time against Lucide (default) plus any
|
|
396
555
|
// other Iconify pack via the `pack:name` shorthand. Caller
|
|
@@ -404,47 +563,39 @@ function inlineNodeToTemplate(node) {
|
|
|
404
563
|
// to Lucide for everything.
|
|
405
564
|
const fullName = node.library ? `${node.library}:${node.name}` : node.name;
|
|
406
565
|
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("");
|
|
566
|
+
},
|
|
567
|
+
// Raw HTML inline — must use set:html to avoid brace issues
|
|
568
|
+
"html-inline": (node) => `<Fragment set:html={${escapeExpr(node.html)}} />`,
|
|
569
|
+
break: () => "<br />",
|
|
570
|
+
};
|
|
420
571
|
}
|
|
421
|
-
function
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
572
|
+
function htmlModeInlineEmitters() {
|
|
573
|
+
return {
|
|
574
|
+
...templateInlineEmitters(),
|
|
575
|
+
// Inherited from the template map, this emitted `title={"…"}` — an
|
|
576
|
+
// EXPRESSION, inside a string that becomes a `set:html` value. Astro never
|
|
577
|
+
// parses it as markup, so the reader saw the braces and quotes.
|
|
578
|
+
link: (node, ctx) => {
|
|
579
|
+
if (cardLinkDepth > 0)
|
|
580
|
+
return ctx.recurse(node.children);
|
|
581
|
+
if (!isSafeHref(node.href)) {
|
|
582
|
+
ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
|
|
583
|
+
return ctx.recurse(node.children);
|
|
584
|
+
}
|
|
434
585
|
const titleAttr = node.title ? ` title="${escapeAttr(node.title)}"` : "";
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
return `<
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
586
|
+
const extraAttrs = renderInlineElementAttrs(node.attrs);
|
|
587
|
+
// No components in HTML mode: a marked link (and a marked span for a badge below).
|
|
588
|
+
const button = node.button ? ` data-button data-variant="${escapeAttr(buttonVariant(node.button.variant))}"` : "";
|
|
589
|
+
return `<a href="${escapeAttr(node.href)}"${titleAttr}${button}${extraAttrs}>${ctx.recurse(node.children)}</a>`;
|
|
590
|
+
},
|
|
591
|
+
kbd: (node, ctx) => node.keys.map((k) => `<kbd>${ctx.escape(k)}</kbd>`).join("+"),
|
|
592
|
+
badge: (node, ctx) => `<span data-badge data-variant="${escapeAttr(node.variant ?? "default")}"${renderInlineElementAttrs(node.attrs)}>${ctx.escape(node.text)}</span>`,
|
|
593
|
+
text: (node, ctx) => applyTextFlags(ctx.escape(node.text), node, {
|
|
594
|
+
bold: (s) => `<strong>${s}</strong>`,
|
|
595
|
+
italic: (s) => `<em>${s}</em>`,
|
|
596
|
+
strike: (s) => `<s>${s}</s>`,
|
|
597
|
+
}),
|
|
598
|
+
icon: (node) => {
|
|
448
599
|
// Hybrid-mode HTML rendering — fall back to a textual
|
|
449
600
|
// shortcode reference when we can't inline the SVG. Real
|
|
450
601
|
// resolution happens in template mode via the <Icon>
|
|
@@ -452,14 +603,19 @@ function inlineNodeToHtml(node) {
|
|
|
452
603
|
// production output.
|
|
453
604
|
const fullName = node.library ? `${node.library}:${node.name}` : node.name;
|
|
454
605
|
return `<span class="dogsbay-icon" data-icon="${escapeAttr(fullName)}"></span>`;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
606
|
+
},
|
|
607
|
+
"html-inline": (node) => node.html,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
const TEMPLATE_INLINE = templateInlineEmitters();
|
|
611
|
+
const HTML_INLINE = htmlModeInlineEmitters();
|
|
612
|
+
/** Render inline nodes as Astro template markup (clean, editable). */
|
|
613
|
+
function inlineToTemplate(nodes) {
|
|
614
|
+
return walkInline(nodes, { emitters: TEMPLATE_INLINE, escape: escapeTemplate });
|
|
615
|
+
}
|
|
616
|
+
/** Render inline nodes as HTML string (for hybrid mode set:html). */
|
|
617
|
+
function inlineToHtml(nodes) {
|
|
618
|
+
return walkInline(nodes, { emitters: HTML_INLINE, escape: escapeHtml });
|
|
463
619
|
}
|
|
464
620
|
/** Render inline content in the current mode. */
|
|
465
621
|
function renderInline(nodes, ctx) {
|
|
@@ -467,6 +623,8 @@ function renderInline(nodes, ctx) {
|
|
|
467
623
|
const html = inlineToHtml(nodes);
|
|
468
624
|
return `<Fragment set:html={${escapeExpr(html)}} />`;
|
|
469
625
|
}
|
|
626
|
+
// Inline parsed from a prop (a caption) is not in the page-level pre-scan; its components are imported here.
|
|
627
|
+
inlineComponentsIn([{ type: "paragraph", inline: nodes }], ctx.imports);
|
|
470
628
|
return inlineToTemplate(nodes);
|
|
471
629
|
}
|
|
472
630
|
/** Render an HTML string in the current mode. */
|
|
@@ -487,18 +645,24 @@ function headingToAstro(node, ctx) {
|
|
|
487
645
|
// `id` takes precedence over `slug` (slug is auto-generated, id is explicit user override)
|
|
488
646
|
const id = node.props?.id ?? node.props?.slug ?? "";
|
|
489
647
|
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>`
|
|
648
|
+
? `<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
649
|
: "";
|
|
492
650
|
const idAttr = id ? ` id="${escapeAttr(id)}"` : "";
|
|
493
|
-
const
|
|
494
|
-
const
|
|
495
|
-
"level", "slug", "id", "class", "text",
|
|
496
|
-
]);
|
|
651
|
+
const consumed = ["level", "slug", "id", "class", "text"];
|
|
652
|
+
const userClass = node.props?.class;
|
|
497
653
|
if (ctx.mode === "template" && node.inline) {
|
|
654
|
+
// Real Astro markup: expressions are parsed.
|
|
655
|
+
const classAttr = mergeClassAttr("group scroll-mt-20", userClass);
|
|
656
|
+
const passthrough = renderPassthroughAttrs(node.props, consumed);
|
|
498
657
|
const text = inlineToTemplate(node.inline);
|
|
499
658
|
return `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
|
|
500
659
|
}
|
|
501
|
-
// Hybrid or no inline —
|
|
660
|
+
// Hybrid or no inline — the whole heading becomes a set:html STRING, where an
|
|
661
|
+
// expression would be literal text. Every MDX/Mintlify heading takes this
|
|
662
|
+
// branch (that importer emits `props.text` + children, never `inline`), so
|
|
663
|
+
// this is the common path, not an edge case.
|
|
664
|
+
const classAttr = mergeClassAttr("group scroll-mt-20", userClass, "html");
|
|
665
|
+
const passthrough = renderPassthroughAttrs(node.props, consumed, "html");
|
|
502
666
|
const text = node.inline ? inlineToHtml(node.inline) : node.props?.text ?? "";
|
|
503
667
|
const html = `<h${level}${idAttr}${classAttr}${passthrough}>${text}${anchor}</h${level}>`;
|
|
504
668
|
return `<Fragment set:html={${escapeExpr(html)}} />`;
|
|
@@ -515,15 +679,28 @@ function paragraphToAstro(node, ctx) {
|
|
|
515
679
|
if (meaningful.length === 1 && meaningful[0].type === "image") {
|
|
516
680
|
const img = meaningful[0];
|
|
517
681
|
ctx.imports.add("image");
|
|
682
|
+
// imageMap is keyed by the unprefixed publicPath built from
|
|
683
|
+
// `import.meta.glob("/src/assets/**", ...)` at runtime, so
|
|
684
|
+
// strip the URL prefix back off if rewriteTreeImageSrcs has
|
|
685
|
+
// already applied it. Keeps the optimized-image lookup working
|
|
686
|
+
// alongside the prefix-on-emit fix.
|
|
687
|
+
const lookupKey = ctx.combinedPrefix && img.src.startsWith(`${ctx.combinedPrefix}/`)
|
|
688
|
+
? img.src.slice(ctx.combinedPrefix.length)
|
|
689
|
+
: img.src;
|
|
518
690
|
const imgDataAttr = ctx.imageOptimization
|
|
519
|
-
? ` imageData={imageMap[${escapeExpr(
|
|
691
|
+
? ` imageData={imageMap[${escapeExpr(lookupKey)}]}`
|
|
520
692
|
: "";
|
|
521
693
|
// Forward arbitrary `data-*` / `aria-*` attrs that plugins
|
|
522
694
|
// may have attached to the wrapping paragraph (e.g.
|
|
523
695
|
// @dogsbay/plugin-image-zoom tags `data-zoomable="true"` so
|
|
524
696
|
// the runtime can attach handlers without scanning the DOM).
|
|
525
697
|
const passthrough = renderPassthroughAttrs(node.props, ["class"]);
|
|
526
|
-
|
|
698
|
+
// Author-supplied {.class #id ...} on the image itself
|
|
699
|
+
// (Phase 1.5 of plans/inline-attrs.md).
|
|
700
|
+
const imageAttrs = renderInlineElementAttrs(img.attrs);
|
|
701
|
+
// `content.imageCaptions: title`: the figure form of BlockImage, captioned by the title (as dogsbay-ssg).
|
|
702
|
+
const figure = ctx.imageCaptions && img.title ? ` block${componentAttr("title", img.title)}` : "";
|
|
703
|
+
return `<BlockImage${componentAttr("src", img.src)}${componentAttr("alt", img.alt ?? "")}${figure}${imageAttrs}${passthrough}${imgDataAttr} />`;
|
|
527
704
|
}
|
|
528
705
|
}
|
|
529
706
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
@@ -550,7 +727,7 @@ function paragraphToAstro(node, ctx) {
|
|
|
550
727
|
}).join("");
|
|
551
728
|
return `<p${classAttr}${passthrough} set:html={${escapeExpr(html)}} />`;
|
|
552
729
|
}
|
|
553
|
-
function codeToAstro(node, ctx) {
|
|
730
|
+
function codeToAstro(node, ctx, figure) {
|
|
554
731
|
// Code content may live on props.code (Starlight convention) or node.html
|
|
555
732
|
// (dogsbay-md convention). Same for lang — Starlight uses props.lang,
|
|
556
733
|
// dogsbay-md uses props.language.
|
|
@@ -561,34 +738,52 @@ function codeToAstro(node, ctx) {
|
|
|
561
738
|
?? node.html
|
|
562
739
|
?? "";
|
|
563
740
|
const title = node.props?.title;
|
|
564
|
-
|
|
741
|
+
// The fence's documented `mark`, `ins` and `del` are CodeRich's highlights, diffAdd and diffRemove.
|
|
742
|
+
const lineSpec = (...values) => values.filter((v) => typeof v === "string" || typeof v === "number").map(String).filter(Boolean).join(",") || undefined;
|
|
743
|
+
const highlights = lineSpec(node.props?.highlights, node.props?.mark);
|
|
565
744
|
const lineNumbers = node.props?.lineNumbers;
|
|
566
|
-
const
|
|
567
|
-
const
|
|
745
|
+
const copyText = node.props?.copyText;
|
|
746
|
+
const diffAdd = lineSpec(node.props?.diffAdd, node.props?.ins);
|
|
747
|
+
const diffRemove = lineSpec(node.props?.diffRemove, node.props?.del);
|
|
748
|
+
// Expressive Code's `collapse`: line runs folded behind a toggle (`{2-4, 9}` or `2-4,9`)
|
|
749
|
+
const collapse = lineSpec(node.props?.collapse)?.replace(/[{}\s]/g, "") || undefined;
|
|
750
|
+
const isRich = lineNumbers || highlights || code.includes("[!code") || Boolean(diffAdd || diffRemove) || Boolean(collapse);
|
|
751
|
+
const classAttr = mergeClassAttr(figure?.classes ?? "", node.props?.class);
|
|
752
|
+
const figureAttrs = figure?.attrs ?? "";
|
|
753
|
+
// The component with its caption slot filled, or self-closing
|
|
754
|
+
const close = (tag, open) => figure ? `${open}>\n<Fragment slot="caption">${figure.caption}</Fragment>\n</${tag}>` : `${open} />`;
|
|
568
755
|
const consumed = [
|
|
569
756
|
"lang", "language", "code", "title", "highlights", "lineNumbers",
|
|
570
|
-
"class", "ins", "del", "mark", "collapse",
|
|
757
|
+
"class", "ins", "del", "mark", "collapse", "copyText", "diffAdd", "diffRemove",
|
|
571
758
|
];
|
|
572
759
|
const passthrough = renderPassthroughAttrs(node.props, consumed);
|
|
573
760
|
if (isRich) {
|
|
574
761
|
ctx.imports.add("code-rich");
|
|
575
762
|
const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
|
|
576
763
|
if (title)
|
|
577
|
-
attrs.push(
|
|
764
|
+
attrs.push(componentAttr("title", title).trimStart());
|
|
578
765
|
if (lineNumbers)
|
|
579
766
|
attrs.push("lineNumbers");
|
|
580
767
|
if (highlights)
|
|
581
768
|
attrs.push(`highlights="${escapeAttr(highlights)}"`);
|
|
582
|
-
|
|
769
|
+
if (copyText !== undefined)
|
|
770
|
+
attrs.push(`copyText={${escapeExpr(copyText)}}`);
|
|
771
|
+
if (diffAdd)
|
|
772
|
+
attrs.push(`diffAdd="${escapeAttr(diffAdd)}"`);
|
|
773
|
+
if (diffRemove)
|
|
774
|
+
attrs.push(`diffRemove="${escapeAttr(diffRemove)}"`);
|
|
775
|
+
if (collapse)
|
|
776
|
+
attrs.push(`collapse="${escapeAttr(collapse)}"`);
|
|
777
|
+
return close("CodeRich", `<CodeRich ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
|
|
583
778
|
}
|
|
584
779
|
ctx.imports.add("code");
|
|
585
780
|
const attrs = [`code={${escapeExpr(code)}}`, `lang="${escapeAttr(lang)}"`];
|
|
586
781
|
if (title)
|
|
587
|
-
attrs.push(
|
|
782
|
+
attrs.push(componentAttr("title", title).trimStart());
|
|
588
783
|
if (ctx.codeBlockTitle !== true) {
|
|
589
784
|
attrs.push(`showTitle="${ctx.codeBlockTitle}"`);
|
|
590
785
|
}
|
|
591
|
-
return `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough}
|
|
786
|
+
return close("CodeBlock", `<CodeBlock ${attrs.join(" ")}${classAttr}${passthrough}${figureAttrs}`);
|
|
592
787
|
}
|
|
593
788
|
// Map GitHub alert names (and other aliases) to Alert component variants.
|
|
594
789
|
// The Alert variant palette follows MkDocs Material; not every GitHub name
|
|
@@ -600,6 +795,71 @@ const CALLOUT_VARIANT_MAP = {
|
|
|
600
795
|
hint: "tip",
|
|
601
796
|
attention: "warning",
|
|
602
797
|
};
|
|
798
|
+
// Default title shown when a callout has no explicit one, keyed by the type
|
|
799
|
+
// the author wrote: aliases share a look but keep their own name (`:::info` is
|
|
800
|
+
// "Info", not the "Important" of the variant it shares). The same labels as
|
|
801
|
+
// dogsbay-ssg (src/render/icons.ts CALLOUTS). Anything else: the type, capitalised.
|
|
802
|
+
const CALLOUT_LABELS = {
|
|
803
|
+
note: "Note",
|
|
804
|
+
info: "Info",
|
|
805
|
+
tip: "Tip",
|
|
806
|
+
hint: "Hint",
|
|
807
|
+
success: "Success",
|
|
808
|
+
important: "Important",
|
|
809
|
+
warning: "Warning",
|
|
810
|
+
attention: "Attention",
|
|
811
|
+
caution: "Caution",
|
|
812
|
+
danger: "Danger",
|
|
813
|
+
error: "Error",
|
|
814
|
+
failure: "Failure",
|
|
815
|
+
question: "Question",
|
|
816
|
+
bug: "Bug",
|
|
817
|
+
example: "Example",
|
|
818
|
+
abstract: "Abstract",
|
|
819
|
+
quote: "Quote",
|
|
820
|
+
};
|
|
821
|
+
const CALLOUT_OPEN = /<div role="note" data-callout="([^"]*)"(?: data-title="([^"]*)")?>/g;
|
|
822
|
+
/**
|
|
823
|
+
* Raw HTML as set:html fragments, with each marked callout div rendered by
|
|
824
|
+
* calloutToAstro. The fragments around a callout may hold unbalanced tags
|
|
825
|
+
* (`<table><tr><td>` before, `</td></tr></table>` after); set:html writes
|
|
826
|
+
* them verbatim, so the page's HTML is whole again.
|
|
827
|
+
*/
|
|
828
|
+
function htmlToAstro(html, ctx) {
|
|
829
|
+
const raw = (s) => (s ? `<Fragment set:html={${escapeExpr(s)}} />` : "");
|
|
830
|
+
const out = [];
|
|
831
|
+
let rest = html;
|
|
832
|
+
for (;;) {
|
|
833
|
+
CALLOUT_OPEN.lastIndex = 0;
|
|
834
|
+
const open = CALLOUT_OPEN.exec(rest);
|
|
835
|
+
if (!open)
|
|
836
|
+
break;
|
|
837
|
+
// The callout ends at its matching </div>
|
|
838
|
+
const divs = /<div\b|<\/div>/g;
|
|
839
|
+
divs.lastIndex = open.index + open[0].length;
|
|
840
|
+
let depth = 1;
|
|
841
|
+
let end = -1;
|
|
842
|
+
for (let m = divs.exec(rest); m; m = divs.exec(rest)) {
|
|
843
|
+
depth += m[0] === "</div>" ? -1 : 1;
|
|
844
|
+
if (depth === 0) {
|
|
845
|
+
end = m.index;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (end < 0)
|
|
850
|
+
break;
|
|
851
|
+
const unescape = (s) => s.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
852
|
+
out.push(raw(rest.slice(0, open.index)));
|
|
853
|
+
out.push(calloutToAstro({
|
|
854
|
+
type: "callout",
|
|
855
|
+
props: { type: unescape(open[1]), ...(open[2] !== undefined ? { title: unescape(open[2]) } : {}) },
|
|
856
|
+
children: [{ type: "html", html: rest.slice(open.index + open[0].length, end) }],
|
|
857
|
+
}, ctx));
|
|
858
|
+
rest = rest.slice(end + "</div>".length);
|
|
859
|
+
}
|
|
860
|
+
out.push(raw(rest));
|
|
861
|
+
return out.filter(Boolean).join("\n") || raw(html);
|
|
862
|
+
}
|
|
603
863
|
function calloutToAstro(node, ctx) {
|
|
604
864
|
ctx.imports.add("callout");
|
|
605
865
|
// Callout variant may live on props.variant (Starlight / MkDocs importers)
|
|
@@ -608,36 +868,90 @@ function calloutToAstro(node, ctx) {
|
|
|
608
868
|
?? node.props?.type
|
|
609
869
|
?? "note").toLowerCase();
|
|
610
870
|
const variant = CALLOUT_VARIANT_MAP[rawVariant] ?? rawVariant;
|
|
611
|
-
|
|
871
|
+
// When the source gives no explicit title, fall back to the type label
|
|
872
|
+
// (Note / Warning / …) instead of an empty <AlertTitle> — AsciiDoc
|
|
873
|
+
// admonitions and GitHub-style alerts both show the type next to the icon,
|
|
874
|
+
// and an empty title leaves the icon standing alone. Issue #004.
|
|
875
|
+
const explicitTitle = node.props?.title;
|
|
876
|
+
// The default title names the authored type, not the variant it maps to.
|
|
877
|
+
const title = explicitTitle && explicitTitle.length > 0
|
|
878
|
+
? explicitTitle
|
|
879
|
+
: CALLOUT_LABELS[rawVariant] ?? rawVariant.charAt(0).toUpperCase() + rawVariant.slice(1);
|
|
612
880
|
const icon = node.props?.icon;
|
|
613
|
-
|
|
881
|
+
// The authored type (`hint`, `caution`) beside the variant it shares, as dogsbay-ssg writes it.
|
|
882
|
+
const iconAttr = (icon ? ` icon="${escapeAttr(icon)}"` : "") + (node.props?.["data-type"] === undefined ? ` data-type="${escapeAttr(rawVariant)}"` : "");
|
|
614
883
|
// User classes and passthrough attributes (id, data-*, aria-*, style)
|
|
615
884
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
616
885
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
617
886
|
"type", "variant", "title", "icon", "class",
|
|
618
887
|
]);
|
|
619
888
|
const inner = childrenToAstro(node, ctx);
|
|
889
|
+
// An authored title is inline Markdown (`Before \`harbor init\``), as render-html reads it (MD-002);
|
|
890
|
+
// the default label is text
|
|
891
|
+
const titleNodes = explicitTitle && explicitTitle.length > 0 ? titleInline(title) : [{ type: "text", text: title }];
|
|
892
|
+
const plainTitle = isPlainCaption(titleNodes);
|
|
893
|
+
// `collapsed` / `collapsible`: a disclosure, as render-html draws it (MD-001)
|
|
894
|
+
if (isCollapsibleCallout(node.props)) {
|
|
895
|
+
ctx.imports.add("details");
|
|
896
|
+
const attrs = [`variant="${escapeAttr(collapsibleVariant(variant))}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
|
|
897
|
+
if (calloutStartsOpen(node.props))
|
|
898
|
+
attrs.push("open");
|
|
899
|
+
const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
|
|
900
|
+
const collapsiblePassthrough = renderPassthroughAttrs(node.props, ["type", "variant", "title", "icon", "class", "collapsed", "collapsible", "open"]);
|
|
901
|
+
return `<CollapsibleAlert ${attrs.join(" ")}${iconAttr}${classAttr}${collapsiblePassthrough}>${slot}
|
|
902
|
+
${indentStr(inner, 2)}
|
|
903
|
+
</CollapsibleAlert>`;
|
|
904
|
+
}
|
|
620
905
|
return `<Alert variant="${escapeAttr(variant)}"${iconAttr}${classAttr}${passthrough}>
|
|
621
|
-
<AlertTitle>${escapeTemplate(
|
|
906
|
+
<AlertTitle>${plainTitle ? escapeTemplate(captionText(titleNodes)) : renderInline(titleNodes, ctx)}</AlertTitle>
|
|
622
907
|
<AlertDescription>
|
|
623
908
|
${indentStr(inner, 4)}
|
|
624
909
|
</AlertDescription>
|
|
625
910
|
</Alert>`;
|
|
626
911
|
}
|
|
912
|
+
/**
|
|
913
|
+
* A presence flag: `open`, `open=""`, `open="true"` and `true` are on; absent, `false` and `"false"` are
|
|
914
|
+
* off. A bare `{open}` parses as "" (falsy), and "false" is truthy, so a plain truth test got both wrong
|
|
915
|
+
* (MD-003). render-html reads flags with the same rule (`flagOn`).
|
|
916
|
+
*/
|
|
917
|
+
function flagOn(v) {
|
|
918
|
+
return v !== undefined && v !== null && v !== false && v !== "false";
|
|
919
|
+
}
|
|
920
|
+
/** CollapsibleAlert's variants; `plain` is chrome (no tint, icon or role). */
|
|
921
|
+
const COLLAPSIBLE_VARIANTS = new Set(["plain", "destructive", "note", "abstract", "info", "tip", "success", "question", "warning", "failure", "danger", "bug", "example", "quote"]);
|
|
922
|
+
/** A disclosure's variant: lower-cased, aliases mapped, anything CollapsibleAlert does not draw → note. */
|
|
923
|
+
function collapsibleVariant(raw) {
|
|
924
|
+
const v = raw.toLowerCase();
|
|
925
|
+
const mapped = CALLOUT_VARIANT_MAP[v] ?? v;
|
|
926
|
+
return COLLAPSIBLE_VARIANTS.has(mapped) ? mapped : "note";
|
|
927
|
+
}
|
|
928
|
+
/** A callout with `collapsible` on or any `collapsed` is a disclosure (render-html's `callout`). */
|
|
929
|
+
function isCollapsibleCallout(props) {
|
|
930
|
+
return flagOn(props?.collapsible) || (props?.collapsed !== undefined && props?.collapsed !== null);
|
|
931
|
+
}
|
|
932
|
+
/** It starts open when `collapsed` is off (`collapsed="false"`) or `open` is on. */
|
|
933
|
+
function calloutStartsOpen(props) {
|
|
934
|
+
const c = props?.collapsed;
|
|
935
|
+
return c === false || c === "false" || flagOn(props?.open);
|
|
936
|
+
}
|
|
627
937
|
/**
|
|
628
938
|
* Merge a default class string with an optional user-provided class string.
|
|
629
939
|
* Concatenates (user classes appended) so utility frameworks like Tailwind
|
|
630
940
|
* resolve conflicts via component-internal class-variance-authority / tv().
|
|
631
941
|
*/
|
|
632
|
-
function mergeClassAttr(defaults, userClass) {
|
|
942
|
+
function mergeClassAttr(defaults, userClass, target = "astro") {
|
|
633
943
|
const combined = [defaults, userClass].filter(Boolean).join(" ").trim();
|
|
634
|
-
|
|
944
|
+
if (!combined)
|
|
945
|
+
return "";
|
|
946
|
+
return target === "html"
|
|
947
|
+
? ` class="${escapeAttr(combined)}"`
|
|
948
|
+
: componentAttr("class", combined);
|
|
635
949
|
}
|
|
636
950
|
/**
|
|
637
951
|
* Render attributes not consumed as component props — `id`, `data-*`, `aria-*`,
|
|
638
952
|
* and `style`. These pass through to the rendered element.
|
|
639
953
|
*/
|
|
640
|
-
function renderPassthroughAttrs(props, consumed) {
|
|
954
|
+
function renderPassthroughAttrs(props, consumed, target = "astro") {
|
|
641
955
|
if (!props)
|
|
642
956
|
return "";
|
|
643
957
|
const skip = new Set([...consumed, "source", "children"]);
|
|
@@ -652,9 +966,12 @@ function renderPassthroughAttrs(props, consumed) {
|
|
|
652
966
|
if (value === true) {
|
|
653
967
|
parts.push(key);
|
|
654
968
|
}
|
|
655
|
-
else {
|
|
969
|
+
else if (target === "html") {
|
|
656
970
|
parts.push(`${key}="${escapeAttr(String(value))}"`);
|
|
657
971
|
}
|
|
972
|
+
else {
|
|
973
|
+
parts.push(componentAttr(key, String(value)).trimStart());
|
|
974
|
+
}
|
|
658
975
|
}
|
|
659
976
|
return parts.length ? " " + parts.join(" ") : "";
|
|
660
977
|
}
|
|
@@ -717,25 +1034,78 @@ function wrapBlock(tag, defaultClass, node, content, consumed = []) {
|
|
|
717
1034
|
return `<${tag}${classAttr}${passthrough}>${sep}${content}${sep}</${tag}>`;
|
|
718
1035
|
void needsNewline;
|
|
719
1036
|
}
|
|
1037
|
+
/**
|
|
1038
|
+
* `:::files` around a nested bullet list: FileTree, whose markup (files/markup.ts) dogsbay-ssg
|
|
1039
|
+
* shares. An entry is a folder when it has entries or its name ends in "/".
|
|
1040
|
+
*/
|
|
1041
|
+
function filesToAstro(node, ctx) {
|
|
1042
|
+
ctx.imports.add("files");
|
|
1043
|
+
const isList = (n) => n.type === "unordered-list" || n.type === "ordered-list";
|
|
1044
|
+
const items = (list) => (list?.children ?? [])
|
|
1045
|
+
.filter((li) => li.type === "list-item")
|
|
1046
|
+
.map((li) => {
|
|
1047
|
+
const inline = li.inline ?? (li.children ?? []).find((c) => c.type === "paragraph")?.inline ?? [];
|
|
1048
|
+
const sub = (li.children ?? []).find(isList);
|
|
1049
|
+
const text = inline.map((n) => ("text" in n && typeof n.text === "string" ? n.text : "")).join("");
|
|
1050
|
+
return { label: inlineToHtml(inline), folder: !!sub || /\/\s*$/.test(text), children: items(sub) };
|
|
1051
|
+
});
|
|
1052
|
+
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1053
|
+
// Each list is a tree; anything else in the block (an intro, a note) renders as it is.
|
|
1054
|
+
// `<` is escaped in the JSON: a label may hold inline HTML, and a literal `</script>` in an
|
|
1055
|
+
// expression breaks Vite's dependency scan (see escapeExpr).
|
|
1056
|
+
return (node.children ?? [])
|
|
1057
|
+
.map((child) => isList(child)
|
|
1058
|
+
? `<FileTree items={${JSON.stringify(items(child)).replace(/</g, "\\u003c")}}${classAttr} />`
|
|
1059
|
+
: nodeToAstro(child, ctx))
|
|
1060
|
+
.filter(Boolean)
|
|
1061
|
+
.join("\n");
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* A `:::name` block with a name no renderer knows (a generic container): its content in a
|
|
1065
|
+
* marked <div>, the title (text after the name) first.
|
|
1066
|
+
*/
|
|
1067
|
+
function genericContainerToAstro(node, ctx) {
|
|
1068
|
+
const name = typeof node.meta?.name === "string" ? node.meta.name : "container";
|
|
1069
|
+
const title = typeof node.meta?.title === "string" ? `<p class="font-semibold">${escapeTemplate(node.meta.title)}</p>\n` : "";
|
|
1070
|
+
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1071
|
+
const passthrough = renderPassthroughAttrs(node.props, ["class"]);
|
|
1072
|
+
// Nested props (a `---` block on an unknown directive) reach the page as one JSON attribute,
|
|
1073
|
+
// so a site's own component or script can read them; scalar props pass through as before.
|
|
1074
|
+
const nested = Object.fromEntries(Object.entries(node.props ?? {}).filter(([, v]) => v !== null && typeof v === "object"));
|
|
1075
|
+
const dataProps = Object.keys(nested).length ? ` data-props="${escapeAttr(JSON.stringify(nested))}"` : "";
|
|
1076
|
+
return `<div data-directive="${escapeAttr(name)}"${classAttr}${passthrough}${dataProps}>\n${title}${childrenToAstro(node, ctx)}\n</div>`;
|
|
1077
|
+
}
|
|
720
1078
|
function detailsToAstro(node, ctx) {
|
|
721
1079
|
ctx.imports.add("details");
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
1080
|
+
// `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
|
|
1081
|
+
// Aliases (`caution` → danger) and unknown names (→ note) as render-html maps them
|
|
1082
|
+
const variant = collapsibleVariant(node.props?.variant ?? node.props?.type ?? "note");
|
|
1083
|
+
// `summary` is dogsbay-md's alias for the title (`:::details{summary="…"}`)
|
|
1084
|
+
const title = node.props?.title ?? node.props?.summary ?? "Details";
|
|
1085
|
+
const open = flagOn(node.props?.open);
|
|
725
1086
|
const icon = node.props?.icon;
|
|
726
1087
|
const inner = childrenToAstro(node, ctx);
|
|
727
|
-
|
|
1088
|
+
// The title is inline Markdown, as in render-html: plain text stays the `title` prop
|
|
1089
|
+
const titleNodes = titleInline(title);
|
|
1090
|
+
const plainTitle = isPlainCaption(titleNodes);
|
|
1091
|
+
const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
|
|
728
1092
|
if (open)
|
|
729
1093
|
attrs.push("open");
|
|
730
1094
|
if (icon)
|
|
731
1095
|
attrs.push(`icon="${escapeAttr(icon)}"`);
|
|
732
1096
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
733
1097
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
734
|
-
"variant", "title", "open", "icon", "class", "summary",
|
|
1098
|
+
"variant", "type", "title", "open", "icon", "class", "summary", "sheet",
|
|
735
1099
|
]);
|
|
736
|
-
|
|
1100
|
+
const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
|
|
1101
|
+
const alert = `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>${slot}
|
|
737
1102
|
${indentStr(inner, 2)}
|
|
738
1103
|
</CollapsibleAlert>`;
|
|
1104
|
+
// `sheet`: the same details, opened as a side sheet by <db-details-sheet>
|
|
1105
|
+
if (!isDetailsSheet(node.props))
|
|
1106
|
+
return alert;
|
|
1107
|
+
ctx.imports.add("details-sheet");
|
|
1108
|
+
return `<DetailsSheet>\n${indentStr(alert, 2)}\n</DetailsSheet>`;
|
|
739
1109
|
}
|
|
740
1110
|
function tabsToAstro(node, ctx) {
|
|
741
1111
|
ctx.imports.add("tabs");
|
|
@@ -743,45 +1113,162 @@ function tabsToAstro(node, ctx) {
|
|
|
743
1113
|
if (tabs.length === 0)
|
|
744
1114
|
return "";
|
|
745
1115
|
const tabItems = tabs.map((tab, i) => {
|
|
746
|
-
|
|
1116
|
+
// The same order as render-html: `label` is ours, `title` what a Comark import wrote
|
|
1117
|
+
const title = tab.props?.label || tab.props?.title || tab.props?.value || `Tab ${i + 1}`;
|
|
747
1118
|
const value = tab.props?.value || title;
|
|
748
1119
|
const label = title;
|
|
749
1120
|
return { value, label, node: tab };
|
|
750
1121
|
});
|
|
751
1122
|
const defaultValue = tabItems[0].value;
|
|
1123
|
+
// Diff-trigger indicator: a marked tab panel is invisible until
|
|
1124
|
+
// opened, so its TRIGGER carries a data-diff-tab attribute (styled
|
|
1125
|
+
// as a colored dot by DIFF_CSS) telling the reader WHICH tab to
|
|
1126
|
+
// open. Structural read of @dogsbay/tree-diff marks, same as
|
|
1127
|
+
// diff-decoration; unmarked trees are unaffected.
|
|
1128
|
+
// The indicator must not live in COLOUR alone (WCAG 1.4.1), and a
|
|
1129
|
+
// background-tinted dot disappears in forced-colors mode. So it is a
|
|
1130
|
+
// GLYPH (shape carries the meaning) plus visually-hidden text, so the
|
|
1131
|
+
// trigger announces "Astro, changed in this comparison" rather than
|
|
1132
|
+
// relying on a `title` screen readers may never speak. (TAB_GLYPH and
|
|
1133
|
+
// tabDiffSignal are shared with render-html.)
|
|
1134
|
+
// A variant's class strings are complete (TAB_VARIANTS): TabsList and
|
|
1135
|
+
// TabsTrigger merge a `class` over their base with tailwind-variants
|
|
1136
|
+
const variant = lookup(TAB_VARIANTS, node.props?.variant);
|
|
1137
|
+
const triggerClass = variant ? ` class="${escapeAttr(variant.trigger)}"` : "";
|
|
752
1138
|
const triggers = tabItems
|
|
753
|
-
.map((t) =>
|
|
1139
|
+
.map((t) => {
|
|
1140
|
+
const signal = tabDiffSignal(t.node);
|
|
1141
|
+
if (!signal) {
|
|
1142
|
+
return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass}>${escapeTemplate(t.label)}</TabsTrigger>`;
|
|
1143
|
+
}
|
|
1144
|
+
const glyph = TAB_GLYPH[signal] ?? "•";
|
|
1145
|
+
// The glyph and its announced text are ONE element: "Hide
|
|
1146
|
+
// highlights" hides `.db-tab-mark`, and a sibling sr-only span
|
|
1147
|
+
// would survive that — leaving screen-reader users still hearing
|
|
1148
|
+
// "changed in this comparison" on every tab while the visible
|
|
1149
|
+
// marker is gone (code review, 2026-07-13).
|
|
1150
|
+
const marker = `<span class="db-tab-mark" data-diff-tab="${escapeAttr(signal)}">` +
|
|
1151
|
+
`<span aria-hidden="true">${glyph}</span>` +
|
|
1152
|
+
`<span class="sr-only">${escapeTemplate(signal)} in this comparison</span>` +
|
|
1153
|
+
`</span>`;
|
|
1154
|
+
// `value` MUST match TabsContent's, which is a componentAttr expression:
|
|
1155
|
+
// a title containing `&` was emitted `&` here and `&` there, so the
|
|
1156
|
+
// trigger and its panel no longer paired and the tab rendered empty.
|
|
1157
|
+
// `data-diff-tab` is a plain data attribute — entity escaping is right.
|
|
1158
|
+
return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass} data-diff-tab="${escapeAttr(signal)}">${escapeTemplate(t.label)}<Fragment set:html={${escapeExpr(marker)}} /></TabsTrigger>`;
|
|
1159
|
+
})
|
|
754
1160
|
.join("\n");
|
|
755
1161
|
const contents = tabItems
|
|
756
1162
|
.map((t) => {
|
|
757
1163
|
const inner = childrenToAstro(t.node, ctx);
|
|
758
|
-
return ` <TabsContent
|
|
1164
|
+
return ` <TabsContent${componentAttr("value", t.value)}>\n${indentStr(inner, 4)}\n </TabsContent>`;
|
|
759
1165
|
})
|
|
760
1166
|
.join("\n");
|
|
761
1167
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
762
1168
|
const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
|
|
763
|
-
|
|
764
|
-
|
|
1169
|
+
// Groups with the same key switch together (db-tabs). Written `syncKey`, `sync-key` or `group`, as dogsbay-ssg reads it.
|
|
1170
|
+
const syncKey = [node.props?.syncKey, node.props?.["sync-key"], node.props?.group].find((v) => typeof v === "string" && v !== "");
|
|
1171
|
+
const syncAttr = syncKey ? componentAttr("syncKey", syncKey) : "";
|
|
1172
|
+
// Same encoding as the trigger/content `value` it selects — see above.
|
|
1173
|
+
return `<Tabs${componentAttr("defaultValue", defaultValue)}${syncAttr}${classAttr}${passthrough}>
|
|
1174
|
+
<TabsList${variant ? ` class="${escapeAttr(variant.list)}"` : ""}>
|
|
765
1175
|
${triggers}
|
|
766
1176
|
</TabsList>
|
|
767
1177
|
${contents}
|
|
768
1178
|
</Tabs>`;
|
|
769
1179
|
}
|
|
1180
|
+
/**
|
|
1181
|
+
* A caption is inline Markdown. Plain text renders exactly as before;
|
|
1182
|
+
* formatting (`Sample \`<plugin>\` file`) renders as inline markup.
|
|
1183
|
+
*/
|
|
1184
|
+
function captionInline(caption) {
|
|
1185
|
+
return parseInline(caption);
|
|
1186
|
+
}
|
|
1187
|
+
const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
|
|
1188
|
+
/**
|
|
1189
|
+
* Formatting tags with no attributes, the HTML a title keeps (a raw `<details><summary><code>x</code>`
|
|
1190
|
+
* becomes the title `<code>x</code>`). render-html has the same list.
|
|
1191
|
+
*/
|
|
1192
|
+
const TITLE_TAG = /^<\/?(?:code|kbd|b|strong|i|em|s|del|mark|sub|sup|br)\s*\/?>$/i;
|
|
1193
|
+
/**
|
|
1194
|
+
* A callout or details title: inline Markdown, and any other raw HTML is TEXT. Importers write
|
|
1195
|
+
* plain-text titles with angle brackets (`Map<K, V>`, `List<String>`), which as HTML dropped words,
|
|
1196
|
+
* and a title with `<img onerror=…>` ran a script in the summary. render-html reads titles the same way.
|
|
1197
|
+
*/
|
|
1198
|
+
function titleInline(title) {
|
|
1199
|
+
const nodes = captionInline(title);
|
|
1200
|
+
const kept = balancedTitleTags(nodes);
|
|
1201
|
+
return nodes.map((n, i) => (n.type === "html-inline" && !kept.has(i) ? { type: "text", text: n.html } : n));
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* The indexes of the formatting tags a title keeps: a bare `TITLE_TAG` that is one half of a
|
|
1205
|
+
* matched pair (or `<br>`). An unclosed `<em>` was kept, and the browser carried it past the
|
|
1206
|
+
* title's element into the rest of the page (render-html has the same rule).
|
|
1207
|
+
*/
|
|
1208
|
+
function balancedTitleTags(nodes) {
|
|
1209
|
+
const kept = new Set();
|
|
1210
|
+
const open = [];
|
|
1211
|
+
nodes.forEach((n, i) => {
|
|
1212
|
+
if (n.type !== "html-inline")
|
|
1213
|
+
return;
|
|
1214
|
+
const html = n.html.trim();
|
|
1215
|
+
if (!TITLE_TAG.test(html))
|
|
1216
|
+
return;
|
|
1217
|
+
const name = /^<\/?\s*(\w+)/.exec(html)[1].toLowerCase();
|
|
1218
|
+
if (name === "br")
|
|
1219
|
+
kept.add(i);
|
|
1220
|
+
else if (!html.startsWith("</"))
|
|
1221
|
+
open.push({ name, at: i });
|
|
1222
|
+
else if (open.length && open[open.length - 1].name === name) {
|
|
1223
|
+
kept.add(open.pop().at);
|
|
1224
|
+
kept.add(i);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
return kept;
|
|
1228
|
+
}
|
|
1229
|
+
/** A plain caption's text (escapes undone by the parse). */
|
|
1230
|
+
const captionText = (nodes) => nodes.map((n) => (n.type === "text" ? n.text : "")).join("");
|
|
1231
|
+
function captionToAstro(caption, ctx) {
|
|
1232
|
+
// Raw HTML other than balanced formatting tags is text, as in a title (an imported repo's caption
|
|
1233
|
+
// is someone else's; render-html draws captions the same way).
|
|
1234
|
+
const nodes = titleInline(caption);
|
|
1235
|
+
return isPlainCaption(nodes)
|
|
1236
|
+
? escapeTemplate(captionText(nodes))
|
|
1237
|
+
: renderInline(nodes, ctx);
|
|
1238
|
+
}
|
|
770
1239
|
function tableToAstro(node, ctx) {
|
|
771
1240
|
ctx.imports.add("table");
|
|
772
1241
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
773
|
-
const
|
|
774
|
-
|
|
1242
|
+
const caption = node.props?.caption;
|
|
1243
|
+
const nodes = caption ? captionInline(caption) : [];
|
|
1244
|
+
const plain = isPlainCaption(nodes);
|
|
1245
|
+
// A plain caption stays the `caption` prop; a formatted one fills the table's caption slot
|
|
1246
|
+
const captionAttr = caption && plain
|
|
1247
|
+
? ` caption={${escapeExpr(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))}}`
|
|
1248
|
+
: "";
|
|
1249
|
+
const captionSlot = caption && !plain ? `<Fragment slot="caption">${renderInline(nodes, ctx)}</Fragment>\n` : "";
|
|
1250
|
+
const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
|
|
1251
|
+
return `<Table${classAttr}${captionAttr}${passthrough}>\n${captionSlot}${childrenToAstro(node, ctx)}\n</Table>`;
|
|
775
1252
|
}
|
|
776
1253
|
function diagramToAstro(node, ctx) {
|
|
777
1254
|
ctx.imports.add("diagram");
|
|
778
1255
|
const lang = node.props?.lang || "mermaid";
|
|
779
1256
|
const code = node.props?.code ?? "";
|
|
780
|
-
|
|
1257
|
+
let svg = node.props?.svg ?? "";
|
|
781
1258
|
const title = node.props?.title;
|
|
1259
|
+
// A Pele SVG's ids (`<prefix>-title`, `<prefix>-desc`, for aria-labelledby) are unique within
|
|
1260
|
+
// its source page. A book view or an include can show the same diagram twice on one page, so
|
|
1261
|
+
// the later copies are renamed; the prefix appears nowhere else in the SVG.
|
|
1262
|
+
const prefix = /^<svg[^>]*\bclass="pele\b/.test(svg) ? /\sid="(dg-[0-9a-f]{12}(?:-\d+)?)-/.exec(svg)?.[1] : undefined;
|
|
1263
|
+
if (prefix) {
|
|
1264
|
+
const n = (ctx.diagramIds.get(prefix) ?? 0) + 1;
|
|
1265
|
+
ctx.diagramIds.set(prefix, n);
|
|
1266
|
+
if (n > 1)
|
|
1267
|
+
svg = svg.split(prefix).join(`${prefix}-p${n}`);
|
|
1268
|
+
}
|
|
782
1269
|
const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
|
|
783
1270
|
if (title)
|
|
784
|
-
attrs.push(
|
|
1271
|
+
attrs.push(componentAttr("title", title).trimStart());
|
|
785
1272
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
786
1273
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
787
1274
|
"lang", "code", "svg", "title", "class",
|
|
@@ -794,7 +1281,7 @@ function youtubeToAstro(node, ctx) {
|
|
|
794
1281
|
const title = node.props?.title ?? "";
|
|
795
1282
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
796
1283
|
const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
|
|
797
|
-
return `<YouTube
|
|
1284
|
+
return `<YouTube${componentAttr("id", id)}${componentAttr("title", title)}${classAttr}${passthrough} />`;
|
|
798
1285
|
}
|
|
799
1286
|
function mathBlockToAstro(node, ctx) {
|
|
800
1287
|
ctx.imports.add("math-block");
|
|
@@ -837,6 +1324,11 @@ function footnoteListToAstro(node, ctx) {
|
|
|
837
1324
|
function footnoteDefToAstro(node, ctx) {
|
|
838
1325
|
const id = node.props?.id ?? "";
|
|
839
1326
|
const backrefId = node.props?.backrefId ?? "";
|
|
1327
|
+
// Block content (a list, code): the blocks, with the backref after them
|
|
1328
|
+
if (node.children?.length) {
|
|
1329
|
+
const backref = backrefId ? `\n<a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
|
|
1330
|
+
return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">\n${childrenToAstro(node, ctx)}${backref}\n</li>`;
|
|
1331
|
+
}
|
|
840
1332
|
if (ctx.mode === "template" && node.inline) {
|
|
841
1333
|
const content = inlineToTemplate(node.inline);
|
|
842
1334
|
const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
|
|
@@ -852,8 +1344,14 @@ function apiSymbolToAstro(node, ctx) {
|
|
|
852
1344
|
const kind = node.props?.kind ?? "func";
|
|
853
1345
|
const signature = node.props?.signature;
|
|
854
1346
|
const bases = node.props?.bases || [];
|
|
855
|
-
|
|
856
|
-
|
|
1347
|
+
// By position, not by type (apiSymbolLevels): a module-level function was
|
|
1348
|
+
// an h3 straight under the page's h1, and a class inside a class an h2.
|
|
1349
|
+
const level = ctx.apiSymbolLevels.get(node) ?? (node.type === "api-class" ? 2 : 3);
|
|
1350
|
+
const attrs = [
|
|
1351
|
+
componentAttr("name", name).trimStart(),
|
|
1352
|
+
componentAttr("kind", kind).trimStart(),
|
|
1353
|
+
`level={${level}}`,
|
|
1354
|
+
];
|
|
857
1355
|
if (signature)
|
|
858
1356
|
attrs.push(`signature={${escapeExpr(signature)}}`);
|
|
859
1357
|
if (bases.length > 0)
|
|
@@ -902,11 +1400,17 @@ function endpointToAstro(node, ctx) {
|
|
|
902
1400
|
const path = props.path ?? "";
|
|
903
1401
|
const baseUrl = props.baseUrl ?? "";
|
|
904
1402
|
const summary = props.summary;
|
|
905
|
-
|
|
1403
|
+
// Descriptions are CommonMark, rendered with raw HTML off: the components
|
|
1404
|
+
// put them in the page with set:html (api-markdown.ts).
|
|
1405
|
+
// (Compared as text first: the card hides a description equal to the
|
|
1406
|
+
// summary, and the rendered HTML never equals it.)
|
|
1407
|
+
const description = typeof props.description === "string" && props.description !== props.summary
|
|
1408
|
+
? apiBlockHtml(props.description)
|
|
1409
|
+
: undefined;
|
|
906
1410
|
const deprecated = props.deprecated === true;
|
|
907
|
-
const parameters = props.parameters ?? [];
|
|
908
|
-
const requestBody = props.requestBody ?? [];
|
|
909
|
-
const responses = props.responses ?? [];
|
|
1411
|
+
const parameters = apiPropertiesHtml(props.parameters) ?? [];
|
|
1412
|
+
const requestBody = apiPropertiesHtml(props.requestBody) ?? [];
|
|
1413
|
+
const responses = (props.responses ?? []).map((r) => r && Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r);
|
|
910
1414
|
const codeSamples = props.codeSamples ?? [];
|
|
911
1415
|
const requestBodyExample = props.requestBodyExample;
|
|
912
1416
|
const cardAttrs = [
|
|
@@ -946,7 +1450,9 @@ function endpointToAstro(node, ctx) {
|
|
|
946
1450
|
const sectionsBody = sections.length > 0
|
|
947
1451
|
? `\n${indentStr(sections.join("\n"), 4)}\n `
|
|
948
1452
|
: "";
|
|
949
|
-
// Code column —
|
|
1453
|
+
// Code column — its titles are labels (<p>), not headings: an operation
|
|
1454
|
+
// page has only its h1 above them, so an h3 skipped a level.
|
|
1455
|
+
// Wrapped in <ApiCodePanel> for consistent vertical
|
|
950
1456
|
// rhythm. Code samples first, then a request body example, then one
|
|
951
1457
|
// ExampleBlock per response status that carries a concrete example.
|
|
952
1458
|
// Each ExampleBlock auto-detects language from the response's
|
|
@@ -955,7 +1461,7 @@ function endpointToAstro(node, ctx) {
|
|
|
955
1461
|
const codePanels = [];
|
|
956
1462
|
if (codeSamples.length > 0) {
|
|
957
1463
|
codePanels.push(`<div>\n` +
|
|
958
|
-
` <
|
|
1464
|
+
` <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</p>\n` +
|
|
959
1465
|
` <CodeSamples samples={${JSON.stringify(codeSamples)}} />\n` +
|
|
960
1466
|
`</div>`);
|
|
961
1467
|
}
|
|
@@ -975,31 +1481,66 @@ function endpointToAstro(node, ctx) {
|
|
|
975
1481
|
const codeBody = codePanels.length > 0
|
|
976
1482
|
? `\n${indentStr(codePanels.join("\n"), 4)}\n `
|
|
977
1483
|
: "";
|
|
978
|
-
|
|
1484
|
+
const layoutAttrs = ctx.embeddedEndpoints ? ' variant="embedded"' : "";
|
|
1485
|
+
return (`<ApiLayout${layoutAttrs}>\n` +
|
|
979
1486
|
` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
|
|
980
1487
|
` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
|
|
981
1488
|
`</ApiLayout>`);
|
|
982
1489
|
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Whole-card links being emitted. Inside one, a link in the card's body is its text: a browser
|
|
1492
|
+
* ends the outer <a> at an inner one, so the rest of the card fell out of the link (and lost
|
|
1493
|
+
* not-prose). The serializer is synchronous, so a depth count is enough.
|
|
1494
|
+
*/
|
|
1495
|
+
let cardLinkDepth = 0;
|
|
1496
|
+
function wholeCardLink(yes, fn) {
|
|
1497
|
+
if (!yes)
|
|
1498
|
+
return fn();
|
|
1499
|
+
cardLinkDepth++;
|
|
1500
|
+
try {
|
|
1501
|
+
return fn();
|
|
1502
|
+
}
|
|
1503
|
+
finally {
|
|
1504
|
+
cardLinkDepth--;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
/** A card's title: not-prose, or docs.css's h3 rule (1.25rem, 2rem above) applies inside the card. */
|
|
1508
|
+
const CARD_TITLE_CLASS = "not-prose m-0 border-0 p-0 text-base font-semibold";
|
|
1509
|
+
/**
|
|
1510
|
+
* A card group's columns, responsive as render-html draws them (its GRID_COLS): one column on a
|
|
1511
|
+
* phone, then up to `cols` (1–6) by Grid's breakpoints (0, 640, 768, 1024). Default 2. A list
|
|
1512
|
+
* (`cols="1,2,3"`) is the author's own breakpoints and passes through.
|
|
1513
|
+
*/
|
|
1514
|
+
function cardCols(raw) {
|
|
1515
|
+
const list = coerceNumericList(raw, [2]);
|
|
1516
|
+
if (list.length > 1)
|
|
1517
|
+
return list;
|
|
1518
|
+
const n = Math.min(6, Math.max(1, Math.round(list[0]) || 2));
|
|
1519
|
+
return n === 1 ? [1] : n === 2 ? [1, 2] : n <= 4 ? [1, 2, 2, n] : [1, 3, 3, n];
|
|
1520
|
+
}
|
|
983
1521
|
function cardsToAstro(node, ctx) {
|
|
984
1522
|
ctx.imports.add("grid");
|
|
985
1523
|
ctx.imports.add("card");
|
|
986
1524
|
// Grid-level attributes on the :::cards container
|
|
987
1525
|
const outerClass = mergeClassAttr("", node.props?.class);
|
|
988
1526
|
const outerPass = renderPassthroughAttrs(node.props, [
|
|
989
|
-
"cols", "gap", "rows", "class",
|
|
1527
|
+
"cols", "gap", "rows", "class", "tone",
|
|
990
1528
|
]);
|
|
991
1529
|
// MDX pattern: children are card nodes with title/href/icon props
|
|
992
1530
|
const cardNodes = (node.children ?? []).filter((c) => c.type === "card");
|
|
993
1531
|
if (cardNodes.length > 0) {
|
|
994
1532
|
const cards = cardNodes.map((card) => {
|
|
995
1533
|
const title = card.props?.title ?? "";
|
|
996
|
-
const href = card.props?.href
|
|
1534
|
+
const href = safeHref(card.props?.href);
|
|
997
1535
|
const description = card.props?.description;
|
|
998
1536
|
const icon = card.props?.icon;
|
|
999
1537
|
// Description sources: inline content, children, or props.description.
|
|
1000
|
-
|
|
1001
|
-
|
|
1538
|
+
// Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
|
|
1539
|
+
const blocks = wholeCardLink(!!href, () => childrenToAstro(card, ctx));
|
|
1540
|
+
const inner = blocks
|
|
1541
|
+
|| (card.inline ? wholeCardLink(!!href, () => inlineToTemplate(card.inline)) : "")
|
|
1002
1542
|
|| (description ? escapeTemplate(description) : "");
|
|
1543
|
+
const descTag = blocks ? "div" : "p";
|
|
1003
1544
|
// Icon renders inline before the title — picks up Lucide
|
|
1004
1545
|
// SVG via the `icon` import. Resolved at build time; nothing
|
|
1005
1546
|
// emitted when the name doesn't resolve (silent fallback).
|
|
@@ -1007,26 +1548,32 @@ function cardsToAstro(node, ctx) {
|
|
|
1007
1548
|
// top of the page.
|
|
1008
1549
|
let iconHtml = "";
|
|
1009
1550
|
if (icon) {
|
|
1010
|
-
|
|
1011
|
-
iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
|
|
1551
|
+
iconHtml = cardIconHtml(icon, title, ctx);
|
|
1012
1552
|
}
|
|
1013
|
-
const titleHtml = title ? `<h3 class="
|
|
1014
|
-
|
|
1015
|
-
|
|
1553
|
+
const titleHtml = title ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>` : "";
|
|
1554
|
+
// A description and a body are both shown (the description used to be dropped when a body existed).
|
|
1555
|
+
const lead = blocks && description
|
|
1556
|
+
? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
|
|
1016
1557
|
: "";
|
|
1558
|
+
const desc = lead + (inner
|
|
1559
|
+
? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
|
|
1560
|
+
: "");
|
|
1561
|
+
// A tone (TONES; the card's own, else the group's) sets the card's
|
|
1562
|
+
// border and tint: Card's tailwind-variants merge replaces bg-card
|
|
1563
|
+
const toned = lookup(CARD_TONE_CLASSES, card.props?.tone ?? node.props?.tone);
|
|
1017
1564
|
const cardDefaultClass = href
|
|
1018
|
-
? "p-4 hover:border-foreground/20 transition-colors"
|
|
1019
|
-
: "p-4";
|
|
1565
|
+
? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
|
|
1566
|
+
: toned ? `p-4 ${toned}` : "p-4";
|
|
1020
1567
|
const cardClassAttr = mergeClassAttr(cardDefaultClass, card.props?.class);
|
|
1021
1568
|
const cardPass = renderPassthroughAttrs(card.props, [
|
|
1022
|
-
"title", "href", "description", "icon", "variant", "class",
|
|
1569
|
+
"title", "href", "description", "icon", "variant", "class", "tone",
|
|
1023
1570
|
]);
|
|
1024
1571
|
if (href) {
|
|
1025
|
-
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>`;
|
|
1572
|
+
return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="not-prose [text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
|
|
1026
1573
|
}
|
|
1027
1574
|
return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
|
|
1028
1575
|
}).join("\n");
|
|
1029
|
-
return `<Grid cols={[
|
|
1576
|
+
return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}${outerClass}${outerPass}>\n${cards}\n</Grid>`;
|
|
1030
1577
|
}
|
|
1031
1578
|
// MkDocs pattern: children are unordered-list items
|
|
1032
1579
|
const items = (node.children ?? [])
|
|
@@ -1036,10 +1583,10 @@ function cardsToAstro(node, ctx) {
|
|
|
1036
1583
|
const cards = items
|
|
1037
1584
|
.map((item) => `<Card>\n<CardContent>\n${childrenToAstro(item, ctx)}\n</CardContent>\n</Card>`)
|
|
1038
1585
|
.join("\n");
|
|
1039
|
-
return `<Grid cols={[
|
|
1586
|
+
return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${cards}\n</Grid>`;
|
|
1040
1587
|
}
|
|
1041
1588
|
// Fallback: render children directly in grid
|
|
1042
|
-
return `<Grid cols={[
|
|
1589
|
+
return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${childrenToAstro(node, ctx)}\n</Grid>`;
|
|
1043
1590
|
}
|
|
1044
1591
|
/**
|
|
1045
1592
|
* Render a standalone `card` TreeNode (outside `:::cards`).
|
|
@@ -1051,34 +1598,38 @@ function cardsToAstro(node, ctx) {
|
|
|
1051
1598
|
function standaloneCardToAstro(node, ctx) {
|
|
1052
1599
|
ctx.imports.add("card");
|
|
1053
1600
|
const title = node.props?.title ?? "";
|
|
1054
|
-
const href = node.props?.href
|
|
1601
|
+
const href = safeHref(node.props?.href);
|
|
1055
1602
|
const description = node.props?.description;
|
|
1056
1603
|
const icon = node.props?.icon;
|
|
1057
|
-
|
|
1058
|
-
|
|
1604
|
+
// Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
|
|
1605
|
+
const blocks = wholeCardLink(!!href, () => childrenToAstro(node, ctx));
|
|
1606
|
+
const inner = blocks
|
|
1607
|
+
|| (node.inline ? wholeCardLink(!!href, () => inlineToTemplate(node.inline)) : "")
|
|
1059
1608
|
|| (description ? escapeTemplate(description) : "");
|
|
1060
|
-
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1063
|
-
if (icon) {
|
|
1064
|
-
ctx.imports.add("icon");
|
|
1065
|
-
iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
|
|
1066
|
-
}
|
|
1609
|
+
const descTag = blocks ? "div" : "p";
|
|
1610
|
+
// Icon renders inline before the title, a name or an image path, as in a card group
|
|
1611
|
+
const iconHtml = icon ? cardIconHtml(icon, title, ctx) : "";
|
|
1067
1612
|
const titleHtml = title
|
|
1068
|
-
? `<h3 class="
|
|
1613
|
+
? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>`
|
|
1069
1614
|
: "";
|
|
1070
|
-
|
|
1071
|
-
|
|
1615
|
+
// A description and a body are both shown (the description used to be dropped when a body existed).
|
|
1616
|
+
const lead = blocks && description
|
|
1617
|
+
? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
|
|
1072
1618
|
: "";
|
|
1619
|
+
const desc = lead + (inner
|
|
1620
|
+
? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
|
|
1621
|
+
: "");
|
|
1622
|
+
// A tone (TONES), as on a card in a group (cardsToAstro)
|
|
1623
|
+
const toned = lookup(CARD_TONE_CLASSES, node.props?.tone);
|
|
1073
1624
|
const cardDefaultClass = href
|
|
1074
|
-
? "p-4 hover:border-foreground/20 transition-colors"
|
|
1075
|
-
: "p-4";
|
|
1625
|
+
? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
|
|
1626
|
+
: toned ? `p-4 ${toned}` : "p-4";
|
|
1076
1627
|
const classAttr = mergeClassAttr(cardDefaultClass, node.props?.class);
|
|
1077
1628
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1078
|
-
"title", "href", "description", "icon", "variant", "class",
|
|
1629
|
+
"title", "href", "description", "icon", "variant", "class", "tone",
|
|
1079
1630
|
]);
|
|
1080
1631
|
if (href) {
|
|
1081
|
-
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>`;
|
|
1632
|
+
return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n<a href="${escapeAttr(href)}" class="not-prose [text-decoration:none] text-foreground">\n${iconHtml}\n${titleHtml}\n${desc}\n</a>\n</CardContent>\n</Card>`;
|
|
1082
1633
|
}
|
|
1083
1634
|
return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
|
|
1084
1635
|
}
|
|
@@ -1089,6 +1640,59 @@ function standaloneCardToAstro(node, ctx) {
|
|
|
1089
1640
|
* If the parser kept rich body children instead of folding into description,
|
|
1090
1641
|
* we fall back to the standalone-card path which can hold arbitrary content.
|
|
1091
1642
|
*/
|
|
1643
|
+
/**
|
|
1644
|
+
* `link-button` — a call-to-action link styled as a button.
|
|
1645
|
+
*
|
|
1646
|
+
* There was no case for this at all, so it fell through to rendering the
|
|
1647
|
+
* node's CHILDREN: `<p>Get started</p>`. The label survived, the href did not,
|
|
1648
|
+
* and nothing warned — the reader saw a stray sentence where the page's
|
|
1649
|
+
* primary call to action should be. Every importer that produces one was
|
|
1650
|
+
* affected; `Button.astro` has taken an `href` all along.
|
|
1651
|
+
*/
|
|
1652
|
+
/**
|
|
1653
|
+
* A card icon is either a registry NAME or a path to an image file.
|
|
1654
|
+
*
|
|
1655
|
+
* `<Icon name>` resolves names and renders NOTHING when the name does not
|
|
1656
|
+
* resolve — so a path handed to it disappeared silently, which is how every
|
|
1657
|
+
* "Related products" card lost its logo without anyone noticing.
|
|
1658
|
+
*/
|
|
1659
|
+
function cardIconHtml(icon, title, ctx) {
|
|
1660
|
+
if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon)) {
|
|
1661
|
+
return `<img src="${escapeAttr(icon)}" alt="" class="size-5 mb-2" loading="lazy" />`;
|
|
1662
|
+
}
|
|
1663
|
+
ctx.imports.add("icon");
|
|
1664
|
+
return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* A button's variant as `Button.astro` names it. The spec's `primary` is its
|
|
1668
|
+
* filled `default` style; `Button.astro` has no `primary`, so it rendered
|
|
1669
|
+
* with no fill at all.
|
|
1670
|
+
*/
|
|
1671
|
+
function buttonVariant(variant) {
|
|
1672
|
+
return !variant || variant === "primary" ? "default" : variant;
|
|
1673
|
+
}
|
|
1674
|
+
function linkButtonToAstro(node, ctx) {
|
|
1675
|
+
ctx.imports.add("link-button");
|
|
1676
|
+
const href = safeHref(node.props?.href);
|
|
1677
|
+
const written = node.props?.variant;
|
|
1678
|
+
const variant = written === undefined ? undefined : buttonVariant(written);
|
|
1679
|
+
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1680
|
+
const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
|
|
1681
|
+
// A button's label is phrasing content, so a lone paragraph child is
|
|
1682
|
+
// unwrapped — `<Button><p>Get started</p></Button>` is invalid markup and
|
|
1683
|
+
// renders with the paragraph's block spacing inside the button.
|
|
1684
|
+
const kids = node.children ?? [];
|
|
1685
|
+
const soleParagraph = kids.length === 1 && kids[0].type === "paragraph" ? kids[0] : undefined;
|
|
1686
|
+
const label = soleParagraph
|
|
1687
|
+
? leafContent(soleParagraph, ctx)
|
|
1688
|
+
: leafContent(node, ctx) || childrenToAstro(node, ctx);
|
|
1689
|
+
return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
|
|
1690
|
+
}
|
|
1691
|
+
/** A card or button `href` from an importer, or "" (no link) when its
|
|
1692
|
+
* scheme could run script: these never passed markdown-it's link check. */
|
|
1693
|
+
function safeHref(href) {
|
|
1694
|
+
return typeof href === "string" && isSafeHref(href) ? href : "";
|
|
1695
|
+
}
|
|
1092
1696
|
function linkCardToAstro(node, ctx) {
|
|
1093
1697
|
const hasRichChildren = (node.children ?? []).length > 0;
|
|
1094
1698
|
if (hasRichChildren) {
|
|
@@ -1097,15 +1701,17 @@ function linkCardToAstro(node, ctx) {
|
|
|
1097
1701
|
ctx.imports.add("link-card");
|
|
1098
1702
|
const title = node.props?.title ?? "";
|
|
1099
1703
|
const description = node.props?.description;
|
|
1100
|
-
const href = node.props?.href
|
|
1101
|
-
const titleAttr =
|
|
1102
|
-
const hrefAttr =
|
|
1103
|
-
const descAttr = description ?
|
|
1704
|
+
const href = safeHref(node.props?.href);
|
|
1705
|
+
const titleAttr = componentAttr("title", title);
|
|
1706
|
+
const hrefAttr = componentAttr("href", href);
|
|
1707
|
+
const descAttr = description ? componentAttr("description", description) : "";
|
|
1708
|
+
const icon = node.props?.icon;
|
|
1709
|
+
const iconAttr = icon ? componentAttr("icon", icon) : "";
|
|
1104
1710
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1105
1711
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1106
1712
|
"title", "description", "href", "icon", "class",
|
|
1107
1713
|
]);
|
|
1108
|
-
return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
|
|
1714
|
+
return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${iconAttr}${classAttr}${passthrough} />`;
|
|
1109
1715
|
}
|
|
1110
1716
|
/**
|
|
1111
1717
|
* Render an `accordion` TreeNode and its `accordion-item` children.
|
|
@@ -1124,9 +1730,9 @@ function accordionToAstro(node, ctx) {
|
|
|
1124
1730
|
const defaultValue = node.props?.defaultValue;
|
|
1125
1731
|
const typeAttr = ` type="${escapeAttr(type)}"`;
|
|
1126
1732
|
const defaultAttr = defaultValue !== undefined
|
|
1127
|
-
?
|
|
1128
|
-
?
|
|
1129
|
-
: `{${JSON.stringify(defaultValue)}}`
|
|
1733
|
+
? typeof defaultValue === "string"
|
|
1734
|
+
? componentAttr("defaultValue", defaultValue)
|
|
1735
|
+
: ` defaultValue={${JSON.stringify(defaultValue)}}`
|
|
1130
1736
|
: "";
|
|
1131
1737
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1132
1738
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
@@ -1134,15 +1740,18 @@ function accordionToAstro(node, ctx) {
|
|
|
1134
1740
|
]);
|
|
1135
1741
|
const items = (node.children ?? [])
|
|
1136
1742
|
.filter((c) => c.type === "accordion-item")
|
|
1137
|
-
.map((item) => accordionItemToAstro(item, ctx, /*standalone*/ false))
|
|
1743
|
+
.map((item, i) => accordionItemToAstro(item, ctx, /*standalone*/ false, i + 1))
|
|
1138
1744
|
.join("\n");
|
|
1139
1745
|
return `<Accordion${typeAttr}${defaultAttr}${classAttr}${passthrough}>\n${items}\n</Accordion>`;
|
|
1140
1746
|
}
|
|
1141
|
-
function accordionItemToAstro(node, ctx, standalone
|
|
1747
|
+
function accordionItemToAstro(node, ctx, standalone,
|
|
1748
|
+
/** 1-based position: an item with no `value` must not share one with its siblings */
|
|
1749
|
+
index = 1) {
|
|
1142
1750
|
ctx.imports.add("accordion");
|
|
1143
|
-
|
|
1751
|
+
// Items are matched by value: a shared fallback opened and closed them together
|
|
1752
|
+
const value = node.props?.value ?? `item-${index}`;
|
|
1144
1753
|
const label = node.props?.label ?? node.props?.title ?? "Item";
|
|
1145
|
-
const valueAttr =
|
|
1754
|
+
const valueAttr = componentAttr("value", value);
|
|
1146
1755
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1147
1756
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1148
1757
|
"value", "label", "title", "class",
|
|
@@ -1169,9 +1778,9 @@ function avatarToAstro(node, ctx) {
|
|
|
1169
1778
|
const src = node.props?.src ?? "";
|
|
1170
1779
|
const alt = node.props?.alt ?? "";
|
|
1171
1780
|
const fallback = node.props?.fallback;
|
|
1172
|
-
const srcAttr = src ?
|
|
1173
|
-
const altAttr =
|
|
1174
|
-
const fbAttr = fallback ?
|
|
1781
|
+
const srcAttr = src ? componentAttr("src", src) : "";
|
|
1782
|
+
const altAttr = componentAttr("alt", alt);
|
|
1783
|
+
const fbAttr = fallback ? componentAttr("fallback", fallback) : "";
|
|
1175
1784
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1176
1785
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1177
1786
|
"src", "alt", "fallback", "class",
|
|
@@ -1192,7 +1801,7 @@ function exampleToAstro(node, ctx) {
|
|
|
1192
1801
|
const source = String(node.props?.source ?? "");
|
|
1193
1802
|
const title = node.props?.title;
|
|
1194
1803
|
const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
|
|
1195
|
-
const titleAttr = title ?
|
|
1804
|
+
const titleAttr = title ? componentAttr("title", title) : "";
|
|
1196
1805
|
const fallbackAttr = showFallback ? " showFallback" : "";
|
|
1197
1806
|
const inner = childrenToAstro(node, ctx);
|
|
1198
1807
|
return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
|
|
@@ -1207,29 +1816,34 @@ function htmlContainerToAstro(node, ctx) {
|
|
|
1207
1816
|
const inner = childrenToAstro(node, ctx);
|
|
1208
1817
|
return `<div class="columns-1 sm:columns-2 gap-4 [&_ul]:m-0">\n${inner}\n</div>`;
|
|
1209
1818
|
}
|
|
1819
|
+
// A container parsed from raw HTML (a one-line `<details>` body) carries
|
|
1820
|
+
// its content in `html`, not children — rendering only children dropped it
|
|
1821
|
+
if (!node.children?.length && node.html)
|
|
1822
|
+
return htmlToAstro(node.html, ctx);
|
|
1210
1823
|
const inner = childrenToAstro(node, ctx);
|
|
1211
1824
|
return `<div>\n${inner}\n</div>`;
|
|
1212
1825
|
}
|
|
1213
1826
|
// ─── Utilities ────────────────────────────────────────────────────
|
|
1214
1827
|
/**
|
|
1215
|
-
* Render a node's content
|
|
1216
|
-
* parser
|
|
1217
|
-
*
|
|
1828
|
+
* Render a node's content across all THREE TreeNode shapes: flat `inline`
|
|
1829
|
+
* (dogsbay-md parser), wrapped `children[{prose, inline}]` (Starlight
|
|
1830
|
+
* importer), and a node carrying only `html` (MDX/Starlight/MkDocs). Inline
|
|
1831
|
+
* comes before children when both are present.
|
|
1218
1832
|
*
|
|
1219
1833
|
* Used for list-item, table cells, step, dt, dd — types that commonly
|
|
1220
1834
|
* carry leaf text content directly on the node.
|
|
1835
|
+
*
|
|
1836
|
+
* This function is where serialize-core's `renderLeaf` was extracted FROM, so
|
|
1837
|
+
* delegating to it closes the loop: the wrapper-dedup and html-shape handling
|
|
1838
|
+
* the core grew afterwards (from the Docusaurus dogfooding bug) now flow back
|
|
1839
|
+
* here instead of the two implementations continuing to drift apart.
|
|
1221
1840
|
*/
|
|
1222
1841
|
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");
|
|
1842
|
+
return renderLeaf(node, {
|
|
1843
|
+
inline: inlineToTemplate,
|
|
1844
|
+
children: () => childrenToAstro(node, ctx),
|
|
1845
|
+
html: (html) => `<Fragment set:html={${escapeExpr(html)}} />`,
|
|
1846
|
+
});
|
|
1233
1847
|
}
|
|
1234
1848
|
function childrenToAstro(node, ctx) {
|
|
1235
1849
|
if (!node.children || node.children.length === 0)
|
|
@@ -1243,40 +1857,28 @@ function childrenToAstro(node, ctx) {
|
|
|
1243
1857
|
* icon usage on cards registers `icon` at the renderer call site;
|
|
1244
1858
|
* this is the inline-only path. Returns true on first hit.
|
|
1245
1859
|
*/
|
|
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
|
-
}
|
|
1860
|
+
/** The component imports inline nodes need: `icon`, `badge`, and `link-button` for an inline `:button`. */
|
|
1861
|
+
function inlineComponentsIn(nodes, out = new Set()) {
|
|
1862
|
+
const visit = (node) => {
|
|
1863
|
+
if (node.type === "icon")
|
|
1864
|
+
out.add("icon");
|
|
1865
|
+
else if (node.type === "badge")
|
|
1866
|
+
out.add("badge");
|
|
1867
|
+
else if (node.type === "kbd")
|
|
1868
|
+
out.add("kbd");
|
|
1869
|
+
else if (node.type === "link" && node.button)
|
|
1870
|
+
out.add("link-button");
|
|
1871
|
+
if ("children" in node && Array.isArray(node.children))
|
|
1872
|
+
node.children.forEach(visit);
|
|
1873
|
+
};
|
|
1874
|
+
for (const node of nodes ?? []) {
|
|
1875
|
+
node.inline?.forEach(visit);
|
|
1876
|
+
inlineComponentsIn(node.children, out);
|
|
1269
1877
|
}
|
|
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");
|
|
1878
|
+
return out;
|
|
1278
1879
|
}
|
|
1880
|
+
/** Indent, via the core's shared implementation (was a fourth local copy). */
|
|
1881
|
+
const indentStr = indent;
|
|
1279
1882
|
function escapeHtml(s) {
|
|
1280
1883
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1281
1884
|
}
|
|
1282
|
-
//# sourceMappingURL=serialize.js.map
|