@dogsbay/format-astro 0.2.0-beta.13 → 0.2.0-beta.131
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 +323 -0
- package/dist/browser.d.ts +13 -0
- package/dist/browser.js +13 -0
- package/dist/canonical.d.ts +33 -0
- package/dist/canonical.js +106 -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 -5
- package/dist/index.js +34 -4
- 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 +377 -13
- package/dist/project.js +2239 -649
- package/dist/redirects.d.ts +115 -0
- package/dist/redirects.js +223 -0
- package/dist/serialize.d.ts +14 -1
- package/dist/serialize.js +821 -254
- package/dist/sitemap.d.ts +107 -0
- package/dist/sitemap.js +126 -0
- package/dist/taxonomy.d.ts +48 -2
- package/dist/taxonomy.js +46 -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 +16 -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/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,74 @@ 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
|
+
return `<div data-directive="${escapeAttr(name)}"${classAttr}${passthrough}>\n${title}${childrenToAstro(node, ctx)}\n</div>`;
|
|
1073
|
+
}
|
|
720
1074
|
function detailsToAstro(node, ctx) {
|
|
721
1075
|
ctx.imports.add("details");
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
1076
|
+
// `variant` as documented; `type` as dogsbay-ssg (and callouts) read it.
|
|
1077
|
+
// Aliases (`caution` → danger) and unknown names (→ note) as render-html maps them
|
|
1078
|
+
const variant = collapsibleVariant(node.props?.variant ?? node.props?.type ?? "note");
|
|
1079
|
+
// `summary` is dogsbay-md's alias for the title (`:::details{summary="…"}`)
|
|
1080
|
+
const title = node.props?.title ?? node.props?.summary ?? "Details";
|
|
1081
|
+
const open = flagOn(node.props?.open);
|
|
725
1082
|
const icon = node.props?.icon;
|
|
726
1083
|
const inner = childrenToAstro(node, ctx);
|
|
727
|
-
|
|
1084
|
+
// The title is inline Markdown, as in render-html: plain text stays the `title` prop
|
|
1085
|
+
const titleNodes = titleInline(title);
|
|
1086
|
+
const plainTitle = isPlainCaption(titleNodes);
|
|
1087
|
+
const attrs = [`variant="${escapeAttr(variant)}"`, componentAttr("title", plainTitle ? captionText(titleNodes) : title).trimStart()];
|
|
728
1088
|
if (open)
|
|
729
1089
|
attrs.push("open");
|
|
730
1090
|
if (icon)
|
|
731
1091
|
attrs.push(`icon="${escapeAttr(icon)}"`);
|
|
732
1092
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
733
1093
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
734
|
-
"variant", "title", "open", "icon", "class", "summary",
|
|
1094
|
+
"variant", "type", "title", "open", "icon", "class", "summary", "sheet",
|
|
735
1095
|
]);
|
|
736
|
-
|
|
1096
|
+
const slot = plainTitle ? "" : `\n <Fragment slot="title">${renderInline(titleNodes, ctx)}</Fragment>`;
|
|
1097
|
+
const alert = `<CollapsibleAlert ${attrs.join(" ")}${classAttr}${passthrough}>${slot}
|
|
737
1098
|
${indentStr(inner, 2)}
|
|
738
1099
|
</CollapsibleAlert>`;
|
|
1100
|
+
// `sheet`: the same details, opened as a side sheet by <db-details-sheet>
|
|
1101
|
+
if (!isDetailsSheet(node.props))
|
|
1102
|
+
return alert;
|
|
1103
|
+
ctx.imports.add("details-sheet");
|
|
1104
|
+
return `<DetailsSheet>\n${indentStr(alert, 2)}\n</DetailsSheet>`;
|
|
739
1105
|
}
|
|
740
1106
|
function tabsToAstro(node, ctx) {
|
|
741
1107
|
ctx.imports.add("tabs");
|
|
@@ -749,39 +1115,125 @@ function tabsToAstro(node, ctx) {
|
|
|
749
1115
|
return { value, label, node: tab };
|
|
750
1116
|
});
|
|
751
1117
|
const defaultValue = tabItems[0].value;
|
|
1118
|
+
// Diff-trigger indicator: a marked tab panel is invisible until
|
|
1119
|
+
// opened, so its TRIGGER carries a data-diff-tab attribute (styled
|
|
1120
|
+
// as a colored dot by DIFF_CSS) telling the reader WHICH tab to
|
|
1121
|
+
// open. Structural read of @dogsbay/tree-diff marks, same as
|
|
1122
|
+
// diff-decoration; unmarked trees are unaffected.
|
|
1123
|
+
// The indicator must not live in COLOUR alone (WCAG 1.4.1), and a
|
|
1124
|
+
// background-tinted dot disappears in forced-colors mode. So it is a
|
|
1125
|
+
// GLYPH (shape carries the meaning) plus visually-hidden text, so the
|
|
1126
|
+
// trigger announces "Astro, changed in this comparison" rather than
|
|
1127
|
+
// relying on a `title` screen readers may never speak. (TAB_GLYPH and
|
|
1128
|
+
// tabDiffSignal are shared with render-html.)
|
|
1129
|
+
// A variant's class strings are complete (TAB_VARIANTS): TabsList and
|
|
1130
|
+
// TabsTrigger merge a `class` over their base with tailwind-variants
|
|
1131
|
+
const variant = lookup(TAB_VARIANTS, node.props?.variant);
|
|
1132
|
+
const triggerClass = variant ? ` class="${escapeAttr(variant.trigger)}"` : "";
|
|
752
1133
|
const triggers = tabItems
|
|
753
|
-
.map((t) =>
|
|
1134
|
+
.map((t) => {
|
|
1135
|
+
const signal = tabDiffSignal(t.node);
|
|
1136
|
+
if (!signal) {
|
|
1137
|
+
return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass}>${escapeTemplate(t.label)}</TabsTrigger>`;
|
|
1138
|
+
}
|
|
1139
|
+
const glyph = TAB_GLYPH[signal] ?? "•";
|
|
1140
|
+
// The glyph and its announced text are ONE element: "Hide
|
|
1141
|
+
// highlights" hides `.db-tab-mark`, and a sibling sr-only span
|
|
1142
|
+
// would survive that — leaving screen-reader users still hearing
|
|
1143
|
+
// "changed in this comparison" on every tab while the visible
|
|
1144
|
+
// marker is gone (code review, 2026-07-13).
|
|
1145
|
+
const marker = `<span class="db-tab-mark" data-diff-tab="${escapeAttr(signal)}">` +
|
|
1146
|
+
`<span aria-hidden="true">${glyph}</span>` +
|
|
1147
|
+
`<span class="sr-only">${escapeTemplate(signal)} in this comparison</span>` +
|
|
1148
|
+
`</span>`;
|
|
1149
|
+
// `value` MUST match TabsContent's, which is a componentAttr expression:
|
|
1150
|
+
// a title containing `&` was emitted `&` here and `&` there, so the
|
|
1151
|
+
// trigger and its panel no longer paired and the tab rendered empty.
|
|
1152
|
+
// `data-diff-tab` is a plain data attribute — entity escaping is right.
|
|
1153
|
+
return ` <TabsTrigger${componentAttr("value", t.value)}${triggerClass} data-diff-tab="${escapeAttr(signal)}">${escapeTemplate(t.label)}<Fragment set:html={${escapeExpr(marker)}} /></TabsTrigger>`;
|
|
1154
|
+
})
|
|
754
1155
|
.join("\n");
|
|
755
1156
|
const contents = tabItems
|
|
756
1157
|
.map((t) => {
|
|
757
1158
|
const inner = childrenToAstro(t.node, ctx);
|
|
758
|
-
return ` <TabsContent
|
|
1159
|
+
return ` <TabsContent${componentAttr("value", t.value)}>\n${indentStr(inner, 4)}\n </TabsContent>`;
|
|
759
1160
|
})
|
|
760
1161
|
.join("\n");
|
|
761
1162
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
762
1163
|
const passthrough = renderPassthroughAttrs(node.props, ["sync", "default", "class"]);
|
|
763
|
-
|
|
764
|
-
|
|
1164
|
+
// Groups with the same key switch together (db-tabs). Written `syncKey`, `sync-key` or `group`, as dogsbay-ssg reads it.
|
|
1165
|
+
const syncKey = [node.props?.syncKey, node.props?.["sync-key"], node.props?.group].find((v) => typeof v === "string" && v !== "");
|
|
1166
|
+
const syncAttr = syncKey ? componentAttr("syncKey", syncKey) : "";
|
|
1167
|
+
// Same encoding as the trigger/content `value` it selects — see above.
|
|
1168
|
+
return `<Tabs${componentAttr("defaultValue", defaultValue)}${syncAttr}${classAttr}${passthrough}>
|
|
1169
|
+
<TabsList${variant ? ` class="${escapeAttr(variant.list)}"` : ""}>
|
|
765
1170
|
${triggers}
|
|
766
1171
|
</TabsList>
|
|
767
1172
|
${contents}
|
|
768
1173
|
</Tabs>`;
|
|
769
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* A caption is inline Markdown. Plain text renders exactly as before;
|
|
1177
|
+
* formatting (`Sample \`<plugin>\` file`) renders as inline markup.
|
|
1178
|
+
*/
|
|
1179
|
+
function captionInline(caption) {
|
|
1180
|
+
return parseInline(caption);
|
|
1181
|
+
}
|
|
1182
|
+
const isPlainCaption = (nodes) => nodes.every((n) => n.type === "text" && !n.bold && !n.italic && !n.strikethrough);
|
|
1183
|
+
/**
|
|
1184
|
+
* Formatting tags with no attributes, the HTML a title keeps (a raw `<details><summary><code>x</code>`
|
|
1185
|
+
* becomes the title `<code>x</code>`). render-html has the same list.
|
|
1186
|
+
*/
|
|
1187
|
+
const TITLE_TAG = /^<\/?(?:code|kbd|b|strong|i|em|s|del|mark|sub|sup|br)\s*\/?>$/i;
|
|
1188
|
+
/**
|
|
1189
|
+
* A callout or details title: inline Markdown, and any other raw HTML is TEXT. Importers write
|
|
1190
|
+
* plain-text titles with angle brackets (`Map<K, V>`, `List<String>`), which as HTML dropped words,
|
|
1191
|
+
* and a title with `<img onerror=…>` ran a script in the summary. render-html reads titles the same way.
|
|
1192
|
+
*/
|
|
1193
|
+
function titleInline(title) {
|
|
1194
|
+
return captionInline(title).map((n) => (n.type === "html-inline" && !TITLE_TAG.test(n.html.trim()) ? { type: "text", text: n.html } : n));
|
|
1195
|
+
}
|
|
1196
|
+
/** A plain caption's text (escapes undone by the parse). */
|
|
1197
|
+
const captionText = (nodes) => nodes.map((n) => (n.type === "text" ? n.text : "")).join("");
|
|
1198
|
+
function captionToAstro(caption, ctx) {
|
|
1199
|
+
const nodes = captionInline(caption);
|
|
1200
|
+
return isPlainCaption(nodes)
|
|
1201
|
+
? escapeTemplate(captionText(nodes))
|
|
1202
|
+
: renderInline(nodes, ctx);
|
|
1203
|
+
}
|
|
770
1204
|
function tableToAstro(node, ctx) {
|
|
771
1205
|
ctx.imports.add("table");
|
|
772
1206
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
773
|
-
const
|
|
774
|
-
|
|
1207
|
+
const caption = node.props?.caption;
|
|
1208
|
+
const nodes = caption ? captionInline(caption) : [];
|
|
1209
|
+
const plain = isPlainCaption(nodes);
|
|
1210
|
+
// A plain caption stays the `caption` prop; a formatted one fills the table's caption slot
|
|
1211
|
+
const captionAttr = caption && plain
|
|
1212
|
+
? ` caption={${escapeExpr(nodes.map((n) => (n.type === "text" ? n.text : "")).join(""))}}`
|
|
1213
|
+
: "";
|
|
1214
|
+
const captionSlot = caption && !plain ? `<Fragment slot="caption">${renderInline(nodes, ctx)}</Fragment>\n` : "";
|
|
1215
|
+
const passthrough = renderPassthroughAttrs(node.props, ["class", "caption"]);
|
|
1216
|
+
return `<Table${classAttr}${captionAttr}${passthrough}>\n${captionSlot}${childrenToAstro(node, ctx)}\n</Table>`;
|
|
775
1217
|
}
|
|
776
1218
|
function diagramToAstro(node, ctx) {
|
|
777
1219
|
ctx.imports.add("diagram");
|
|
778
1220
|
const lang = node.props?.lang || "mermaid";
|
|
779
1221
|
const code = node.props?.code ?? "";
|
|
780
|
-
|
|
1222
|
+
let svg = node.props?.svg ?? "";
|
|
781
1223
|
const title = node.props?.title;
|
|
1224
|
+
// A Pele SVG's ids (`<prefix>-title`, `<prefix>-desc`, for aria-labelledby) are unique within
|
|
1225
|
+
// its source page. A book view or an include can show the same diagram twice on one page, so
|
|
1226
|
+
// the later copies are renamed; the prefix appears nowhere else in the SVG.
|
|
1227
|
+
const prefix = /^<svg[^>]*\bclass="pele\b/.test(svg) ? /\sid="(dg-[0-9a-f]{12}(?:-\d+)?)-/.exec(svg)?.[1] : undefined;
|
|
1228
|
+
if (prefix) {
|
|
1229
|
+
const n = (ctx.diagramIds.get(prefix) ?? 0) + 1;
|
|
1230
|
+
ctx.diagramIds.set(prefix, n);
|
|
1231
|
+
if (n > 1)
|
|
1232
|
+
svg = svg.split(prefix).join(`${prefix}-p${n}`);
|
|
1233
|
+
}
|
|
782
1234
|
const attrs = [`lang="${escapeAttr(lang)}"`, `code={${escapeExpr(code)}}`, `svg={${escapeExpr(svg)}}`];
|
|
783
1235
|
if (title)
|
|
784
|
-
attrs.push(
|
|
1236
|
+
attrs.push(componentAttr("title", title).trimStart());
|
|
785
1237
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
786
1238
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
787
1239
|
"lang", "code", "svg", "title", "class",
|
|
@@ -794,7 +1246,7 @@ function youtubeToAstro(node, ctx) {
|
|
|
794
1246
|
const title = node.props?.title ?? "";
|
|
795
1247
|
const classAttr = mergeClassAttr("my-4", node.props?.class);
|
|
796
1248
|
const passthrough = renderPassthroughAttrs(node.props, ["id", "title", "class"]);
|
|
797
|
-
return `<YouTube
|
|
1249
|
+
return `<YouTube${componentAttr("id", id)}${componentAttr("title", title)}${classAttr}${passthrough} />`;
|
|
798
1250
|
}
|
|
799
1251
|
function mathBlockToAstro(node, ctx) {
|
|
800
1252
|
ctx.imports.add("math-block");
|
|
@@ -837,6 +1289,11 @@ function footnoteListToAstro(node, ctx) {
|
|
|
837
1289
|
function footnoteDefToAstro(node, ctx) {
|
|
838
1290
|
const id = node.props?.id ?? "";
|
|
839
1291
|
const backrefId = node.props?.backrefId ?? "";
|
|
1292
|
+
// Block content (a list, code): the blocks, with the backref after them
|
|
1293
|
+
if (node.children?.length) {
|
|
1294
|
+
const backref = backrefId ? `\n<a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
|
|
1295
|
+
return `<li id="${escapeAttr(id)}" class="text-sm text-muted-foreground">\n${childrenToAstro(node, ctx)}${backref}\n</li>`;
|
|
1296
|
+
}
|
|
840
1297
|
if (ctx.mode === "template" && node.inline) {
|
|
841
1298
|
const content = inlineToTemplate(node.inline);
|
|
842
1299
|
const backref = backrefId ? ` <a href="#${escapeAttr(backrefId)}" class="text-primary hover:underline">↩</a>` : "";
|
|
@@ -852,8 +1309,14 @@ function apiSymbolToAstro(node, ctx) {
|
|
|
852
1309
|
const kind = node.props?.kind ?? "func";
|
|
853
1310
|
const signature = node.props?.signature;
|
|
854
1311
|
const bases = node.props?.bases || [];
|
|
855
|
-
|
|
856
|
-
|
|
1312
|
+
// By position, not by type (apiSymbolLevels): a module-level function was
|
|
1313
|
+
// an h3 straight under the page's h1, and a class inside a class an h2.
|
|
1314
|
+
const level = ctx.apiSymbolLevels.get(node) ?? (node.type === "api-class" ? 2 : 3);
|
|
1315
|
+
const attrs = [
|
|
1316
|
+
componentAttr("name", name).trimStart(),
|
|
1317
|
+
componentAttr("kind", kind).trimStart(),
|
|
1318
|
+
`level={${level}}`,
|
|
1319
|
+
];
|
|
857
1320
|
if (signature)
|
|
858
1321
|
attrs.push(`signature={${escapeExpr(signature)}}`);
|
|
859
1322
|
if (bases.length > 0)
|
|
@@ -902,11 +1365,17 @@ function endpointToAstro(node, ctx) {
|
|
|
902
1365
|
const path = props.path ?? "";
|
|
903
1366
|
const baseUrl = props.baseUrl ?? "";
|
|
904
1367
|
const summary = props.summary;
|
|
905
|
-
|
|
1368
|
+
// Descriptions are CommonMark, rendered with raw HTML off: the components
|
|
1369
|
+
// put them in the page with set:html (api-markdown.ts).
|
|
1370
|
+
// (Compared as text first: the card hides a description equal to the
|
|
1371
|
+
// summary, and the rendered HTML never equals it.)
|
|
1372
|
+
const description = typeof props.description === "string" && props.description !== props.summary
|
|
1373
|
+
? apiBlockHtml(props.description)
|
|
1374
|
+
: undefined;
|
|
906
1375
|
const deprecated = props.deprecated === true;
|
|
907
|
-
const parameters = props.parameters ?? [];
|
|
908
|
-
const requestBody = props.requestBody ?? [];
|
|
909
|
-
const responses = props.responses ?? [];
|
|
1376
|
+
const parameters = apiPropertiesHtml(props.parameters) ?? [];
|
|
1377
|
+
const requestBody = apiPropertiesHtml(props.requestBody) ?? [];
|
|
1378
|
+
const responses = (props.responses ?? []).map((r) => r && Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r);
|
|
910
1379
|
const codeSamples = props.codeSamples ?? [];
|
|
911
1380
|
const requestBodyExample = props.requestBodyExample;
|
|
912
1381
|
const cardAttrs = [
|
|
@@ -946,7 +1415,9 @@ function endpointToAstro(node, ctx) {
|
|
|
946
1415
|
const sectionsBody = sections.length > 0
|
|
947
1416
|
? `\n${indentStr(sections.join("\n"), 4)}\n `
|
|
948
1417
|
: "";
|
|
949
|
-
// Code column —
|
|
1418
|
+
// Code column — its titles are labels (<p>), not headings: an operation
|
|
1419
|
+
// page has only its h1 above them, so an h3 skipped a level.
|
|
1420
|
+
// Wrapped in <ApiCodePanel> for consistent vertical
|
|
950
1421
|
// rhythm. Code samples first, then a request body example, then one
|
|
951
1422
|
// ExampleBlock per response status that carries a concrete example.
|
|
952
1423
|
// Each ExampleBlock auto-detects language from the response's
|
|
@@ -955,7 +1426,7 @@ function endpointToAstro(node, ctx) {
|
|
|
955
1426
|
const codePanels = [];
|
|
956
1427
|
if (codeSamples.length > 0) {
|
|
957
1428
|
codePanels.push(`<div>\n` +
|
|
958
|
-
` <
|
|
1429
|
+
` <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">Code samples</p>\n` +
|
|
959
1430
|
` <CodeSamples samples={${JSON.stringify(codeSamples)}} />\n` +
|
|
960
1431
|
`</div>`);
|
|
961
1432
|
}
|
|
@@ -975,31 +1446,66 @@ function endpointToAstro(node, ctx) {
|
|
|
975
1446
|
const codeBody = codePanels.length > 0
|
|
976
1447
|
? `\n${indentStr(codePanels.join("\n"), 4)}\n `
|
|
977
1448
|
: "";
|
|
978
|
-
|
|
1449
|
+
const layoutAttrs = ctx.embeddedEndpoints ? ' variant="embedded"' : "";
|
|
1450
|
+
return (`<ApiLayout${layoutAttrs}>\n` +
|
|
979
1451
|
` <EndpointCard ${cardAttrs.join(" ")}>${sectionsBody}</EndpointCard>\n` +
|
|
980
1452
|
` <ApiCodePanel slot="code">${codeBody}</ApiCodePanel>\n` +
|
|
981
1453
|
`</ApiLayout>`);
|
|
982
1454
|
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Whole-card links being emitted. Inside one, a link in the card's body is its text: a browser
|
|
1457
|
+
* ends the outer <a> at an inner one, so the rest of the card fell out of the link (and lost
|
|
1458
|
+
* not-prose). The serializer is synchronous, so a depth count is enough.
|
|
1459
|
+
*/
|
|
1460
|
+
let cardLinkDepth = 0;
|
|
1461
|
+
function wholeCardLink(yes, fn) {
|
|
1462
|
+
if (!yes)
|
|
1463
|
+
return fn();
|
|
1464
|
+
cardLinkDepth++;
|
|
1465
|
+
try {
|
|
1466
|
+
return fn();
|
|
1467
|
+
}
|
|
1468
|
+
finally {
|
|
1469
|
+
cardLinkDepth--;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
/** A card's title: not-prose, or docs.css's h3 rule (1.25rem, 2rem above) applies inside the card. */
|
|
1473
|
+
const CARD_TITLE_CLASS = "not-prose m-0 border-0 p-0 text-base font-semibold";
|
|
1474
|
+
/**
|
|
1475
|
+
* A card group's columns, responsive as render-html draws them (its GRID_COLS): one column on a
|
|
1476
|
+
* phone, then up to `cols` (1–6) by Grid's breakpoints (0, 640, 768, 1024). Default 2. A list
|
|
1477
|
+
* (`cols="1,2,3"`) is the author's own breakpoints and passes through.
|
|
1478
|
+
*/
|
|
1479
|
+
function cardCols(raw) {
|
|
1480
|
+
const list = coerceNumericList(raw, [2]);
|
|
1481
|
+
if (list.length > 1)
|
|
1482
|
+
return list;
|
|
1483
|
+
const n = Math.min(6, Math.max(1, Math.round(list[0]) || 2));
|
|
1484
|
+
return n === 1 ? [1] : n === 2 ? [1, 2] : n <= 4 ? [1, 2, 2, n] : [1, 3, 3, n];
|
|
1485
|
+
}
|
|
983
1486
|
function cardsToAstro(node, ctx) {
|
|
984
1487
|
ctx.imports.add("grid");
|
|
985
1488
|
ctx.imports.add("card");
|
|
986
1489
|
// Grid-level attributes on the :::cards container
|
|
987
1490
|
const outerClass = mergeClassAttr("", node.props?.class);
|
|
988
1491
|
const outerPass = renderPassthroughAttrs(node.props, [
|
|
989
|
-
"cols", "gap", "rows", "class",
|
|
1492
|
+
"cols", "gap", "rows", "class", "tone",
|
|
990
1493
|
]);
|
|
991
1494
|
// MDX pattern: children are card nodes with title/href/icon props
|
|
992
1495
|
const cardNodes = (node.children ?? []).filter((c) => c.type === "card");
|
|
993
1496
|
if (cardNodes.length > 0) {
|
|
994
1497
|
const cards = cardNodes.map((card) => {
|
|
995
1498
|
const title = card.props?.title ?? "";
|
|
996
|
-
const href = card.props?.href
|
|
1499
|
+
const href = safeHref(card.props?.href);
|
|
997
1500
|
const description = card.props?.description;
|
|
998
1501
|
const icon = card.props?.icon;
|
|
999
1502
|
// Description sources: inline content, children, or props.description.
|
|
1000
|
-
|
|
1001
|
-
|
|
1503
|
+
// Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
|
|
1504
|
+
const blocks = wholeCardLink(!!href, () => childrenToAstro(card, ctx));
|
|
1505
|
+
const inner = blocks
|
|
1506
|
+
|| (card.inline ? wholeCardLink(!!href, () => inlineToTemplate(card.inline)) : "")
|
|
1002
1507
|
|| (description ? escapeTemplate(description) : "");
|
|
1508
|
+
const descTag = blocks ? "div" : "p";
|
|
1003
1509
|
// Icon renders inline before the title — picks up Lucide
|
|
1004
1510
|
// SVG via the `icon` import. Resolved at build time; nothing
|
|
1005
1511
|
// emitted when the name doesn't resolve (silent fallback).
|
|
@@ -1007,26 +1513,32 @@ function cardsToAstro(node, ctx) {
|
|
|
1007
1513
|
// top of the page.
|
|
1008
1514
|
let iconHtml = "";
|
|
1009
1515
|
if (icon) {
|
|
1010
|
-
|
|
1011
|
-
iconHtml = `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
|
|
1516
|
+
iconHtml = cardIconHtml(icon, title, ctx);
|
|
1012
1517
|
}
|
|
1013
|
-
const titleHtml = title ? `<h3 class="
|
|
1014
|
-
|
|
1015
|
-
|
|
1518
|
+
const titleHtml = title ? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>` : "";
|
|
1519
|
+
// A description and a body are both shown (the description used to be dropped when a body existed).
|
|
1520
|
+
const lead = blocks && description
|
|
1521
|
+
? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
|
|
1016
1522
|
: "";
|
|
1523
|
+
const desc = lead + (inner
|
|
1524
|
+
? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
|
|
1525
|
+
: "");
|
|
1526
|
+
// A tone (TONES; the card's own, else the group's) sets the card's
|
|
1527
|
+
// border and tint: Card's tailwind-variants merge replaces bg-card
|
|
1528
|
+
const toned = lookup(CARD_TONE_CLASSES, card.props?.tone ?? node.props?.tone);
|
|
1017
1529
|
const cardDefaultClass = href
|
|
1018
|
-
? "p-4 hover:border-foreground/20 transition-colors"
|
|
1019
|
-
: "p-4";
|
|
1530
|
+
? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
|
|
1531
|
+
: toned ? `p-4 ${toned}` : "p-4";
|
|
1020
1532
|
const cardClassAttr = mergeClassAttr(cardDefaultClass, card.props?.class);
|
|
1021
1533
|
const cardPass = renderPassthroughAttrs(card.props, [
|
|
1022
|
-
"title", "href", "description", "icon", "variant", "class",
|
|
1534
|
+
"title", "href", "description", "icon", "variant", "class", "tone",
|
|
1023
1535
|
]);
|
|
1024
1536
|
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>`;
|
|
1537
|
+
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
1538
|
}
|
|
1027
1539
|
return `<Card${cardClassAttr}${cardPass}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
|
|
1028
1540
|
}).join("\n");
|
|
1029
|
-
return `<Grid cols={[
|
|
1541
|
+
return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}${outerClass}${outerPass}>\n${cards}\n</Grid>`;
|
|
1030
1542
|
}
|
|
1031
1543
|
// MkDocs pattern: children are unordered-list items
|
|
1032
1544
|
const items = (node.children ?? [])
|
|
@@ -1036,10 +1548,10 @@ function cardsToAstro(node, ctx) {
|
|
|
1036
1548
|
const cards = items
|
|
1037
1549
|
.map((item) => `<Card>\n<CardContent>\n${childrenToAstro(item, ctx)}\n</CardContent>\n</Card>`)
|
|
1038
1550
|
.join("\n");
|
|
1039
|
-
return `<Grid cols={[
|
|
1551
|
+
return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${cards}\n</Grid>`;
|
|
1040
1552
|
}
|
|
1041
1553
|
// Fallback: render children directly in grid
|
|
1042
|
-
return `<Grid cols={[
|
|
1554
|
+
return `<Grid cols={[${cardCols(node.props?.cols).join(", ")}]} gap={4}>\n${childrenToAstro(node, ctx)}\n</Grid>`;
|
|
1043
1555
|
}
|
|
1044
1556
|
/**
|
|
1045
1557
|
* Render a standalone `card` TreeNode (outside `:::cards`).
|
|
@@ -1051,34 +1563,38 @@ function cardsToAstro(node, ctx) {
|
|
|
1051
1563
|
function standaloneCardToAstro(node, ctx) {
|
|
1052
1564
|
ctx.imports.add("card");
|
|
1053
1565
|
const title = node.props?.title ?? "";
|
|
1054
|
-
const href = node.props?.href
|
|
1566
|
+
const href = safeHref(node.props?.href);
|
|
1055
1567
|
const description = node.props?.description;
|
|
1056
1568
|
const icon = node.props?.icon;
|
|
1057
|
-
|
|
1058
|
-
|
|
1569
|
+
// Child blocks carry their own <p>, so they get a <div>: <p><p>…</p></p> is invalid HTML.
|
|
1570
|
+
const blocks = wholeCardLink(!!href, () => childrenToAstro(node, ctx));
|
|
1571
|
+
const inner = blocks
|
|
1572
|
+
|| (node.inline ? wholeCardLink(!!href, () => inlineToTemplate(node.inline)) : "")
|
|
1059
1573
|
|| (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
|
-
}
|
|
1574
|
+
const descTag = blocks ? "div" : "p";
|
|
1575
|
+
// Icon renders inline before the title, a name or an image path, as in a card group
|
|
1576
|
+
const iconHtml = icon ? cardIconHtml(icon, title, ctx) : "";
|
|
1067
1577
|
const titleHtml = title
|
|
1068
|
-
? `<h3 class="
|
|
1578
|
+
? `<h3 class="${CARD_TITLE_CLASS}">${escapeTemplate(title)}</h3>`
|
|
1069
1579
|
: "";
|
|
1070
|
-
|
|
1071
|
-
|
|
1580
|
+
// A description and a body are both shown (the description used to be dropped when a body existed).
|
|
1581
|
+
const lead = blocks && description
|
|
1582
|
+
? `<p class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${escapeTemplate(description)}</p>`
|
|
1072
1583
|
: "";
|
|
1584
|
+
const desc = lead + (inner
|
|
1585
|
+
? `<${descTag} class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${inner}</${descTag}>`
|
|
1586
|
+
: "");
|
|
1587
|
+
// A tone (TONES), as on a card in a group (cardsToAstro)
|
|
1588
|
+
const toned = lookup(CARD_TONE_CLASSES, node.props?.tone);
|
|
1073
1589
|
const cardDefaultClass = href
|
|
1074
|
-
? "p-4 hover:border-foreground/20 transition-colors"
|
|
1075
|
-
: "p-4";
|
|
1590
|
+
? toned ? `p-4 ${toned} hover:shadow-md transition-colors` : "p-4 hover:border-foreground/20 transition-colors"
|
|
1591
|
+
: toned ? `p-4 ${toned}` : "p-4";
|
|
1076
1592
|
const classAttr = mergeClassAttr(cardDefaultClass, node.props?.class);
|
|
1077
1593
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1078
|
-
"title", "href", "description", "icon", "variant", "class",
|
|
1594
|
+
"title", "href", "description", "icon", "variant", "class", "tone",
|
|
1079
1595
|
]);
|
|
1080
1596
|
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>`;
|
|
1597
|
+
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
1598
|
}
|
|
1083
1599
|
return `<Card${classAttr}${passthrough}>\n<CardContent class="p-0">\n${iconHtml}\n${titleHtml}\n${desc}\n</CardContent>\n</Card>`;
|
|
1084
1600
|
}
|
|
@@ -1089,6 +1605,59 @@ function standaloneCardToAstro(node, ctx) {
|
|
|
1089
1605
|
* If the parser kept rich body children instead of folding into description,
|
|
1090
1606
|
* we fall back to the standalone-card path which can hold arbitrary content.
|
|
1091
1607
|
*/
|
|
1608
|
+
/**
|
|
1609
|
+
* `link-button` — a call-to-action link styled as a button.
|
|
1610
|
+
*
|
|
1611
|
+
* There was no case for this at all, so it fell through to rendering the
|
|
1612
|
+
* node's CHILDREN: `<p>Get started</p>`. The label survived, the href did not,
|
|
1613
|
+
* and nothing warned — the reader saw a stray sentence where the page's
|
|
1614
|
+
* primary call to action should be. Every importer that produces one was
|
|
1615
|
+
* affected; `Button.astro` has taken an `href` all along.
|
|
1616
|
+
*/
|
|
1617
|
+
/**
|
|
1618
|
+
* A card icon is either a registry NAME or a path to an image file.
|
|
1619
|
+
*
|
|
1620
|
+
* `<Icon name>` resolves names and renders NOTHING when the name does not
|
|
1621
|
+
* resolve — so a path handed to it disappeared silently, which is how every
|
|
1622
|
+
* "Related products" card lost its logo without anyone noticing.
|
|
1623
|
+
*/
|
|
1624
|
+
function cardIconHtml(icon, title, ctx) {
|
|
1625
|
+
if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon)) {
|
|
1626
|
+
return `<img src="${escapeAttr(icon)}" alt="" class="size-5 mb-2" loading="lazy" />`;
|
|
1627
|
+
}
|
|
1628
|
+
ctx.imports.add("icon");
|
|
1629
|
+
return `<Icon name="${escapeAttr(icon)}" class="size-5 mb-2 text-muted-foreground" />`;
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* A button's variant as `Button.astro` names it. The spec's `primary` is its
|
|
1633
|
+
* filled `default` style; `Button.astro` has no `primary`, so it rendered
|
|
1634
|
+
* with no fill at all.
|
|
1635
|
+
*/
|
|
1636
|
+
function buttonVariant(variant) {
|
|
1637
|
+
return !variant || variant === "primary" ? "default" : variant;
|
|
1638
|
+
}
|
|
1639
|
+
function linkButtonToAstro(node, ctx) {
|
|
1640
|
+
ctx.imports.add("link-button");
|
|
1641
|
+
const href = safeHref(node.props?.href);
|
|
1642
|
+
const written = node.props?.variant;
|
|
1643
|
+
const variant = written === undefined ? undefined : buttonVariant(written);
|
|
1644
|
+
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1645
|
+
const passthrough = renderPassthroughAttrs(node.props, ["href", "variant", "class"]);
|
|
1646
|
+
// A button's label is phrasing content, so a lone paragraph child is
|
|
1647
|
+
// unwrapped — `<Button><p>Get started</p></Button>` is invalid markup and
|
|
1648
|
+
// renders with the paragraph's block spacing inside the button.
|
|
1649
|
+
const kids = node.children ?? [];
|
|
1650
|
+
const soleParagraph = kids.length === 1 && kids[0].type === "paragraph" ? kids[0] : undefined;
|
|
1651
|
+
const label = soleParagraph
|
|
1652
|
+
? leafContent(soleParagraph, ctx)
|
|
1653
|
+
: leafContent(node, ctx) || childrenToAstro(node, ctx);
|
|
1654
|
+
return `<Button${componentAttr("href", href)}${variant ? componentAttr("variant", variant) : ""}${classAttr}${passthrough}>${label}</Button>`;
|
|
1655
|
+
}
|
|
1656
|
+
/** A card or button `href` from an importer, or "" (no link) when its
|
|
1657
|
+
* scheme could run script: these never passed markdown-it's link check. */
|
|
1658
|
+
function safeHref(href) {
|
|
1659
|
+
return typeof href === "string" && isSafeHref(href) ? href : "";
|
|
1660
|
+
}
|
|
1092
1661
|
function linkCardToAstro(node, ctx) {
|
|
1093
1662
|
const hasRichChildren = (node.children ?? []).length > 0;
|
|
1094
1663
|
if (hasRichChildren) {
|
|
@@ -1097,15 +1666,17 @@ function linkCardToAstro(node, ctx) {
|
|
|
1097
1666
|
ctx.imports.add("link-card");
|
|
1098
1667
|
const title = node.props?.title ?? "";
|
|
1099
1668
|
const description = node.props?.description;
|
|
1100
|
-
const href = node.props?.href
|
|
1101
|
-
const titleAttr =
|
|
1102
|
-
const hrefAttr =
|
|
1103
|
-
const descAttr = description ?
|
|
1669
|
+
const href = safeHref(node.props?.href);
|
|
1670
|
+
const titleAttr = componentAttr("title", title);
|
|
1671
|
+
const hrefAttr = componentAttr("href", href);
|
|
1672
|
+
const descAttr = description ? componentAttr("description", description) : "";
|
|
1673
|
+
const icon = node.props?.icon;
|
|
1674
|
+
const iconAttr = icon ? componentAttr("icon", icon) : "";
|
|
1104
1675
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1105
1676
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1106
1677
|
"title", "description", "href", "icon", "class",
|
|
1107
1678
|
]);
|
|
1108
|
-
return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${classAttr}${passthrough} />`;
|
|
1679
|
+
return `<LinkCard${titleAttr}${descAttr}${hrefAttr}${iconAttr}${classAttr}${passthrough} />`;
|
|
1109
1680
|
}
|
|
1110
1681
|
/**
|
|
1111
1682
|
* Render an `accordion` TreeNode and its `accordion-item` children.
|
|
@@ -1124,9 +1695,9 @@ function accordionToAstro(node, ctx) {
|
|
|
1124
1695
|
const defaultValue = node.props?.defaultValue;
|
|
1125
1696
|
const typeAttr = ` type="${escapeAttr(type)}"`;
|
|
1126
1697
|
const defaultAttr = defaultValue !== undefined
|
|
1127
|
-
?
|
|
1128
|
-
?
|
|
1129
|
-
: `{${JSON.stringify(defaultValue)}}`
|
|
1698
|
+
? typeof defaultValue === "string"
|
|
1699
|
+
? componentAttr("defaultValue", defaultValue)
|
|
1700
|
+
: ` defaultValue={${JSON.stringify(defaultValue)}}`
|
|
1130
1701
|
: "";
|
|
1131
1702
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1132
1703
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
@@ -1134,15 +1705,18 @@ function accordionToAstro(node, ctx) {
|
|
|
1134
1705
|
]);
|
|
1135
1706
|
const items = (node.children ?? [])
|
|
1136
1707
|
.filter((c) => c.type === "accordion-item")
|
|
1137
|
-
.map((item) => accordionItemToAstro(item, ctx, /*standalone*/ false))
|
|
1708
|
+
.map((item, i) => accordionItemToAstro(item, ctx, /*standalone*/ false, i + 1))
|
|
1138
1709
|
.join("\n");
|
|
1139
1710
|
return `<Accordion${typeAttr}${defaultAttr}${classAttr}${passthrough}>\n${items}\n</Accordion>`;
|
|
1140
1711
|
}
|
|
1141
|
-
function accordionItemToAstro(node, ctx, standalone
|
|
1712
|
+
function accordionItemToAstro(node, ctx, standalone,
|
|
1713
|
+
/** 1-based position: an item with no `value` must not share one with its siblings */
|
|
1714
|
+
index = 1) {
|
|
1142
1715
|
ctx.imports.add("accordion");
|
|
1143
|
-
|
|
1716
|
+
// Items are matched by value: a shared fallback opened and closed them together
|
|
1717
|
+
const value = node.props?.value ?? `item-${index}`;
|
|
1144
1718
|
const label = node.props?.label ?? node.props?.title ?? "Item";
|
|
1145
|
-
const valueAttr =
|
|
1719
|
+
const valueAttr = componentAttr("value", value);
|
|
1146
1720
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1147
1721
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1148
1722
|
"value", "label", "title", "class",
|
|
@@ -1169,9 +1743,9 @@ function avatarToAstro(node, ctx) {
|
|
|
1169
1743
|
const src = node.props?.src ?? "";
|
|
1170
1744
|
const alt = node.props?.alt ?? "";
|
|
1171
1745
|
const fallback = node.props?.fallback;
|
|
1172
|
-
const srcAttr = src ?
|
|
1173
|
-
const altAttr =
|
|
1174
|
-
const fbAttr = fallback ?
|
|
1746
|
+
const srcAttr = src ? componentAttr("src", src) : "";
|
|
1747
|
+
const altAttr = componentAttr("alt", alt);
|
|
1748
|
+
const fbAttr = fallback ? componentAttr("fallback", fallback) : "";
|
|
1175
1749
|
const classAttr = mergeClassAttr("", node.props?.class);
|
|
1176
1750
|
const passthrough = renderPassthroughAttrs(node.props, [
|
|
1177
1751
|
"src", "alt", "fallback", "class",
|
|
@@ -1192,7 +1766,7 @@ function exampleToAstro(node, ctx) {
|
|
|
1192
1766
|
const source = String(node.props?.source ?? "");
|
|
1193
1767
|
const title = node.props?.title;
|
|
1194
1768
|
const showFallback = node.props?.fallback === true || node.props?.fallback === "true";
|
|
1195
|
-
const titleAttr = title ?
|
|
1769
|
+
const titleAttr = title ? componentAttr("title", title) : "";
|
|
1196
1770
|
const fallbackAttr = showFallback ? " showFallback" : "";
|
|
1197
1771
|
const inner = childrenToAstro(node, ctx);
|
|
1198
1772
|
return `<MarkdownExample source={${escapeExpr(source)}}${titleAttr}${fallbackAttr}>\n${inner}\n</MarkdownExample>`;
|
|
@@ -1207,29 +1781,34 @@ function htmlContainerToAstro(node, ctx) {
|
|
|
1207
1781
|
const inner = childrenToAstro(node, ctx);
|
|
1208
1782
|
return `<div class="columns-1 sm:columns-2 gap-4 [&_ul]:m-0">\n${inner}\n</div>`;
|
|
1209
1783
|
}
|
|
1784
|
+
// A container parsed from raw HTML (a one-line `<details>` body) carries
|
|
1785
|
+
// its content in `html`, not children — rendering only children dropped it
|
|
1786
|
+
if (!node.children?.length && node.html)
|
|
1787
|
+
return htmlToAstro(node.html, ctx);
|
|
1210
1788
|
const inner = childrenToAstro(node, ctx);
|
|
1211
1789
|
return `<div>\n${inner}\n</div>`;
|
|
1212
1790
|
}
|
|
1213
1791
|
// ─── Utilities ────────────────────────────────────────────────────
|
|
1214
1792
|
/**
|
|
1215
|
-
* Render a node's content
|
|
1216
|
-
* parser
|
|
1217
|
-
*
|
|
1793
|
+
* Render a node's content across all THREE TreeNode shapes: flat `inline`
|
|
1794
|
+
* (dogsbay-md parser), wrapped `children[{prose, inline}]` (Starlight
|
|
1795
|
+
* importer), and a node carrying only `html` (MDX/Starlight/MkDocs). Inline
|
|
1796
|
+
* comes before children when both are present.
|
|
1218
1797
|
*
|
|
1219
1798
|
* Used for list-item, table cells, step, dt, dd — types that commonly
|
|
1220
1799
|
* carry leaf text content directly on the node.
|
|
1800
|
+
*
|
|
1801
|
+
* This function is where serialize-core's `renderLeaf` was extracted FROM, so
|
|
1802
|
+
* delegating to it closes the loop: the wrapper-dedup and html-shape handling
|
|
1803
|
+
* the core grew afterwards (from the Docusaurus dogfooding bug) now flow back
|
|
1804
|
+
* here instead of the two implementations continuing to drift apart.
|
|
1221
1805
|
*/
|
|
1222
1806
|
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");
|
|
1807
|
+
return renderLeaf(node, {
|
|
1808
|
+
inline: inlineToTemplate,
|
|
1809
|
+
children: () => childrenToAstro(node, ctx),
|
|
1810
|
+
html: (html) => `<Fragment set:html={${escapeExpr(html)}} />`,
|
|
1811
|
+
});
|
|
1233
1812
|
}
|
|
1234
1813
|
function childrenToAstro(node, ctx) {
|
|
1235
1814
|
if (!node.children || node.children.length === 0)
|
|
@@ -1243,40 +1822,28 @@ function childrenToAstro(node, ctx) {
|
|
|
1243
1822
|
* icon usage on cards registers `icon` at the renderer call site;
|
|
1244
1823
|
* this is the inline-only path. Returns true on first hit.
|
|
1245
1824
|
*/
|
|
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
|
-
}
|
|
1825
|
+
/** The component imports inline nodes need: `icon`, `badge`, and `link-button` for an inline `:button`. */
|
|
1826
|
+
function inlineComponentsIn(nodes, out = new Set()) {
|
|
1827
|
+
const visit = (node) => {
|
|
1828
|
+
if (node.type === "icon")
|
|
1829
|
+
out.add("icon");
|
|
1830
|
+
else if (node.type === "badge")
|
|
1831
|
+
out.add("badge");
|
|
1832
|
+
else if (node.type === "kbd")
|
|
1833
|
+
out.add("kbd");
|
|
1834
|
+
else if (node.type === "link" && node.button)
|
|
1835
|
+
out.add("link-button");
|
|
1836
|
+
if ("children" in node && Array.isArray(node.children))
|
|
1837
|
+
node.children.forEach(visit);
|
|
1838
|
+
};
|
|
1839
|
+
for (const node of nodes ?? []) {
|
|
1840
|
+
node.inline?.forEach(visit);
|
|
1841
|
+
inlineComponentsIn(node.children, out);
|
|
1269
1842
|
}
|
|
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");
|
|
1843
|
+
return out;
|
|
1278
1844
|
}
|
|
1845
|
+
/** Indent, via the core's shared implementation (was a fourth local copy). */
|
|
1846
|
+
const indentStr = indent;
|
|
1279
1847
|
function escapeHtml(s) {
|
|
1280
1848
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1281
1849
|
}
|
|
1282
|
-
//# sourceMappingURL=serialize.js.map
|