@dogsbay/render-html 0.0.0-stage → 0.2.0-beta.116
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/README.md +50 -2
- package/assets/favicon.svg +28 -0
- package/css/code-rich.css +88 -0
- package/css/docs.css +207 -0
- package/css/steps.css +149 -0
- package/css/theme-default.css +239 -0
- package/css/theme-mintlify.css +243 -0
- package/dist/alert-icons.d.ts +6 -0
- package/dist/alert-icons.js +22 -0
- package/dist/badge-variants.d.ts +19 -0
- package/dist/badge-variants.js +33 -0
- package/dist/code-markers.d.ts +29 -0
- package/dist/code-markers.js +66 -0
- package/dist/css-tools.d.ts +12 -0
- package/dist/css-tools.js +75 -0
- package/dist/favicon.d.ts +13 -0
- package/dist/favicon.js +13 -0
- package/dist/file-tree.d.ts +14 -0
- package/dist/file-tree.js +20 -0
- package/dist/html.d.ts +17 -0
- package/dist/html.js +53 -0
- package/dist/icons.d.ts +8 -0
- package/dist/icons.js +65 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/kbd-variants.d.ts +2 -0
- package/dist/kbd-variants.js +3 -0
- package/dist/lines.d.ts +62 -0
- package/dist/lines.js +119 -0
- package/dist/math.d.ts +1 -0
- package/dist/math.js +13 -0
- package/dist/render.d.ts +114 -0
- package/dist/render.js +830 -0
- package/dist/sanitize.d.ts +13 -0
- package/dist/sanitize.js +230 -0
- package/dist/tone-classes.d.ts +1 -0
- package/dist/tone-classes.js +15 -0
- package/dist/url.d.ts +10 -0
- package/dist/url.js +29 -0
- package/package.json +61 -3
package/dist/render.js
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
import { parseInline } from "@dogsbay/format-dogsbay-md/browser";
|
|
2
|
+
import { attrs, esc, FOREIGN_END, FOREIGN_START, passthrough, slugify, unesc } from "./html.js";
|
|
3
|
+
import { CALLOUTS, hasIcon, icon } from "./icons.js";
|
|
4
|
+
import { badgeClass } from "./badge-variants.js";
|
|
5
|
+
import { KBD_CLASS } from "./kbd-variants.js";
|
|
6
|
+
import { TONE_CLASSES } from "./tone-classes.js";
|
|
7
|
+
import { fileTreeHtml } from "./file-tree.js";
|
|
8
|
+
import { stripMarkersForCopy } from "./code-markers.js";
|
|
9
|
+
import { icons as ALERT_ICONS, chevronSvg as ALERT_CHEVRON } from "./alert-icons.js";
|
|
10
|
+
import { parseLineRanges, plainHighlighter } from "./lines.js";
|
|
11
|
+
import { scriptUrl } from "./url.js";
|
|
12
|
+
export { FOREIGN_END, FOREIGN_START };
|
|
13
|
+
export { scriptUrl };
|
|
14
|
+
/** ctx.link, unless the target is a script link: then "#", and it is reported. */
|
|
15
|
+
export function linkUrl(href, ctx) {
|
|
16
|
+
if (scriptUrl(href, "link")) {
|
|
17
|
+
ctx.unsafeUrls?.add(href.slice(0, 80));
|
|
18
|
+
return "#";
|
|
19
|
+
}
|
|
20
|
+
return ctx.link(href);
|
|
21
|
+
}
|
|
22
|
+
/** ctx.asset, unless the source is a script URL: then empty, and it is reported. */
|
|
23
|
+
export function assetUrl(src, ctx) {
|
|
24
|
+
if (scriptUrl(src, "image")) {
|
|
25
|
+
ctx.unsafeUrls?.add(src.slice(0, 80));
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
return ctx.asset(src);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* LaTeX → HTML with MathML for screen readers, at build time: no script on
|
|
32
|
+
* the page. As `@dogsbay/ui`'s MathKatex; a formula KaTeX cannot parse is
|
|
33
|
+
* shown in the error colour instead of failing the build.
|
|
34
|
+
*/
|
|
35
|
+
export function renderMath(latex, display, ctx) {
|
|
36
|
+
if (!ctx.math)
|
|
37
|
+
return undefined;
|
|
38
|
+
ctx.usedMath = true;
|
|
39
|
+
try {
|
|
40
|
+
const html = ctx.math(latex, display);
|
|
41
|
+
if (typeof html !== "string")
|
|
42
|
+
throw new TypeError("math renderer returned no string");
|
|
43
|
+
// The region must end where the formula ends, or classes after it would be skipped by the stylesheet build.
|
|
44
|
+
return `${FOREIGN_START}${html.replaceAll(FOREIGN_START, "").replaceAll(FOREIGN_END, "")}${FOREIGN_END}`;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return `<code class="text-destructive">${esc(latex)}</code>`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Ids the layout and components generate. A heading that would take one gets an
|
|
52
|
+
* "h-" prefix, so it cannot shadow the search dialog, a nav group or a tab.
|
|
53
|
+
*/
|
|
54
|
+
const RESERVED_ID = /^(db-|tab-\d|tabpanel-\d|acc-\d|accpanel-\d|fn-|fnref-)/;
|
|
55
|
+
export function uniqueId(ctx, base) {
|
|
56
|
+
if (RESERVED_ID.test(base))
|
|
57
|
+
base = `h-${base}`;
|
|
58
|
+
let id = base || "section";
|
|
59
|
+
let n = 1;
|
|
60
|
+
while (ctx.ids.has(id))
|
|
61
|
+
id = `${base}-${++n}`;
|
|
62
|
+
ctx.ids.add(id);
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
// ─── Inline ──────────────────────────────────────────────────────────
|
|
66
|
+
export function renderInline(nodes, ctx) {
|
|
67
|
+
if (!nodes)
|
|
68
|
+
return "";
|
|
69
|
+
let out = "";
|
|
70
|
+
for (const n of nodes)
|
|
71
|
+
out += inline(n, ctx);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function decorate(html, n) {
|
|
75
|
+
if (n.strikethrough)
|
|
76
|
+
html = `<del>${html}</del>`;
|
|
77
|
+
if (n.italic)
|
|
78
|
+
html = `<em>${html}</em>`;
|
|
79
|
+
if (n.bold)
|
|
80
|
+
html = `<strong>${html}</strong>`;
|
|
81
|
+
return html;
|
|
82
|
+
}
|
|
83
|
+
function inline(n, ctx) {
|
|
84
|
+
switch (n.type) {
|
|
85
|
+
case "text":
|
|
86
|
+
return decorate(esc(n.text), n);
|
|
87
|
+
case "code":
|
|
88
|
+
return decorate(`<code>${esc(n.text)}</code>`, n);
|
|
89
|
+
case "link": {
|
|
90
|
+
const href = linkUrl(n.href, ctx);
|
|
91
|
+
const external = /^[a-z][a-z0-9+.-]*:|^\/\//i.test(href) && !href.startsWith("mailto:");
|
|
92
|
+
// An inline `:button`: Button.astro's classes at its small size, as the Astro pipeline renders it.
|
|
93
|
+
const variant = n.button ? buttonVariant(n.button.variant) : undefined;
|
|
94
|
+
const a = {
|
|
95
|
+
// Button.astro's small size, whose text-xs replaces the base text-sm (tailwind-merge in tv()).
|
|
96
|
+
...passthrough(n.attrs, variant ? `${BUTTON_BASE.split(" ").filter((c) => c !== "text-sm").join(" ")} ${BUTTON_VARIANTS[variant] ?? BUTTON_VARIANTS.default} h-9 px-3 text-xs no-underline` : undefined),
|
|
97
|
+
href,
|
|
98
|
+
title: n.title,
|
|
99
|
+
"data-variant": variant,
|
|
100
|
+
"data-size": variant ? "sm" : undefined,
|
|
101
|
+
};
|
|
102
|
+
if (external)
|
|
103
|
+
a.rel = "noopener";
|
|
104
|
+
// A button carries no link icon (the icon rules skip [data-variant]); it is not marked.
|
|
105
|
+
if (!variant)
|
|
106
|
+
a["data-link-kind"] = external ? "external" : href.startsWith("#") ? undefined : "internal";
|
|
107
|
+
return `<a${attrs(a)}>${insideLink(true, ctx, () => renderInline(n.children, ctx))}</a>`;
|
|
108
|
+
}
|
|
109
|
+
case "image": {
|
|
110
|
+
const at = (n.attrs ?? {});
|
|
111
|
+
return `<img${attrs({
|
|
112
|
+
...passthrough(at),
|
|
113
|
+
src: assetUrl(n.src, ctx),
|
|
114
|
+
alt: n.alt ?? "",
|
|
115
|
+
title: n.title,
|
|
116
|
+
width: n.width ?? at.width,
|
|
117
|
+
height: n.height ?? at.height,
|
|
118
|
+
loading: "lazy",
|
|
119
|
+
decoding: "async",
|
|
120
|
+
// As @dogsbay/plugin-image-zoom's walker: the link's own click wins over zooming. An author's
|
|
121
|
+
// own data-zoomable (to opt one image out with "false") is kept.
|
|
122
|
+
"data-zoomable": at["data-zoomable"] ?? (ctx.imageZoom && !(ctx.inLink && ctx.imageZoom === "outside-links") ? "true" : undefined),
|
|
123
|
+
})}>`;
|
|
124
|
+
}
|
|
125
|
+
case "highlight":
|
|
126
|
+
return `<mark class="rounded-sm bg-yellow-200/60 px-0.5 dark:bg-yellow-500/30">${renderInline(n.children, ctx)}</mark>`;
|
|
127
|
+
case "footnote-ref": {
|
|
128
|
+
const label = esc(n.label);
|
|
129
|
+
const refs = (ctx.fnRefs ??= new Map());
|
|
130
|
+
const count = (refs.get(n.label) ?? 0) + 1;
|
|
131
|
+
refs.set(n.label, count);
|
|
132
|
+
// The first reference is the backlink target; repeats get their own ids.
|
|
133
|
+
const id = count === 1 ? `fnref-${label}` : `fnref-${label}-${count}`;
|
|
134
|
+
return `<sup><a id="${id}" href="#fn-${label}" role="doc-noteref" class="text-primary hover:underline">[${label}]</a></sup>`;
|
|
135
|
+
}
|
|
136
|
+
case "kbd":
|
|
137
|
+
// Kbd.astro's keycap classes (synced from @dogsbay/ui).
|
|
138
|
+
return n.keys.map((k) => `<kbd class="${KBD_CLASS}">${esc(k)}</kbd>`).join("+");
|
|
139
|
+
case "badge": {
|
|
140
|
+
const variant = n.variant ?? "default";
|
|
141
|
+
return `<span${attrs({ ...passthrough(n.attrs, badgeClass(variant)), "data-variant": variant })}>${esc(n.text)}</span>`;
|
|
142
|
+
}
|
|
143
|
+
case "icon":
|
|
144
|
+
return namedIcon((n.library ? n.library + ":" : "") + n.name, "db-icon db-icon-inline", ctx);
|
|
145
|
+
case "math":
|
|
146
|
+
return renderMath(n.latex, false, ctx) ?? `<code class="db-math">${esc(n.latex)}</code>`;
|
|
147
|
+
case "html-inline":
|
|
148
|
+
return rewriteHtml(n.html, ctx);
|
|
149
|
+
case "break":
|
|
150
|
+
return "<br>";
|
|
151
|
+
default:
|
|
152
|
+
ctx.unknown.add(`inline:${n.type}`);
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Plain text of inline nodes (for TOC entries, titles and search). */
|
|
157
|
+
export function inlineText(nodes) {
|
|
158
|
+
if (!nodes)
|
|
159
|
+
return "";
|
|
160
|
+
let out = "";
|
|
161
|
+
for (const n of nodes) {
|
|
162
|
+
switch (n.type) {
|
|
163
|
+
case "text":
|
|
164
|
+
case "code":
|
|
165
|
+
out += n.text;
|
|
166
|
+
break;
|
|
167
|
+
case "link":
|
|
168
|
+
case "highlight":
|
|
169
|
+
out += inlineText(n.children);
|
|
170
|
+
break;
|
|
171
|
+
case "kbd":
|
|
172
|
+
out += n.keys.join("+");
|
|
173
|
+
break;
|
|
174
|
+
case "badge":
|
|
175
|
+
out += n.text;
|
|
176
|
+
break;
|
|
177
|
+
case "math":
|
|
178
|
+
out += n.latex;
|
|
179
|
+
break;
|
|
180
|
+
case "image":
|
|
181
|
+
out += n.alt ?? "";
|
|
182
|
+
break;
|
|
183
|
+
case "html-inline":
|
|
184
|
+
out += n.html.replace(/<[^>]*>/g, "");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/** URL attributes the host does not rewrite, and unquoted values: only the script-link check. */
|
|
191
|
+
const SCRIPT_ATTRS = /(\s)(href|src|xlink:href|action|formaction|poster|data)(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
|
192
|
+
function refuseScriptAttrs(tag, ctx) {
|
|
193
|
+
return tag.replace(SCRIPT_ATTRS, (m, sp, attr, eq, raw) => {
|
|
194
|
+
const quoted = raw.startsWith('"') || raw.startsWith("'");
|
|
195
|
+
const lower = attr.toLowerCase();
|
|
196
|
+
// Quoted href and src are checked (and rewritten) by rewriteHtml itself.
|
|
197
|
+
if (quoted && (lower === "href" || lower === "src"))
|
|
198
|
+
return m;
|
|
199
|
+
const v = unesc(quoted ? raw.slice(1, -1) : raw);
|
|
200
|
+
if (!scriptUrl(v, lower === "src" || lower === "poster" ? "image" : "link"))
|
|
201
|
+
return m;
|
|
202
|
+
ctx.unsafeUrls?.add(v.slice(0, 80));
|
|
203
|
+
return `${sp}${attr}${eq}"${lower === "href" ? "#" : ""}"`;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Rewrite href/src attributes of tags in raw HTML so root-relative links follow
|
|
208
|
+
* the site layout. Only attributes inside a start tag are touched (not text such
|
|
209
|
+
* as an escaped code example, nor data-href), and values are entity-decoded
|
|
210
|
+
* before resolving so `&` is not escaped twice.
|
|
211
|
+
*/
|
|
212
|
+
export function rewriteHtml(raw, ctx) {
|
|
213
|
+
// Every raw-HTML sink comes through here, including HTML parsed out of a prop
|
|
214
|
+
// (a callout title, a card description) at render time.
|
|
215
|
+
const html = ctx.rawHtml ? ctx.rawHtml(raw) : raw;
|
|
216
|
+
return html.replace(/<[a-zA-Z][^<>]*>/g, (tag) => refuseScriptAttrs(tag, ctx).replace(/(\s)(href|src)(\s*=\s*)("([^"]*)"|'([^']*)')/gi, (m, sp, attr, eq, _q, dq, sq) => {
|
|
217
|
+
const v = unesc(dq ?? sq ?? "");
|
|
218
|
+
const isHref = attr.toLowerCase() === "href";
|
|
219
|
+
if (v && scriptUrl(v, isHref ? "link" : "image")) {
|
|
220
|
+
ctx.unsafeUrls?.add(v.slice(0, 80));
|
|
221
|
+
return `${sp}${attr}${eq}"${isHref ? "#" : ""}"`;
|
|
222
|
+
}
|
|
223
|
+
if (!v || v.startsWith("#") || /^[a-z][a-z0-9+.-]*:|^\/\//i.test(v))
|
|
224
|
+
return m;
|
|
225
|
+
const out = isHref ? ctx.link(v) : ctx.asset(v);
|
|
226
|
+
return `${sp}${attr}${eq}"${esc(out)}"`;
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
// ─── Blocks ──────────────────────────────────────────────────────────
|
|
230
|
+
export function renderBlocks(nodes, ctx) {
|
|
231
|
+
if (!nodes)
|
|
232
|
+
return "";
|
|
233
|
+
let out = "";
|
|
234
|
+
for (const n of nodes)
|
|
235
|
+
out += block(n, ctx);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
const str = (v) => (v == null || v === "" ? undefined : String(v));
|
|
239
|
+
/** A list item or step: the item's own inline run followed by its block children. */
|
|
240
|
+
function itemBody(n, ctx) {
|
|
241
|
+
const lead = n.inline && n.inline.length ? `<p>${renderInline(n.inline, ctx)}</p>` : "";
|
|
242
|
+
return lead + renderBlocks(n.children, ctx);
|
|
243
|
+
}
|
|
244
|
+
/** Tight list items render their text without a wrapping <p>. */
|
|
245
|
+
function listItem(n, ctx) {
|
|
246
|
+
const p = n.props ?? {};
|
|
247
|
+
const task = typeof p.checked === "boolean" ? `<input type="checkbox" disabled${p.checked ? " checked" : ""}> ` : "";
|
|
248
|
+
const lead = n.inline && n.inline.length ? renderInline(n.inline, ctx) : "";
|
|
249
|
+
const rest = renderBlocks(n.children, ctx);
|
|
250
|
+
const body = rest && lead ? `<p>${task}${lead}</p>${rest}` : task + lead + rest;
|
|
251
|
+
return `<li${attrs(passthrough(p))}>${body}</li>`;
|
|
252
|
+
}
|
|
253
|
+
function block(n, ctx) {
|
|
254
|
+
const p = (n.props ?? {});
|
|
255
|
+
switch (n.type) {
|
|
256
|
+
case "paragraph": {
|
|
257
|
+
// markdown-it treats a line that starts with a custom element (<db-popover>) as
|
|
258
|
+
// inline HTML and wraps it in <p>. A block child such as <div> would then make the
|
|
259
|
+
// browser close that <p> early and move the child out of the element, so a
|
|
260
|
+
// paragraph that is exactly one custom element, open tag to close tag, is emitted as-is.
|
|
261
|
+
const inl = n.inline ?? [];
|
|
262
|
+
const first = inl[0];
|
|
263
|
+
const last = inl[inl.length - 1];
|
|
264
|
+
const tag = first?.type === "html-inline" ? /^<([a-z][a-z0-9]*-[a-z0-9-]*)[\s>]/i.exec(first.html)?.[1]?.toLowerCase() : undefined;
|
|
265
|
+
if (tag && last?.type === "html-inline" && last.html.trim().toLowerCase() === `</${tag}>` && closesAtEnd(inl, tag)) {
|
|
266
|
+
return renderInline(inl, ctx) + "\n";
|
|
267
|
+
}
|
|
268
|
+
const only = inl.filter((i) => !(i.type === "text" && !i.text.trim()));
|
|
269
|
+
if (ctx.imageCaptions === "title" && only.length === 1 && only[0].type === "image" && only[0].title) {
|
|
270
|
+
// The figure form of the Astro pipeline's BlockImage (Image.astro, block), with its classes: the image's
|
|
271
|
+
// own class goes on the figure, as Image.astro takes `class` for the figure and keeps the img's fixed.
|
|
272
|
+
const img = only[0];
|
|
273
|
+
const { class: imgClass, ...at } = (img.attrs ?? {});
|
|
274
|
+
const framed = { ...img, attrs: { ...at, class: "rounded-lg border" } };
|
|
275
|
+
const figClass = ["my-4", imgClass].filter(Boolean).join(" ");
|
|
276
|
+
return `<figure${attrs(passthrough(p, figClass))}>${renderInline([framed], ctx)}<figcaption class="mt-2 text-center text-sm text-muted-foreground">${esc(img.title)}</figcaption></figure>\n`;
|
|
277
|
+
}
|
|
278
|
+
return `<p${attrs(passthrough(p))}>${renderInline(n.inline, ctx)}</p>\n`;
|
|
279
|
+
}
|
|
280
|
+
case "heading": {
|
|
281
|
+
const level = Math.min(6, Math.max(1, Number(p.level) || 2));
|
|
282
|
+
const text = inlineText(n.inline);
|
|
283
|
+
const id = uniqueId(ctx, str(p.id) ?? str(p.slug) ?? slugify(text));
|
|
284
|
+
if (level === 2 || level === 3)
|
|
285
|
+
ctx.headings.push({ depth: level, id, text });
|
|
286
|
+
// Astro serializer: a hover pilcrow link, hidden from assistive tech (the heading is the target).
|
|
287
|
+
const anchor = level > 1 ? `<a href="#${esc(id)}" class="ml-2 text-muted-foreground no-underline opacity-0 group-hover:opacity-100 focus:opacity-100" aria-hidden="true" tabindex="-1">¶</a>` : "";
|
|
288
|
+
const { id: _i, ...rest } = passthrough(p, "group scroll-mt-20");
|
|
289
|
+
return `<h${level}${attrs({ id, ...rest })}>${renderInline(n.inline, ctx)}${anchor}</h${level}>\n`;
|
|
290
|
+
}
|
|
291
|
+
case "unordered-list":
|
|
292
|
+
return `<ul${attrs(passthrough(p))}>${(n.children ?? []).map((c) => listItem(c, ctx)).join("")}</ul>\n`;
|
|
293
|
+
case "ordered-list":
|
|
294
|
+
return `<ol${attrs({ ...passthrough(p), start: p.start != null && Number(p.start) !== 1 ? p.start : undefined })}>${(n.children ?? []).map((c) => listItem(c, ctx)).join("")}</ol>\n`;
|
|
295
|
+
case "list-item":
|
|
296
|
+
return listItem(n, ctx);
|
|
297
|
+
case "blockquote":
|
|
298
|
+
return `<blockquote${attrs(passthrough(p, "border-l-4 border-border pl-4 italic text-muted-foreground"))}>${renderBlocks(n.children, ctx)}</blockquote>\n`;
|
|
299
|
+
case "hr":
|
|
300
|
+
return `<hr class="my-8 border-border">\n`;
|
|
301
|
+
case "html":
|
|
302
|
+
return rewriteHtml(n.html ?? "", ctx) + "\n";
|
|
303
|
+
case "code":
|
|
304
|
+
return codeBlock(n, ctx);
|
|
305
|
+
case "table":
|
|
306
|
+
return table(n, ctx);
|
|
307
|
+
case "thead":
|
|
308
|
+
return `<thead class="[&_tr]:border-b">${renderBlocks(n.children, ctx)}</thead>`;
|
|
309
|
+
case "tbody":
|
|
310
|
+
return `<tbody class="[&_tr:last-child]:border-0">${renderBlocks(n.children, ctx)}</tbody>`;
|
|
311
|
+
case "tr":
|
|
312
|
+
return `<tr${attrs(passthrough(p, "border-b transition-colors hover:bg-muted/50"))}>${renderBlocks(n.children, ctx)}</tr>`;
|
|
313
|
+
case "th":
|
|
314
|
+
case "td": {
|
|
315
|
+
// Only the three values: the style is written as given.
|
|
316
|
+
const align = /^(left|center|right)$/i.test(str(p.align) ?? "") ? str(p.align).toLowerCase() : undefined;
|
|
317
|
+
const cls = n.type === "th" ? "px-2 py-2 text-left align-bottom font-semibold text-muted-foreground" : "p-2 align-top";
|
|
318
|
+
return `<${n.type}${attrs({ ...passthrough(p, cls), colspan: p.colspan, rowspan: p.rowspan, style: align ? `text-align:${align}` : p.style })}>${renderInline(n.inline, ctx)}${renderBlocks(n.children, ctx)}</${n.type}>`;
|
|
319
|
+
}
|
|
320
|
+
case "deflist":
|
|
321
|
+
return `<dl${attrs(passthrough(p, "my-4 space-y-2"))}>${renderBlocks(n.children, ctx)}</dl>\n`;
|
|
322
|
+
case "dt":
|
|
323
|
+
return `<dt class="font-semibold">${renderInline(n.inline, ctx)}</dt>`;
|
|
324
|
+
case "dd":
|
|
325
|
+
return `<dd class="ml-6 text-muted-foreground">${itemBody(n, ctx)}</dd>`;
|
|
326
|
+
case "footnote-list":
|
|
327
|
+
return `<section class="footnotes mt-12 border-t pt-6 text-sm text-muted-foreground" role="doc-endnotes"><ol>${renderBlocks(n.children, ctx)}</ol></section>\n`;
|
|
328
|
+
case "footnote-def": {
|
|
329
|
+
const label = esc(str(p.label) ?? "");
|
|
330
|
+
const body = n.inline?.length ? `<p>${renderInline(n.inline, ctx)} <a href="#fnref-${label}" class="text-primary no-underline" role="doc-backlink" aria-label="${esc(ctx.t("backToContent"))}">↩</a></p>` : "";
|
|
331
|
+
return `<li id="fn-${label}">${body}${renderBlocks(n.children, ctx)}</li>`;
|
|
332
|
+
}
|
|
333
|
+
case "callout":
|
|
334
|
+
return callout(n, ctx);
|
|
335
|
+
case "steps":
|
|
336
|
+
return steps(n, ctx);
|
|
337
|
+
case "step":
|
|
338
|
+
// Tight, like the Astro Step: the lead text is not wrapped in <p>.
|
|
339
|
+
return `<li>${renderInline(n.inline, ctx)}${renderBlocks(n.children, ctx)}</li>`;
|
|
340
|
+
case "tabs":
|
|
341
|
+
return tabs(n, ctx);
|
|
342
|
+
case "tab":
|
|
343
|
+
return renderBlocks(n.children, ctx);
|
|
344
|
+
case "accordion":
|
|
345
|
+
return accordion(n, ctx);
|
|
346
|
+
case "accordion-item":
|
|
347
|
+
return accordion({ type: "accordion", children: [n] }, ctx);
|
|
348
|
+
case "details":
|
|
349
|
+
return details(n, ctx);
|
|
350
|
+
case "figure":
|
|
351
|
+
return figure(n, ctx);
|
|
352
|
+
case "substeps": {
|
|
353
|
+
// SubSteps.astro: a lettered list; inside a step, steps.css styles it as the nested list.
|
|
354
|
+
const items = (n.children ?? []).map((c) => (c.type === "step" ? block(c, ctx) : `<li>${block(c, ctx)}</li>`)).join("");
|
|
355
|
+
return `<ol${attrs({ ...passthrough(p, "sub-steps-list"), role: "list" })}>${items}</ol>\n`;
|
|
356
|
+
}
|
|
357
|
+
case "annotation-list":
|
|
358
|
+
// format-astro's wrapBlock for annotation-list.
|
|
359
|
+
return `<div${attrs(passthrough(p, "my-2 rounded-md border-l-4 border-info/50 bg-info/5 p-4"))}>${renderBlocks(n.children, ctx)}</div>\n`;
|
|
360
|
+
case "avatar":
|
|
361
|
+
return avatar(p, ctx);
|
|
362
|
+
case "example":
|
|
363
|
+
return example(n, ctx);
|
|
364
|
+
case "cards":
|
|
365
|
+
return `<div${attrs(passthrough(p, "not-prose my-4 grid grid-cols-1 gap-4 sm:grid-cols-2"))}>${renderBlocks(n.children, ctx)}</div>\n`;
|
|
366
|
+
case "card":
|
|
367
|
+
return card(n, ctx);
|
|
368
|
+
case "link-card":
|
|
369
|
+
return linkCard(n, ctx);
|
|
370
|
+
case "grid": {
|
|
371
|
+
const cols = Math.min(6, Math.max(1, Number(p.cols) || 2));
|
|
372
|
+
return `<div${attrs({ ...passthrough(p, `not-prose my-4 grid grid-cols-1 gap-4 ${GRID_COLS[cols]}`) })}>${renderBlocks(n.children, ctx)}</div>\n`;
|
|
373
|
+
}
|
|
374
|
+
case "grid-item": {
|
|
375
|
+
const span = Number(p.span) || 0;
|
|
376
|
+
const rows = Number(p.rowSpan) || 0;
|
|
377
|
+
const style = [span > 1 && `grid-column:span ${span} / span ${span}`, rows > 1 && `grid-row:span ${rows} / span ${rows}`].filter(Boolean).join(";") || undefined;
|
|
378
|
+
// `label` draws a placeholder cell in the `tone` colours, as GridItem does in the Astro pipeline.
|
|
379
|
+
const label = str(p.label);
|
|
380
|
+
const inner = label !== undefined
|
|
381
|
+
? `<div class="flex h-full min-h-16 items-center justify-center rounded-lg font-mono text-sm font-medium ${TONE_CLASSES[str(p.tone) ?? "primary-light"] ?? TONE_CLASSES["primary-light"]}">${esc(label)}</div>`
|
|
382
|
+
: renderBlocks(n.children, ctx);
|
|
383
|
+
const { label: _l, tone: _t, rowSpan: _r, ...rest } = p;
|
|
384
|
+
const pass = passthrough(rest);
|
|
385
|
+
// The span styles join an author's own `style` rather than replace it.
|
|
386
|
+
const merged = [style, str(pass.style)].filter(Boolean).join(";") || undefined;
|
|
387
|
+
return `<div${attrs({ ...pass, style: merged })}>${inner}</div>`;
|
|
388
|
+
}
|
|
389
|
+
case "files":
|
|
390
|
+
// Each list is a tree; anything else in the block (an intro, a note) renders as it is.
|
|
391
|
+
return (n.children ?? [])
|
|
392
|
+
.map((c) => (c.type === "unordered-list" || c.type === "ordered-list" ? fileTreeHtml(fileTreeItems(c, ctx), str(p.class) ?? "") + "\n" : renderBlocks([c], ctx)))
|
|
393
|
+
.join("");
|
|
394
|
+
case "container": {
|
|
395
|
+
// A `:::name` block no renderer knows: its content in a marked div (and a build warning).
|
|
396
|
+
const name = typeof n.meta?.name === "string" ? n.meta.name : "container";
|
|
397
|
+
ctx.unknown.add(`:::${name}`);
|
|
398
|
+
const title = typeof n.meta?.title === "string" ? `<p class="font-semibold">${esc(n.meta.title)}</p>` : "";
|
|
399
|
+
return `<div${attrs({ ...passthrough(p), "data-directive": name })}>${title}${renderBlocks(n.children, ctx)}</div>\n`;
|
|
400
|
+
}
|
|
401
|
+
case "link-button": {
|
|
402
|
+
const href = str(p.href);
|
|
403
|
+
const variant = buttonVariant(str(p.variant));
|
|
404
|
+
const label = insideLink(true, ctx, () => (n.children?.length === 1 && n.children[0].type === "paragraph" ? renderInline(n.children[0].inline, ctx) : renderBlocks(n.children, ctx)));
|
|
405
|
+
return `<p><a${attrs({ ...passthrough(p, `${BUTTON_BASE} ${BUTTON_VARIANTS[variant] ?? BUTTON_VARIANTS.default} h-10 px-4 py-2 no-underline`), "data-variant": variant, "data-size": "default", href: href ? linkUrl(href, ctx) : undefined })}>${label}</a></p>\n`;
|
|
406
|
+
}
|
|
407
|
+
case "youtube": {
|
|
408
|
+
// A card linking to the video, not an embed: the page must work offline, with no request to YouTube
|
|
409
|
+
// (the Astro pipeline embeds it on click). A local `poster` image is shown on it.
|
|
410
|
+
const id = str(p.id) ?? "";
|
|
411
|
+
const title = str(p.title) ?? "YouTube video";
|
|
412
|
+
// The video id is not the element's id (two cards for one video would share it).
|
|
413
|
+
const { id: _vid, ...ytRest } = p;
|
|
414
|
+
const poster = str(p.poster);
|
|
415
|
+
const play = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7z"/></svg>';
|
|
416
|
+
return (`<a${attrs({ ...passthrough(ytRest, "not-prose group my-4 flex items-center gap-4 rounded-lg border bg-card p-3 text-card-foreground no-underline shadow-sm transition-colors hover:bg-accent/50"), href: `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`, rel: "noopener", "data-component": "youtube" })}>` +
|
|
417
|
+
(poster ? `<img src="${esc(assetUrl(poster, ctx))}" alt="" class="h-16 w-28 shrink-0 rounded object-cover" loading="lazy">` : "") +
|
|
418
|
+
`<span class="flex size-10 shrink-0 items-center justify-center rounded-full bg-red-600 text-white">${play}</span>` +
|
|
419
|
+
`<span class="min-w-0"><span class="block font-semibold group-hover:underline">${esc(title)}</span><span class="block text-xs text-muted-foreground">YouTube</span></span></a>\n`);
|
|
420
|
+
}
|
|
421
|
+
case "diagram": {
|
|
422
|
+
// Importers that render diagrams themselves (the Astro pipeline's `diagram` node) carry the SVG; otherwise render or show the code.
|
|
423
|
+
const lang = str(p.lang) ?? "mermaid";
|
|
424
|
+
const code = str(p.code) ?? "";
|
|
425
|
+
const svg = str(p.svg) ?? ctx.diagram?.(lang, code);
|
|
426
|
+
return svg ? diagramFigure(lang, svg, str(p.title), p) : codeBlock({ type: "code", props: { ...p, language: lang }, html: code }, ctx);
|
|
427
|
+
}
|
|
428
|
+
case "math-block": {
|
|
429
|
+
const latex = str(p.latex) ?? "";
|
|
430
|
+
const html = renderMath(latex, true, ctx);
|
|
431
|
+
return html != null
|
|
432
|
+
? `<div class="my-4 overflow-x-auto">${html}</div>\n`
|
|
433
|
+
: `<pre class="my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code class="math-display">${esc(latex)}</code></pre>\n`;
|
|
434
|
+
}
|
|
435
|
+
case "math":
|
|
436
|
+
return `<pre class="my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code class="math-display">${esc(n.html ?? inlineText(n.inline))}</code></pre>\n`;
|
|
437
|
+
case "related":
|
|
438
|
+
return `<nav class="related-resources" aria-label="${esc(ctx.t("related"))}">${renderInline(n.inline, ctx)}${renderBlocks(n.children, ctx)}</nav>\n`;
|
|
439
|
+
case "include": {
|
|
440
|
+
const target = str(p.path) ?? str(p.src) ?? str(p.file) ?? "";
|
|
441
|
+
const remote = /https?:\/\/\S+/.exec(target)?.[0];
|
|
442
|
+
if (remote) {
|
|
443
|
+
ctx.unknown.add("include of a remote file (not fetched)");
|
|
444
|
+
return `<p><a rel="noopener" href="${esc(remote)}">${esc(remote.split("/").pop() ?? remote)}</a></p>\n`;
|
|
445
|
+
}
|
|
446
|
+
ctx.unknown.add("include (unresolved)");
|
|
447
|
+
return `<!-- unresolved include: ${esc(target)} -->\n`;
|
|
448
|
+
}
|
|
449
|
+
default: {
|
|
450
|
+
ctx.unknown.add(n.type);
|
|
451
|
+
if (n.html && !n.children?.length)
|
|
452
|
+
return rewriteHtml(n.html, ctx);
|
|
453
|
+
const inner = (n.inline?.length ? `<p>${renderInline(n.inline, ctx)}</p>` : "") + renderBlocks(n.children, ctx);
|
|
454
|
+
return `<div${attrs(passthrough(p, `db-${slugify(n.type)}`))}>${inner}</div>\n`;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** True when the element opened by the first node is closed by the last node and not before. */
|
|
459
|
+
function closesAtEnd(inl, tag) {
|
|
460
|
+
let depth = 0;
|
|
461
|
+
for (let i = 0; i < inl.length; i++) {
|
|
462
|
+
const n = inl[i];
|
|
463
|
+
if (n.type !== "html-inline")
|
|
464
|
+
continue;
|
|
465
|
+
const h = n.html.trim().toLowerCase();
|
|
466
|
+
if (new RegExp(`^<${tag}[\\s>]`).test(h))
|
|
467
|
+
depth++;
|
|
468
|
+
else if (h === `</${tag}>`) {
|
|
469
|
+
depth--;
|
|
470
|
+
if (depth === 0 && i !== inl.length - 1)
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return depth === 0;
|
|
475
|
+
}
|
|
476
|
+
const GRID_COLS = {
|
|
477
|
+
1: "sm:grid-cols-1",
|
|
478
|
+
2: "sm:grid-cols-2",
|
|
479
|
+
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
480
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
481
|
+
5: "sm:grid-cols-3 lg:grid-cols-5",
|
|
482
|
+
6: "sm:grid-cols-3 lg:grid-cols-6",
|
|
483
|
+
};
|
|
484
|
+
/** A button variant as Button.astro names it: the spec's `primary` is its filled `default`. */
|
|
485
|
+
function buttonVariant(variant) {
|
|
486
|
+
return !variant || variant === "primary" ? "default" : variant;
|
|
487
|
+
}
|
|
488
|
+
const BUTTON_BASE = "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50";
|
|
489
|
+
const BUTTON_VARIANTS = {
|
|
490
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
491
|
+
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
492
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
493
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
494
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
495
|
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
496
|
+
};
|
|
497
|
+
// ─── Code (CodeBlock.astro, with db-code-block for copy) ────────────
|
|
498
|
+
const COPY_ICON = '<svg data-part="copy-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
|
|
499
|
+
const CHECK_ICON = '<svg data-part="check-icon" hidden xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
500
|
+
/** A rendered diagram, as `@dogsbay/ui`'s Diagram.astro: dark mode inverts it and turns the hues back. */
|
|
501
|
+
function diagramFigure(lang, svg, title, p) {
|
|
502
|
+
return (`<figure${attrs({ ...passthrough(p, "diagram not-prose my-4"), "data-lang": lang })}>` +
|
|
503
|
+
(title ? `<figcaption class="mb-2 text-sm text-muted-foreground">${esc(title)}</figcaption>` : "") +
|
|
504
|
+
`<div class="diagram-content overflow-x-auto [&>svg]:mx-auto [&>svg]:max-w-full dark:invert dark:[&>svg]:hue-rotate-180">${FOREIGN_START}${svg}${FOREIGN_END}</div></figure>\n`);
|
|
505
|
+
}
|
|
506
|
+
export function codeRequest(n) {
|
|
507
|
+
const p = (n.props ?? {});
|
|
508
|
+
const code = n.html ?? String(p.code ?? "");
|
|
509
|
+
const rawLang = str(p.language) ?? str(p.lang);
|
|
510
|
+
const lang = rawLang === "terminal" ? "bash" : rawLang;
|
|
511
|
+
// The fence's documented `mark`, `ins` and `del` are highlighted, added and removed lines.
|
|
512
|
+
const highlights = [...parseLineRanges(p.highlights ?? p.highlight ?? p.hl_lines), ...parseLineRanges(p.mark)];
|
|
513
|
+
const diffAdd = [...parseLineRanges(p.diffAdd), ...parseLineRanges(p.ins)];
|
|
514
|
+
const diffRemove = [...parseLineRanges(p.diffRemove), ...parseLineRanges(p.del)];
|
|
515
|
+
const lineNumbers = p.lineNumbers === true || p.lineNumbers === "true" || p.linenums != null;
|
|
516
|
+
return { code, lang, opts: { highlights, lineNumbers, diffAdd, diffRemove } };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Every block (language tag as written, and code) a tree's rendering may ask
|
|
520
|
+
* the highlighter for, so a highlighter that loads grammars per language
|
|
521
|
+
* (`Highlighter.load`) can load them before rendering: code nodes, and
|
|
522
|
+
* diagrams, which fall back to a code block when not rendered. The code is
|
|
523
|
+
* part of it: a markdown block needs the grammars its fences name. Looks
|
|
524
|
+
* through props as well as children, so a node a component nests elsewhere
|
|
525
|
+
* is found.
|
|
526
|
+
*/
|
|
527
|
+
export function highlightInputs(tree) {
|
|
528
|
+
const out = [];
|
|
529
|
+
const walk = (v) => {
|
|
530
|
+
if (Array.isArray(v))
|
|
531
|
+
return v.forEach(walk);
|
|
532
|
+
if (!v || typeof v !== "object")
|
|
533
|
+
return;
|
|
534
|
+
const n = v;
|
|
535
|
+
if (n.type === "code") {
|
|
536
|
+
const { lang, code } = codeRequest(n);
|
|
537
|
+
out.push({ lang, code });
|
|
538
|
+
}
|
|
539
|
+
else if (n.type === "diagram") {
|
|
540
|
+
// The code block a diagram falls back to, built as the "diagram" case builds it.
|
|
541
|
+
const p = (n.props ?? {});
|
|
542
|
+
const { lang, code } = codeRequest({ type: "code", props: { ...p, language: str(p.lang) ?? "mermaid" }, html: str(p.code) ?? "" });
|
|
543
|
+
out.push({ lang, code });
|
|
544
|
+
}
|
|
545
|
+
if (n.children)
|
|
546
|
+
walk(n.children);
|
|
547
|
+
if (n.props)
|
|
548
|
+
for (const x of Object.values(n.props))
|
|
549
|
+
if (x && typeof x === "object")
|
|
550
|
+
walk(x);
|
|
551
|
+
};
|
|
552
|
+
walk(tree);
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
function codeBlock(n, ctx) {
|
|
556
|
+
const p = (n.props ?? {});
|
|
557
|
+
const rawLang = str(p.language) ?? str(p.lang);
|
|
558
|
+
const { code, lang, opts } = codeRequest(n);
|
|
559
|
+
const svg = rawLang && ctx.diagram ? ctx.diagram(rawLang, code) : undefined;
|
|
560
|
+
if (svg)
|
|
561
|
+
return diagramFigure(rawLang, svg, str(p.title) ?? str(p.filename), p);
|
|
562
|
+
const title = str(p.title) ?? str(p.filename);
|
|
563
|
+
const { highlights, lineNumbers, diffAdd, diffRemove } = opts;
|
|
564
|
+
const hl = ctx.highlighter ?? plainHighlighter;
|
|
565
|
+
const pre = hl.code(code, lang, opts);
|
|
566
|
+
// A block with annotations takes CodeRich's styles, as the Astro pipeline routes it to CodeRich.
|
|
567
|
+
const diffs = diffAdd.length > 0 || diffRemove.length > 0;
|
|
568
|
+
const rich = lineNumbers || highlights.length > 0 || diffs || code.includes("[!code");
|
|
569
|
+
const plain = !lang || hl.resolve(lang) === "text";
|
|
570
|
+
const copyLabel = esc(ctx.t("copyCode"));
|
|
571
|
+
const copyBtn = (extra = "") => `<button type="button" data-part="copy" aria-label="${copyLabel}" class="inline-flex h-7 w-7 items-center justify-center rounded-md text-code-fg/50 transition-colors hover:bg-code-fg/10 hover:text-code-fg/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring${extra}">${COPY_ICON}${CHECK_ICON}</button>`;
|
|
572
|
+
let header = "";
|
|
573
|
+
let floating = "";
|
|
574
|
+
const mode = ctx.codeBlockTitle ?? true;
|
|
575
|
+
if (mode === "auto" ? !!title : mode && (title || !plain)) {
|
|
576
|
+
const left = title ? `<span class="min-w-0 font-medium text-code-fg" data-part="title">${esc(title)}</span>` : `<span class="select-none" data-part="label">${esc(lang ?? "")}</span>`;
|
|
577
|
+
const langRight = title && !plain ? `<span class="select-none" data-part="language">${esc(lang ?? "")}</span>` : "";
|
|
578
|
+
header = `<div data-part="header" class="flex items-center justify-between border-b border-code-fg/10 bg-code-fg/5 px-4 py-2 text-xs text-code-fg/70">${left}<span class="flex shrink-0 items-center gap-2">${langRight}${copyBtn()}</span></div>`;
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
floating = copyBtn(" absolute right-2 top-2 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100");
|
|
582
|
+
}
|
|
583
|
+
return (`<db-code-block${attrs({
|
|
584
|
+
...passthrough(p, "not-prose group relative my-4 overflow-hidden rounded-lg border bg-code-bg text-sm"),
|
|
585
|
+
// CodeRich's name for an annotated block, CodeBlock's otherwise, as the Astro pipeline renders them.
|
|
586
|
+
"data-component": rich ? "code-rich" : "code-block",
|
|
587
|
+
"data-lang": lang,
|
|
588
|
+
"data-line-numbers": lineNumbers || undefined,
|
|
589
|
+
// With a gutter or [!code …] markers the text on the page is not the code: copy the source, markers removed.
|
|
590
|
+
"data-copy-text": str(p.copyText) ?? (lineNumbers || diffs || code.includes("[!code") ? stripMarkersForCopy(code.replace(/\n$/, "")) : undefined),
|
|
591
|
+
})}>` +
|
|
592
|
+
header +
|
|
593
|
+
floating +
|
|
594
|
+
`<div data-part="code" class="${rich ? "code-rich-body " : ""}relative overflow-x-auto [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!border-0 [&_pre]:px-4 [&_pre]:py-3">${pre}</div></db-code-block>\n`);
|
|
595
|
+
}
|
|
596
|
+
function table(n, ctx) {
|
|
597
|
+
const p = (n.props ?? {});
|
|
598
|
+
const caption = str(p.caption);
|
|
599
|
+
const cap = caption ? `<caption class="mb-2 text-left text-sm font-medium text-foreground">${renderInline(parseInline(caption), ctx)}</caption>` : "";
|
|
600
|
+
// Focusable so keyboard users can scroll a wide table (axe scrollable-region-focusable).
|
|
601
|
+
return `<div class="relative w-full overflow-x-auto" tabindex="0"><table${attrs(passthrough(p, "my-4 w-full caption-top text-sm"))}>${cap}${renderBlocks(n.children, ctx)}</table></div>\n`;
|
|
602
|
+
}
|
|
603
|
+
// ─── Callouts (Alert.astro / CollapsibleAlert.astro) ────────────────
|
|
604
|
+
/** Authored type → Alert variant (format-astro serialize.ts). */
|
|
605
|
+
const VARIANT_ALIAS = { important: "info", caution: "danger", error: "failure", hint: "tip", attention: "warning" };
|
|
606
|
+
const ALERT_BASE = "relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:size-4 [&>svg~div]:pl-7";
|
|
607
|
+
const ALERT_VARIANT = {
|
|
608
|
+
note: "border-note/50 bg-note/5 text-foreground [&>svg]:text-note",
|
|
609
|
+
abstract: "border-abstract/50 bg-abstract/5 text-foreground [&>svg]:text-abstract",
|
|
610
|
+
info: "border-info/50 bg-info/5 text-foreground [&>svg]:text-info",
|
|
611
|
+
tip: "border-tip/50 bg-tip/5 text-foreground [&>svg]:text-tip",
|
|
612
|
+
success: "border-success/50 bg-success/5 text-foreground [&>svg]:text-success",
|
|
613
|
+
question: "border-question/50 bg-question/5 text-foreground [&>svg]:text-question",
|
|
614
|
+
warning: "border-warning/50 bg-warning/5 text-foreground [&>svg]:text-warning",
|
|
615
|
+
failure: "border-failure/50 bg-failure/5 text-foreground [&>svg]:text-failure",
|
|
616
|
+
danger: "border-danger/50 bg-danger/5 text-foreground [&>svg]:text-danger",
|
|
617
|
+
bug: "border-bug/50 bg-bug/5 text-foreground [&>svg]:text-bug",
|
|
618
|
+
example: "border-example/50 bg-example/5 text-foreground [&>svg]:text-example",
|
|
619
|
+
quote: "border-quote/50 bg-quote/5 text-foreground [&>svg]:text-quote",
|
|
620
|
+
};
|
|
621
|
+
const COLLAPSIBLE = Object.fromEntries(Object.keys(ALERT_VARIANT).map((v) => [v, { border: `border-${v}/50`, summary: `bg-${v}/10`, icon: `text-${v}`, body: `bg-${v}/3` }]));
|
|
622
|
+
function calloutLabel(type, ctx) {
|
|
623
|
+
return ctx.t(`callout.${type}`) || CALLOUTS[type]?.label || type.charAt(0).toUpperCase() + type.slice(1);
|
|
624
|
+
}
|
|
625
|
+
function callout(n, ctx) {
|
|
626
|
+
const p = (n.props ?? {});
|
|
627
|
+
const type = (str(p.type) ?? "note").toLowerCase();
|
|
628
|
+
const variant = ALERT_VARIANT[VARIANT_ALIAS[type] ?? type] ? VARIANT_ALIAS[type] ?? type : "note";
|
|
629
|
+
const title = str(p.title);
|
|
630
|
+
const titleHtml = title ? renderInline(parseInline(title), ctx) : esc(calloutLabel(type, ctx));
|
|
631
|
+
const body = renderBlocks(n.children, ctx);
|
|
632
|
+
if (p.collapsible === true || p.collapsible === "true" || p.collapsed != null) {
|
|
633
|
+
const open = p.collapsed === false || p.collapsed === "false" || p.open != null;
|
|
634
|
+
return collapsibleAlert(variant, titleHtml, body, open, p);
|
|
635
|
+
}
|
|
636
|
+
return (`<div${attrs({ ...passthrough(p, `${ALERT_BASE} ${ALERT_VARIANT[variant]} my-4`), "data-variant": variant, "data-type": type, role: "note" })}>` +
|
|
637
|
+
(ALERT_ICONS[variant] ?? "").replace("<svg ", '<svg aria-hidden="true" focusable="false" ') +
|
|
638
|
+
`<div data-part="title" class="mb-1 text-sm font-semibold leading-none tracking-tight">${titleHtml}</div>` +
|
|
639
|
+
`<div class="text-sm [&_p]:leading-relaxed">${body}</div></div>\n`);
|
|
640
|
+
}
|
|
641
|
+
function collapsibleAlert(variant, titleHtml, body, open, p) {
|
|
642
|
+
const c = COLLAPSIBLE[variant] ?? COLLAPSIBLE.note;
|
|
643
|
+
const icon = (ALERT_ICONS[variant] ?? "").replace("<svg ", '<svg aria-hidden="true" focusable="false" ');
|
|
644
|
+
return (`<details${attrs({ ...passthrough(p, `group/collapsible relative my-4 w-full overflow-hidden rounded-lg border [&>summary]:list-none [&>summary::-webkit-details-marker]:hidden ${c.border}`), "data-variant": variant, open })}>` +
|
|
645
|
+
`<summary class="flex cursor-pointer select-none items-center gap-2 px-4 py-3 text-sm font-semibold ${c.summary}"><span class="shrink-0 [&>svg]:size-4 ${c.icon}">${icon}</span><span class="flex-1 text-foreground" data-part="title">${titleHtml}</span><span class="shrink-0 text-muted-foreground transition-transform duration-200 group-open/collapsible:rotate-180">${ALERT_CHEVRON.replace("<svg ", '<svg aria-hidden="true" focusable="false" ')}</span></summary>` +
|
|
646
|
+
`<div class="px-4 py-3 text-sm [&_p]:leading-relaxed ${c.body}">${body}</div></details>\n`);
|
|
647
|
+
}
|
|
648
|
+
// ─── Steps (Steps.astro; CSS in steps.css) ───────────────────────────
|
|
649
|
+
// ─── Avatar (Avatar.astro) and example (MarkdownExample.astro) ──────
|
|
650
|
+
const AVATAR = "relative inline-flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted";
|
|
651
|
+
const AVATAR_FALLBACK = "flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-muted-foreground";
|
|
652
|
+
function avatar(p, ctx) {
|
|
653
|
+
const src = str(p.src);
|
|
654
|
+
const alt = str(p.alt) ?? "";
|
|
655
|
+
const initials = str(p.fallback) || alt.split(" ").map((w) => w[0] ?? "").join("").slice(0, 2).toUpperCase();
|
|
656
|
+
const { src: _s, alt: _a, fallback: _f, ...rest } = p;
|
|
657
|
+
// As Avatar.astro: the initials stand in when the image fails to load.
|
|
658
|
+
const inner = src
|
|
659
|
+
? `<img class="aspect-square h-full w-full object-cover" src="${esc(assetUrl(src, ctx))}" alt="${esc(alt)}" width="40" height="40" onerror="this.hidden=true;this.nextElementSibling.hidden=false"><span class="${AVATAR_FALLBACK}" aria-hidden="true" hidden>${esc(initials)}</span>`
|
|
660
|
+
: `<span class="${AVATAR_FALLBACK}" aria-hidden="true">${esc(initials)}</span>`;
|
|
661
|
+
return `<span${attrs(passthrough(rest, AVATAR))}>${inner}</span>`;
|
|
662
|
+
}
|
|
663
|
+
const CHEVRON = '<svg class="h-3 w-3 transition-transform group-open:rotate-90" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
|
|
664
|
+
/**
|
|
665
|
+
* A live example: the rendered content, then its source in a collapsed panel. The Astro component's optional
|
|
666
|
+
* plain-Markdown panel (`fallback=true`) needs a second Markdown renderer and is left out; it is reported.
|
|
667
|
+
*/
|
|
668
|
+
function example(n, ctx) {
|
|
669
|
+
const p = (n.props ?? {});
|
|
670
|
+
const title = str(p.title);
|
|
671
|
+
if (p.fallback === true || p.fallback === "true")
|
|
672
|
+
ctx.unknown.add("example fallback panel (not rendered)");
|
|
673
|
+
return (`<figure class="my-6 overflow-hidden rounded-lg border border-border bg-card">` +
|
|
674
|
+
(title ? `<figcaption class="border-b border-border bg-muted/30 px-4 py-2 text-sm font-medium">${esc(title)}</figcaption>` : "") +
|
|
675
|
+
`<div class="border-b border-border"><div class="border-b border-border bg-muted/10 px-4 py-1 text-[11px] uppercase tracking-wide text-muted-foreground">Rendered</div>` +
|
|
676
|
+
`<div${attrs({ class: "markdown-preview p-6 [&>:first-child]:mt-0 [&>:last-child]:mb-0" })}>${renderBlocks(n.children, ctx)}</div></div>` +
|
|
677
|
+
`<details class="group"><summary class="flex cursor-pointer list-none select-none items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground">${CHEVRON}<span class="uppercase tracking-wide">Source</span></summary>` +
|
|
678
|
+
`<pre class="m-0 overflow-auto border-t border-border bg-muted/20 p-4 text-xs"><code>${esc(String(p.source ?? ""))}</code></pre></details></figure>\n`);
|
|
679
|
+
}
|
|
680
|
+
function steps(n, ctx) {
|
|
681
|
+
const p = (n.props ?? {});
|
|
682
|
+
const items = (n.children ?? []).map((c) => (c.type === "step" ? block(c, ctx) : `<li>${block(c, ctx)}</li>`)).join("");
|
|
683
|
+
return `<ol${attrs({ ...passthrough(p, "steps-list"), role: "list" })}>${items}</ol>\n`;
|
|
684
|
+
}
|
|
685
|
+
// ─── Tabs (Tabs*.astro + db-tabs) ───────────────────────────────────
|
|
686
|
+
const TAB_TRIGGER = "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm";
|
|
687
|
+
const TAB_CONTENT = "px-4 py-3 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0";
|
|
688
|
+
function tabs(n, ctx) {
|
|
689
|
+
const p = (n.props ?? {});
|
|
690
|
+
const kids = (n.children ?? []).filter((c) => c.type === "tab");
|
|
691
|
+
if (!kids.length)
|
|
692
|
+
return renderBlocks(n.children, ctx);
|
|
693
|
+
const group = ++ctx.seq;
|
|
694
|
+
const used = new Set();
|
|
695
|
+
// The label itself: synced groups match by value, and a slug would merge C, C++ and C#.
|
|
696
|
+
const values = kids.map((k, i) => {
|
|
697
|
+
let v = (str(k.props?.label) ?? `Tab ${i + 1}`).trim();
|
|
698
|
+
while (used.has(v))
|
|
699
|
+
v += ` (${i + 1})`;
|
|
700
|
+
used.add(v);
|
|
701
|
+
return v;
|
|
702
|
+
});
|
|
703
|
+
const syncKey = str(p.syncKey) ?? str(p["sync-key"]) ?? str(p.group);
|
|
704
|
+
let list = "";
|
|
705
|
+
let panels = "";
|
|
706
|
+
kids.forEach((k, i) => {
|
|
707
|
+
const tid = `tab-${group}-${i + 1}`;
|
|
708
|
+
const pid = `tabpanel-${group}-${i + 1}`;
|
|
709
|
+
const sel = i === 0;
|
|
710
|
+
list += `<button data-part="trigger" data-value="${esc(values[i])}" role="tab" type="button" id="${tid}" aria-controls="${pid}" aria-selected="${sel}" tabindex="${sel ? 0 : -1}"${sel ? ' data-state="active"' : ""} class="${TAB_TRIGGER}">${esc(str(k.props?.label) ?? `Tab ${i + 1}`)}</button>`;
|
|
711
|
+
panels += `<div data-part="content" data-value="${esc(values[i])}" role="tabpanel" id="${pid}" aria-labelledby="${tid}" tabindex="0"${sel ? ' data-state="active"' : " hidden"} class="${TAB_CONTENT}">${renderBlocks(k.children, ctx)}</div>`;
|
|
712
|
+
});
|
|
713
|
+
return `<db-tabs${attrs({ ...passthrough(p, "my-4 w-full overflow-hidden rounded-lg border border-border"), "default-value": values[0], "sync-key": syncKey })}><div data-part="list" role="tablist" aria-orientation="horizontal" class="flex items-center border-b border-border bg-muted p-1 text-muted-foreground">${list}</div>${panels}</db-tabs>\n`;
|
|
714
|
+
}
|
|
715
|
+
// ─── Accordion (Accordion*.astro + db-accordion) ────────────────────
|
|
716
|
+
function accordion(n, ctx) {
|
|
717
|
+
const p = (n.props ?? {});
|
|
718
|
+
const group = ++ctx.seq;
|
|
719
|
+
const items = (n.children ?? []).filter((c) => c.type === "accordion-item");
|
|
720
|
+
const chevron = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 transition-transform duration-200" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>';
|
|
721
|
+
const html = items
|
|
722
|
+
.map((it, i) => {
|
|
723
|
+
const label = str(it.props?.label) ?? str(it.props?.title) ?? `Item ${i + 1}`;
|
|
724
|
+
const value = str(it.props?.value) ?? `${slugify(label)}-${i + 1}`;
|
|
725
|
+
const tid = `acc-${group}-${i + 1}`;
|
|
726
|
+
const cid = `accpanel-${group}-${i + 1}`;
|
|
727
|
+
return (`<div data-part="item" data-value="${esc(value)}" class="border-b">` +
|
|
728
|
+
`<button data-part="trigger" type="button" id="${tid}" aria-controls="${cid}" aria-expanded="false" class="flex w-full items-center justify-between border-0 bg-transparent py-4 text-left font-medium text-foreground transition-all hover:underline disabled:pointer-events-none disabled:opacity-50 [&[aria-expanded=true]>svg]:rotate-180">` +
|
|
729
|
+
`<span>${renderInline(parseInline(label), ctx)}</span>${chevron}</button>` +
|
|
730
|
+
`<div data-part="content" id="${cid}" role="region" aria-labelledby="${tid}" hidden class="overflow-hidden pb-4 text-sm transition-all">${renderBlocks(it.children, ctx)}</div></div>`);
|
|
731
|
+
})
|
|
732
|
+
.join("");
|
|
733
|
+
// As Accordion.astro: one open item at a time unless `type="multiple"`; `defaultValue` opens items.
|
|
734
|
+
const dv = p.defaultValue;
|
|
735
|
+
const defaultValue = Array.isArray(dv) ? dv.map(String).join(",") : str(dv);
|
|
736
|
+
const { defaultValue: _d, ...rest } = p;
|
|
737
|
+
return `<db-accordion${attrs({ ...passthrough(rest, "my-4 w-full"), type: str(p.type) ?? "single", "default-value": defaultValue })}>${html}</db-accordion>\n`;
|
|
738
|
+
}
|
|
739
|
+
// `:::details` renders as a CollapsibleAlert, as in the Astro serializer.
|
|
740
|
+
function details(n, ctx) {
|
|
741
|
+
const p = (n.props ?? {});
|
|
742
|
+
const title = str(p.title) ?? str(p.summary) ?? ctx.t("details");
|
|
743
|
+
// `variant` as documented, then `type`, as the Astro pipeline reads it.
|
|
744
|
+
const type = (str(p.variant) ?? str(p.type) ?? "").toLowerCase();
|
|
745
|
+
const variant = ALERT_VARIANT[VARIANT_ALIAS[type] ?? type] ? VARIANT_ALIAS[type] ?? type : "note";
|
|
746
|
+
return collapsibleAlert(variant, renderInline(parseInline(title), ctx), renderBlocks(n.children, ctx), p.open != null && p.open !== false, p);
|
|
747
|
+
}
|
|
748
|
+
function figure(n, ctx) {
|
|
749
|
+
const p = (n.props ?? {});
|
|
750
|
+
const caption = str(p.caption);
|
|
751
|
+
const kids = n.children ?? [];
|
|
752
|
+
const body = kids.length === 1 && kids[0].type === "paragraph" && kids[0].inline?.every((i) => i.type === "image" || (i.type === "text" && !i.text.trim()))
|
|
753
|
+
? renderInline(kids[0].inline, ctx)
|
|
754
|
+
: renderBlocks(kids, ctx);
|
|
755
|
+
return `<figure${attrs(passthrough(p, "my-4"))}>${body}${caption ? `<figcaption class="mt-2 text-center text-sm text-muted-foreground">${renderInline(parseInline(caption), ctx)}</figcaption>` : ""}</figure>\n`;
|
|
756
|
+
}
|
|
757
|
+
// ─── Cards (Card.astro, LinkCard.astro) ─────────────────────────────
|
|
758
|
+
/** Render `fn` as the content of a link (or not), so images inside links are not made zoomable. */
|
|
759
|
+
function insideLink(yes, ctx, fn) {
|
|
760
|
+
if (!yes)
|
|
761
|
+
return fn();
|
|
762
|
+
const was = ctx.inLink;
|
|
763
|
+
ctx.inLink = true;
|
|
764
|
+
try {
|
|
765
|
+
return fn();
|
|
766
|
+
}
|
|
767
|
+
finally {
|
|
768
|
+
ctx.inLink = was;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* An icon by name. With a resolver (the Node CLI): Icon.astro's markup around the SVG or emoji @dogsbay/icons
|
|
773
|
+
* gives, so pages match the Astro pipeline's. Without one: the built-in Lucide set. An icon neither knows is
|
|
774
|
+
* recorded for a build warning and leaves a marked, empty span.
|
|
775
|
+
*/
|
|
776
|
+
function namedIcon(name, cls, ctx) {
|
|
777
|
+
const resolved = ctx.icons?.(name);
|
|
778
|
+
if (resolved)
|
|
779
|
+
return `<span class="inline-flex shrink-0 ${cls}" aria-hidden="true" data-icon="${esc(name)}">${resolved.startsWith("<") ? resolved : esc(resolved)}</span>`;
|
|
780
|
+
const builtin = name.replace(/^lucide:/, "");
|
|
781
|
+
if (hasIcon(builtin))
|
|
782
|
+
return icon(builtin, cls);
|
|
783
|
+
(ctx.missingIcons ??= new Set()).add(name);
|
|
784
|
+
return `<span class="db-icon-missing" data-icon="${esc(name)}"></span>`;
|
|
785
|
+
}
|
|
786
|
+
/** The `:::files` list as file-tree entries: a folder has entries or a name ending in "/". */
|
|
787
|
+
function fileTreeItems(list, ctx) {
|
|
788
|
+
return (list?.children ?? [])
|
|
789
|
+
.filter((li) => li.type === "list-item")
|
|
790
|
+
.map((li) => {
|
|
791
|
+
const inline = li.inline ?? (li.children ?? []).find((c) => c.type === "paragraph")?.inline ?? [];
|
|
792
|
+
const sub = (li.children ?? []).find((c) => c.type === "unordered-list" || c.type === "ordered-list");
|
|
793
|
+
// The name is the entry's text and code spans, as the Astro pipeline reads it (link text does not count).
|
|
794
|
+
const name = inline.map((n) => (n.type === "text" || n.type === "code" ? n.text : "")).join("");
|
|
795
|
+
return { label: renderInline(inline, ctx), folder: !!sub || /\/\s*$/.test(name), children: fileTreeItems(sub, ctx) };
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
function card(n, ctx) {
|
|
799
|
+
const p = (n.props ?? {});
|
|
800
|
+
const title = str(p.title);
|
|
801
|
+
const href = str(p.href);
|
|
802
|
+
const desc = str(p.description);
|
|
803
|
+
const ic = str(p.icon);
|
|
804
|
+
// An image path is shown as an image, as the Astro pipeline's cardIconHtml does.
|
|
805
|
+
const iconHtml = !ic ? "" : /\.(svg|png|jpe?g|webp|avif)$/i.test(ic) ? `<img src="${esc(assetUrl(ic, ctx))}" alt="" class="size-5 mb-2" loading="lazy">` : namedIcon(ic, "mb-2 size-5 text-muted-foreground", ctx);
|
|
806
|
+
const inner = insideLink(!!href, ctx, () => iconHtml +
|
|
807
|
+
(title ? `<h3 class="m-0 border-0 p-0 text-base font-semibold">${renderInline(parseInline(title), ctx)}</h3>` : "") +
|
|
808
|
+
(desc ? `<p class="m-0 mt-1 text-sm text-muted-foreground [text-decoration:none]">${renderInline(parseInline(desc), ctx)}</p>` : "") +
|
|
809
|
+
// Body blocks in the same description wrapper as the Astro pipeline's card (a <div>: they carry their own <p>).
|
|
810
|
+
(n.children?.length ? `<div class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${renderBlocks(n.children, ctx)}</div>` : ""));
|
|
811
|
+
const cls = `rounded-lg border bg-card p-4 text-card-foreground shadow-sm${href ? " transition-colors hover:border-foreground/20" : ""}`;
|
|
812
|
+
return href
|
|
813
|
+
? `<div${attrs(passthrough(p, cls))}><div class="p-0"><a href="${esc(linkUrl(href, ctx))}" class="block text-foreground [text-decoration:none]">${inner}</a></div></div>`
|
|
814
|
+
: `<div${attrs(passthrough(p, cls))}><div class="p-0">${inner}</div></div>`;
|
|
815
|
+
}
|
|
816
|
+
function linkCard(n, ctx) {
|
|
817
|
+
// A body of more than one paragraph stays as children (the parser folds a single paragraph into
|
|
818
|
+
// the description). The Astro pipeline renders that as a linked card (standaloneCardToAstro); so
|
|
819
|
+
// does this, where the body used to be dropped.
|
|
820
|
+
if (n.children?.length)
|
|
821
|
+
return card(n, ctx);
|
|
822
|
+
const p = (n.props ?? {});
|
|
823
|
+
const title = str(p.title) ?? "";
|
|
824
|
+
const href = str(p.href) ?? "#";
|
|
825
|
+
const desc = str(p.description);
|
|
826
|
+
const chevron = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg>';
|
|
827
|
+
const ic = str(p.icon);
|
|
828
|
+
const iconHtml = ic ? namedIcon(ic, "size-4 shrink-0 text-muted-foreground", ctx) : "";
|
|
829
|
+
return `<a${attrs({ ...passthrough(p, "group not-prose my-4 flex flex-col gap-1 rounded-lg border bg-card p-4 text-card-foreground no-underline shadow-sm transition-colors hover:bg-accent/50"), href: linkUrl(href, ctx) })}><div class="flex items-center justify-between"><span class="flex min-w-0 items-center gap-2">${iconHtml}<span class="text-sm font-semibold group-hover:underline">${insideLink(true, ctx, () => renderInline(parseInline(title), ctx))}</span></span>${chevron}</div>${desc ? `<p class="m-0 text-sm text-muted-foreground">${insideLink(true, ctx, () => renderInline(parseInline(desc), ctx))}</p>` : ""}</a>`;
|
|
830
|
+
}
|