@dogsbay/ui 0.2.0-beta.12 → 0.2.0-beta.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -12
- package/src/alert/CollapsibleAlert.astro +19 -3
- package/src/api-layout/ApiLayout.astro +28 -4
- package/src/api-layout/ExampleBlock.astro +3 -2
- package/src/api-symbol/api-symbol.css +14 -11
- package/src/badge/Badge.astro +3 -10
- package/src/badge/variants.ts +37 -0
- package/src/code-block/CodeBlock.astro +38 -8
- package/src/code-rich/CodeRich.astro +93 -15
- package/src/code-rich/code-rich.css +21 -2
- package/src/code-rich/markers.ts +66 -0
- package/src/content-renderer/ContentRenderer.astro +47 -6
- package/src/content-renderer/InlineRenderer.astro +24 -1
- package/src/docs-layout/DocsLayout.astro +36 -4
- package/src/endpoint-card/EndpointCard.astro +59 -1
- package/src/files/FileTree.astro +16 -0
- package/src/files/index.ts +2 -0
- package/src/files/markup.ts +34 -0
- package/src/image/Image.astro +23 -2
- package/src/kbd/Kbd.astro +2 -3
- package/src/kbd/variants.ts +3 -0
- package/src/link-card/LinkCard.astro +8 -2
- package/src/property-list/PropertyList.astro +3 -2
- package/src/sidebar/SidebarNavMark.astro +85 -0
- package/src/sidebar/SidebarNavTree.astro +111 -7
- package/src/sidebar/sidebar.ts +2 -2
- package/src/table/Table.astro +16 -2
- package/src/table/TableCell.astro +1 -1
- package/src/table/TableHead.astro +1 -1
- package/src/toc/TableOfContents.astro +10 -10
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `[!code …]` annotation hygiene.
|
|
3
|
+
*
|
|
4
|
+
* Shiki's notation transformers consume these markers — but only the
|
|
5
|
+
* ones they can SEE: a marker is recognized when the grammar tokenizes
|
|
6
|
+
* its comment AND the marker ends the line. Anything else reaches the
|
|
7
|
+
* reader as literal text in the code block. Two real cases from the
|
|
8
|
+
* better-auth corpus:
|
|
9
|
+
*
|
|
10
|
+
* - `package-install` (and every unknown language) falls back to
|
|
11
|
+
* `plaintext`, which has no comment tokens at all;
|
|
12
|
+
* - upstream authors mid-line markers — `// [!code highlight] // check
|
|
13
|
+
* if the user is allowed` — where the marker is not the last thing
|
|
14
|
+
* on the line.
|
|
15
|
+
*
|
|
16
|
+
* These helpers strip exactly what Shiki cannot process, and strip
|
|
17
|
+
* everything from copy text (a marker pasted into an editor is always
|
|
18
|
+
* wrong).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A `[!code …]` annotation carrying its own comment leader. */
|
|
22
|
+
const MARKER = /[ \t]*(?:\/\/|#|--|<!--|\/\*|;)[ \t]*\[!code[^\]]*\](?:[ \t]*(?:-->|\*\/))?/g;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A bare annotation — upstream also appends one INSIDE an existing
|
|
26
|
+
* comment (`// e.g. "us-east-1" [!code highlight]`), with no leader of
|
|
27
|
+
* its own.
|
|
28
|
+
*/
|
|
29
|
+
const BARE_MARKER = /[ \t]*\[!code[^\]]*\]/g;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Remove the markers Shiki cannot process, leaving the ones it can for
|
|
33
|
+
* the notation transformers to consume.
|
|
34
|
+
*
|
|
35
|
+
* @param code the block's source
|
|
36
|
+
* @param isPlainGrammar true when highlighting falls back to plaintext
|
|
37
|
+
*/
|
|
38
|
+
export function stripUnprocessableMarkers(code: string, isPlainGrammar: boolean): string {
|
|
39
|
+
// Every marker has "[!code"; without one there is nothing to strip, and MARKER's leading [ \t]* is quadratic on a long run of spaces.
|
|
40
|
+
if (!code.includes("[!code")) return code;
|
|
41
|
+
return code
|
|
42
|
+
.split("\n")
|
|
43
|
+
.map((line) => {
|
|
44
|
+
// No grammar → no comment tokens → Shiki sees nothing to strip.
|
|
45
|
+
if (isPlainGrammar) return line.replace(MARKER, "").replace(BARE_MARKER, "");
|
|
46
|
+
let out = "";
|
|
47
|
+
let last = 0;
|
|
48
|
+
for (const m of line.matchAll(MARKER)) {
|
|
49
|
+
const end = m.index + m[0].length;
|
|
50
|
+
if (line.slice(end).trim() === "") break; // ends the line — Shiki handles it
|
|
51
|
+
out += line.slice(last, m.index);
|
|
52
|
+
last = end;
|
|
53
|
+
}
|
|
54
|
+
return out + line.slice(last);
|
|
55
|
+
})
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Strip EVERY annotation form — markers must never reach the clipboard. */
|
|
60
|
+
export function stripMarkersForCopy(code: string): string {
|
|
61
|
+
return code
|
|
62
|
+
.replace(MARKER, "")
|
|
63
|
+
.replace(BARE_MARKER, "")
|
|
64
|
+
.replace(/\s*\/\/\s*![a-z]+.*$/gm, "")
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* api-params, api-symbol, api-doc, api-source, youtube, image, grid
|
|
30
30
|
*/
|
|
31
31
|
import Alert from "../alert/Alert.astro";
|
|
32
|
+
import { badgeClass } from "../badge/variants.js";
|
|
33
|
+
|
|
34
|
+
/** Attribute-safe text (also safe as element text). */
|
|
35
|
+
const escAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
32
36
|
import AlertTitle from "../alert/AlertTitle.astro";
|
|
33
37
|
import AlertDescription from "../alert/AlertDescription.astro";
|
|
34
38
|
import CollapsibleAlert from "../alert/CollapsibleAlert.astro";
|
|
@@ -126,8 +130,9 @@ function inlineToHtml(nodes: InlineNode[]): string {
|
|
|
126
130
|
case "code": return `<code>${n.text || ""}</code>`;
|
|
127
131
|
case "highlight": return `<mark>${inlineToHtml((n.children || []) as InlineNode[])}</mark>`;
|
|
128
132
|
case "link": return `<a href="${n.href || ""}">${inlineToHtml((n.children || []) as InlineNode[])}</a>`;
|
|
129
|
-
case "image": return `<img src="${n.src
|
|
133
|
+
case "image": return `<img src="${withBaseUrl(n.src as string | undefined)}" alt="${n.alt || ""}">`;
|
|
130
134
|
case "kbd": return (n.keys as string[] || []).map((k: string) => `<kbd>${k}</kbd>`).join("+");
|
|
135
|
+
case "badge": return `<span class="${badgeClass(n.variant as string | undefined)}" data-variant="${escAttr(String(n.variant || "default"))}">${escAttr(String(n.text ?? ""))}</span>`;
|
|
131
136
|
case "html-inline": return String(n.html || "");
|
|
132
137
|
case "break": return "<br>";
|
|
133
138
|
default: return "";
|
|
@@ -142,6 +147,26 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
142
147
|
const clean = normalized.replace(/#.*$/, "");
|
|
143
148
|
return images[clean];
|
|
144
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Prefix Astro's `base` config (= urlBase from site.url's path)
|
|
153
|
+
* onto absolute image paths so they resolve on subpath-mounted
|
|
154
|
+
* deploys. Skip for external / data: / already-prefixed URLs.
|
|
155
|
+
* See plans/content-assets-folder.md.
|
|
156
|
+
*/
|
|
157
|
+
function withBaseUrl(src: string | undefined): string {
|
|
158
|
+
if (!src) return "";
|
|
159
|
+
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
160
|
+
if (
|
|
161
|
+
!baseUrl ||
|
|
162
|
+
!src.startsWith("/") ||
|
|
163
|
+
src.startsWith(`${baseUrl}/`) ||
|
|
164
|
+
/^(?:[a-z]+:|\/\/)/i.test(src)
|
|
165
|
+
) {
|
|
166
|
+
return src;
|
|
167
|
+
}
|
|
168
|
+
return `${baseUrl}${src}`;
|
|
169
|
+
}
|
|
145
170
|
---
|
|
146
171
|
|
|
147
172
|
{/* ── Prose ──────────────────────────────────────────────── */}
|
|
@@ -157,7 +182,7 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
157
182
|
<Fragment set:html={
|
|
158
183
|
`<h${node.props!.level} id="${node.props!.slug || ''}" class="group scroll-mt-20">` +
|
|
159
184
|
`${node.inline ? inlineToHtml(node.inline) : (node.html?.replace(/<h\d[^>]*>|<\/h\d>/g, '') || node.props!.text || '')}` +
|
|
160
|
-
`<a href="#${node.props!.slug || ''}" class="no-underline ml-2 opacity-0 group-hover:opacity-50 transition-opacity text-muted-foreground" aria-label="Link to this heading">¶</a>` +
|
|
185
|
+
`<a href="#${node.props!.slug || ''}" class="heading-anchor no-underline ml-2 opacity-0 group-hover:opacity-50 transition-opacity text-muted-foreground" aria-label="Link to this heading">¶</a>` +
|
|
161
186
|
`</h${node.props!.level}>`
|
|
162
187
|
} />
|
|
163
188
|
)}
|
|
@@ -275,7 +300,18 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
275
300
|
{/* ── Table ──────────────────────────────────────────────── */}
|
|
276
301
|
{node.type === "table" && (
|
|
277
302
|
<Table class="my-4">
|
|
278
|
-
{
|
|
303
|
+
{(node.props?.headerRows as string[][] | undefined)?.length ? (
|
|
304
|
+
<TableHeader>
|
|
305
|
+
{(node.props!.headerRows as string[][]).map((row) => (
|
|
306
|
+
<TableRow>{row.map((cell) => <TableHead set:html={cell} />)}</TableRow>
|
|
307
|
+
))}
|
|
308
|
+
</TableHeader>
|
|
309
|
+
) : null}
|
|
310
|
+
<TableBody>
|
|
311
|
+
{((node.props?.bodyRows as string[][] | undefined) ?? []).map((row) => (
|
|
312
|
+
<TableRow>{row.map((cell) => <TableCell set:html={cell} />)}</TableRow>
|
|
313
|
+
))}
|
|
314
|
+
</TableBody>
|
|
279
315
|
</Table>
|
|
280
316
|
)}
|
|
281
317
|
{node.type === "thead" && <TableHeader>{children.map(child => <Astro.self node={child} images={images} icons={icons} />)}</TableHeader>}
|
|
@@ -296,11 +332,14 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
296
332
|
{node.type === "hr" && <hr class="my-8 border-border" />}
|
|
297
333
|
{node.type === "html" && (() => {
|
|
298
334
|
let html = node.html || "";
|
|
299
|
-
if (html.includes("<img")
|
|
335
|
+
if (html.includes("<img")) {
|
|
300
336
|
html = html.replace(/src="([^"]+)"/g, (_match: string, src: string) => {
|
|
301
337
|
const resolved = resolveImage(src);
|
|
302
338
|
if (resolved) return `src="${resolved.src}" width="${resolved.width}" height="${resolved.height}"`;
|
|
303
|
-
|
|
339
|
+
// Not in the glob (SVG / PDF / build-without-optimization) —
|
|
340
|
+
// prefix BASE_URL so absolute paths resolve on subpath deploys.
|
|
341
|
+
const prefixed = withBaseUrl(src);
|
|
342
|
+
return prefixed === src ? _match : `src="${prefixed}"`;
|
|
304
343
|
});
|
|
305
344
|
}
|
|
306
345
|
return <Fragment set:html={html} />;
|
|
@@ -353,7 +392,9 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
353
392
|
<li id={(node.props?.id as string) || ""} class="text-sm text-muted-foreground">
|
|
354
393
|
{node.inline
|
|
355
394
|
? <span>{node.inline.map(span => <InlineRenderer node={span} icons={icons} images={images} />)}</span>
|
|
356
|
-
:
|
|
395
|
+
: children.length > 0
|
|
396
|
+
? children.map(child => <Astro.self node={child} images={images} icons={icons} />)
|
|
397
|
+
: <Fragment set:html={node.html} />
|
|
357
398
|
}
|
|
358
399
|
{" "}<a href={`#${node.props?.backrefId || ""}`} class="text-primary hover:underline" aria-label={`Back to reference ${node.props?.label}`}>↩</a>
|
|
359
400
|
</li>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* InlineRenderer — renders structured InlineNode[] to HTML elements.
|
|
4
4
|
*/
|
|
5
5
|
import MathKatex from "../math/MathKatex.astro";
|
|
6
|
+
import Badge from "../badge/Badge.astro";
|
|
6
7
|
|
|
7
8
|
interface InlineNode {
|
|
8
9
|
type: string;
|
|
@@ -47,6 +48,24 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
47
48
|
|
|
48
49
|
const resolvedImg = node.type === "image" ? resolveImage(node.src as string || "") : undefined;
|
|
49
50
|
|
|
51
|
+
// When the image isn't in the glob map (SVG, PDF, or built with
|
|
52
|
+
// imageOptimization: false), the `<img src={node.src}>` fallback
|
|
53
|
+
// fires below. Prefix Astro's `base` for absolute paths so they
|
|
54
|
+
// resolve on subpath-mounted deploys. See plans/content-assets-folder.md.
|
|
55
|
+
function withBaseUrl(src: string | undefined): string {
|
|
56
|
+
if (!src) return "";
|
|
57
|
+
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
58
|
+
if (
|
|
59
|
+
!baseUrl ||
|
|
60
|
+
!src.startsWith("/") ||
|
|
61
|
+
src.startsWith(`${baseUrl}/`) ||
|
|
62
|
+
/^(?:[a-z]+:|\/\/)/i.test(src)
|
|
63
|
+
) {
|
|
64
|
+
return src;
|
|
65
|
+
}
|
|
66
|
+
return `${baseUrl}${src}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
// Icon resolution
|
|
51
70
|
const iconSvg = node.type === "icon" && icons
|
|
52
71
|
? icons.resolve(node.library || "emoji", node.name, node.variant, node.size) as string | null
|
|
@@ -90,7 +109,7 @@ const isEmojiIcon = node.type === "icon" && (node.library === "emoji" || (iconSv
|
|
|
90
109
|
)}
|
|
91
110
|
{node.type === "image" && !resolvedImg && (
|
|
92
111
|
<img
|
|
93
|
-
src={node.src}
|
|
112
|
+
src={withBaseUrl(node.src as string | undefined)}
|
|
94
113
|
alt={node.alt || ""}
|
|
95
114
|
title={node.title}
|
|
96
115
|
width={node.width}
|
|
@@ -117,6 +136,10 @@ const isEmojiIcon = node.type === "icon" && (node.library === "emoji" || (iconSv
|
|
|
117
136
|
</a>
|
|
118
137
|
)}
|
|
119
138
|
|
|
139
|
+
{node.type === "badge" && (
|
|
140
|
+
<Badge variant={(node as { variant?: string }).variant ?? "default"}>{node.text}</Badge>
|
|
141
|
+
)}
|
|
142
|
+
|
|
120
143
|
{node.type === "kbd" && (
|
|
121
144
|
<span class="inline-flex items-center gap-0.5">
|
|
122
145
|
{(node.keys || []).map((key, i) => (
|
|
@@ -99,10 +99,33 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
|
|
99
99
|
<title>{title} — {siteTitle}</title>
|
|
100
100
|
<ClientRouter />
|
|
101
101
|
<script is:inline>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
// Explicit choice wins in BOTH directions; only its absence
|
|
103
|
+
// falls through to the OS. Kept in sync with
|
|
104
|
+
// packages/docs-layout/src/DocsLayout.astro — a test asserts no
|
|
105
|
+
// copy regresses to the localStorage-only form.
|
|
106
|
+
(function () {
|
|
107
|
+
var stored = null;
|
|
108
|
+
try {
|
|
109
|
+
stored = localStorage.getItem("theme");
|
|
110
|
+
} catch (e) {}
|
|
111
|
+
var dark =
|
|
112
|
+
stored === "dark" ||
|
|
113
|
+
(stored !== "light" &&
|
|
114
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
115
|
+
if (dark) document.documentElement.classList.add("dark");
|
|
116
|
+
})();
|
|
105
117
|
</script>
|
|
118
|
+
<style is:global>
|
|
119
|
+
/*
|
|
120
|
+
The sticky-chrome contract. The docs header is exactly this tall,
|
|
121
|
+
and anything else that sticks (the comparison toolbar) offsets by
|
|
122
|
+
it — one variable, so a header change can never leave another bar
|
|
123
|
+
overlapping or floating.
|
|
124
|
+
*/
|
|
125
|
+
:root {
|
|
126
|
+
--db-header-height: 3.25rem;
|
|
127
|
+
}
|
|
128
|
+
</style>
|
|
106
129
|
<slot name="head" />
|
|
107
130
|
</head>
|
|
108
131
|
<body class="min-h-screen bg-background text-foreground antialiased">
|
|
@@ -131,7 +154,16 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
|
|
131
154
|
<SidebarRail />
|
|
132
155
|
</Sidebar>
|
|
133
156
|
<SidebarInset>
|
|
134
|
-
|
|
157
|
+
{/*
|
|
158
|
+
The header's height comes from `--db-header-height` (declared on
|
|
159
|
+
:root below) — the CONTRACT that any other sticky chrome sticks
|
|
160
|
+
below, e.g. the comparison toolbar. Both read the same variable,
|
|
161
|
+
so they cannot drift; a magic offset copied into another
|
|
162
|
+
stylesheet would break the moment this padding changed.
|
|
163
|
+
*/}
|
|
164
|
+
<header
|
|
165
|
+
class="sticky top-0 z-40 flex h-[var(--db-header-height)] items-center gap-3 border-b border-border bg-background/95 px-4 backdrop-blur-sm"
|
|
166
|
+
>
|
|
135
167
|
<SidebarTrigger />
|
|
136
168
|
<Separator orientation="vertical" class="h-5" />
|
|
137
169
|
<span class="text-sm font-medium" data-page-title>{title}</span>
|
|
@@ -51,10 +51,68 @@ const { method, path, baseUrl, summary, description, deprecated = false, class:
|
|
|
51
51
|
|
|
52
52
|
{summary && <p class="text-sm text-muted-foreground">{summary}</p>}
|
|
53
53
|
{description && description !== summary && (
|
|
54
|
-
<div class="text-sm text-muted-foreground" set:html={description} />
|
|
54
|
+
<div class="api-description text-sm text-muted-foreground" set:html={description} />
|
|
55
55
|
)}
|
|
56
56
|
|
|
57
57
|
<div class="space-y-3">
|
|
58
58
|
<slot />
|
|
59
59
|
</div>
|
|
60
60
|
</div>
|
|
61
|
+
|
|
62
|
+
<style is:global>
|
|
63
|
+
/* An operation's description is Markdown rendered to HTML (lists, code,
|
|
64
|
+
paragraphs), placed where the page's prose styles do not reach: without
|
|
65
|
+
these a bulleted field list reads as loose lines. */
|
|
66
|
+
.api-description > * + * {
|
|
67
|
+
margin-top: 0.5rem;
|
|
68
|
+
}
|
|
69
|
+
.api-description ul {
|
|
70
|
+
list-style: disc;
|
|
71
|
+
padding-left: 1.25rem;
|
|
72
|
+
}
|
|
73
|
+
.api-description ol {
|
|
74
|
+
list-style: decimal;
|
|
75
|
+
padding-left: 1.25rem;
|
|
76
|
+
}
|
|
77
|
+
.api-description li + li,
|
|
78
|
+
.api-description li > ul,
|
|
79
|
+
.api-description li > ol {
|
|
80
|
+
margin-top: 0.125rem;
|
|
81
|
+
}
|
|
82
|
+
.api-description ul ul {
|
|
83
|
+
list-style: circle;
|
|
84
|
+
}
|
|
85
|
+
/* Field names (`id`, `scm_type`) stand out in the JSON key colour,
|
|
86
|
+
on a faint tint of it. Override --api-field in the theme to change it; a site
|
|
87
|
+
scaffolded before the token existed gets --api-post, the same blue. */
|
|
88
|
+
.api-description code {
|
|
89
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
90
|
+
font-size: 0.85em;
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
color: var(--api-field, var(--api-post, var(--foreground)));
|
|
93
|
+
background-color: color-mix(in oklab, var(--api-field, var(--api-post, var(--foreground))) 8%, transparent);
|
|
94
|
+
border-radius: 0.25rem;
|
|
95
|
+
padding: 0.0625rem 0.3rem;
|
|
96
|
+
}
|
|
97
|
+
/* A code block in a description (a JSON shape, `?order_by=`) is one
|
|
98
|
+
full-width block, as Swagger UI shows it: the chip styling above,
|
|
99
|
+
applied per line, read as a stack of separate highlights. */
|
|
100
|
+
.api-description pre {
|
|
101
|
+
overflow-x: auto;
|
|
102
|
+
border-radius: 0.375rem;
|
|
103
|
+
padding: 0.5rem 0.75rem;
|
|
104
|
+
background-color: var(--code-background, color-mix(in oklab, var(--foreground) 6%, transparent));
|
|
105
|
+
}
|
|
106
|
+
.api-description pre code {
|
|
107
|
+
display: block;
|
|
108
|
+
padding: 0;
|
|
109
|
+
border-radius: 0;
|
|
110
|
+
background: none;
|
|
111
|
+
white-space: pre;
|
|
112
|
+
}
|
|
113
|
+
.api-description a {
|
|
114
|
+
color: var(--primary);
|
|
115
|
+
text-decoration: underline;
|
|
116
|
+
text-underline-offset: 2px;
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FileTree — the `:::files` directive: a nested list shown as folders and files.
|
|
4
|
+
* The markup is fileTreeHtml (markup.ts), shared with dogsbay-ssg.
|
|
5
|
+
*/
|
|
6
|
+
import { fileTreeHtml, type FileTreeItem } from "./markup.js";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
items: FileTreeItem[];
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { items, class: className } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<Fragment set:html={fileTreeHtml(items, className ?? "")} />
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `:::files` tree, written once for both pipelines: FileTree.astro renders it,
|
|
3
|
+
* and dogsbay-ssg copies this file (scripts/sync-astro-styles.mjs) and calls it.
|
|
4
|
+
* Each pipeline turns an entry's inline Markdown into `label` HTML itself.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface FileTreeItem {
|
|
8
|
+
/** The entry's label as HTML (already escaped). */
|
|
9
|
+
label: string;
|
|
10
|
+
/** A folder: it has entries, or its name ends in "/". */
|
|
11
|
+
folder: boolean;
|
|
12
|
+
children: FileTreeItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const svg = (body: string) =>
|
|
16
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" focusable="false">${body}</svg>`;
|
|
17
|
+
// Lucide folder and file (ISC licence).
|
|
18
|
+
const FOLDER = svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>');
|
|
19
|
+
const FILE = svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>');
|
|
20
|
+
|
|
21
|
+
function list(items: FileTreeItem[], nested: boolean): string {
|
|
22
|
+
const cls = nested ? "m-0 ml-2 list-none border-l border-border pl-4" : "m-0 list-none p-0";
|
|
23
|
+
return `<ul class="${cls}">${items
|
|
24
|
+
.map(
|
|
25
|
+
(it) =>
|
|
26
|
+
`<li class="my-0.5" data-kind="${it.folder ? "folder" : "file"}"><span class="inline-flex items-center gap-1.5">${it.folder ? FOLDER : FILE}<span>${it.label}</span></span>${it.children.length ? list(it.children, true) : ""}</li>`,
|
|
27
|
+
)
|
|
28
|
+
.join("")}</ul>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The whole tree, in a bordered, monospaced box. */
|
|
32
|
+
export function fileTreeHtml(items: FileTreeItem[], extraClass = ""): string {
|
|
33
|
+
return `<div class="not-prose my-4 overflow-x-auto rounded-lg border bg-card p-3 font-mono text-sm${extraClass ? ` ${extraClass}` : ""}" data-component="files">${list(items, false)}</div>`;
|
|
34
|
+
}
|
package/src/image/Image.astro
CHANGED
|
@@ -47,6 +47,27 @@ const captionText = caption || title;
|
|
|
47
47
|
const w = width || (imageData ? imageData.width : undefined);
|
|
48
48
|
const h = height || (imageData ? imageData.height : undefined);
|
|
49
49
|
|
|
50
|
+
// When `imageData` is unavailable (image not in import.meta.glob —
|
|
51
|
+
// e.g. SVG, PDF, or build-time `imageOptimization: false`), the
|
|
52
|
+
// `<img src={src}>` fallback fires with the raw markdown src. For
|
|
53
|
+
// absolute paths like `/_assets/foo.png`, we need to prefix with
|
|
54
|
+
// Astro's `base` so the URL resolves on subpath-mounted deploys
|
|
55
|
+
// (GH Pages project pages, multi-mount Cloudflare).
|
|
56
|
+
//
|
|
57
|
+
// `<AstroImage>` does this automatically; raw `<img>` does not.
|
|
58
|
+
//
|
|
59
|
+
// Skip prefixing for: external URLs (http/https), data: URIs,
|
|
60
|
+
// and paths that already start with `import.meta.env.BASE_URL`
|
|
61
|
+
// (defensive; shouldn't normally happen).
|
|
62
|
+
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
63
|
+
const resolvedSrc =
|
|
64
|
+
baseUrl &&
|
|
65
|
+
src.startsWith("/") &&
|
|
66
|
+
!src.startsWith(`${baseUrl}/`) &&
|
|
67
|
+
!/^(?:[a-z]+:|\/\/)/i.test(src)
|
|
68
|
+
? `${baseUrl}${src}`
|
|
69
|
+
: src;
|
|
70
|
+
|
|
50
71
|
const alignClass = align === "left"
|
|
51
72
|
? "float-left mr-4 mb-2"
|
|
52
73
|
: align === "right"
|
|
@@ -71,7 +92,7 @@ const alignClass = align === "left"
|
|
|
71
92
|
/>
|
|
72
93
|
) : (
|
|
73
94
|
<img
|
|
74
|
-
src={
|
|
95
|
+
src={resolvedSrc}
|
|
75
96
|
alt={alt}
|
|
76
97
|
title={title}
|
|
77
98
|
width={w}
|
|
@@ -102,7 +123,7 @@ const alignClass = align === "left"
|
|
|
102
123
|
/>
|
|
103
124
|
) : (
|
|
104
125
|
<img
|
|
105
|
-
src={
|
|
126
|
+
src={resolvedSrc}
|
|
106
127
|
alt={alt}
|
|
107
128
|
title={title}
|
|
108
129
|
width={w}
|
package/src/kbd/Kbd.astro
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { tv } from "tailwind-variants";
|
|
3
|
+
import { KBD_CLASS } from "./variants.js";
|
|
3
4
|
|
|
4
|
-
const kbd = tv({
|
|
5
|
-
base: "pointer-events-none inline-flex h-5 items-center justify-center gap-1 rounded border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground shadow-[0_1px_0_1px] shadow-muted",
|
|
6
|
-
});
|
|
5
|
+
const kbd = tv({ base: KBD_CLASS });
|
|
7
6
|
|
|
8
7
|
interface Props {
|
|
9
8
|
class?: string;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Kbd.astro's classes, shared with dogsbay-ssg (copied by scripts/sync-astro-styles.mjs). */
|
|
2
|
+
export const KBD_CLASS =
|
|
3
|
+
"pointer-events-none inline-flex h-5 items-center justify-center gap-1 rounded border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground shadow-[0_1px_0_1px] shadow-muted";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { tv } from "tailwind-variants";
|
|
3
|
+
import Icon from "../icon/Icon.astro";
|
|
3
4
|
|
|
4
5
|
const linkCard = tv({
|
|
5
6
|
base: "group flex flex-col gap-1 rounded-lg border bg-card p-4 text-card-foreground shadow-sm transition-colors hover:bg-accent/50",
|
|
@@ -17,16 +18,21 @@ interface Props {
|
|
|
17
18
|
title: string;
|
|
18
19
|
description?: string;
|
|
19
20
|
href: string;
|
|
21
|
+
/** An icon before the title: a name (`rocket`, `mdi:home`) as the Icon component takes. */
|
|
22
|
+
icon?: string;
|
|
20
23
|
class?: string;
|
|
21
24
|
[key: string]: unknown;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
const { title, description, href, class: className, ...rest } = Astro.props;
|
|
27
|
+
const { title, description, href, icon, class: className, ...rest } = Astro.props;
|
|
25
28
|
---
|
|
26
29
|
|
|
27
30
|
<a href={href} class={linkCard({ class: className })} {...rest}>
|
|
28
31
|
<div class="flex items-center justify-between">
|
|
29
|
-
<span class=
|
|
32
|
+
<span class="flex min-w-0 items-center gap-2">
|
|
33
|
+
{icon && <Icon name={icon} class="size-4 shrink-0 text-muted-foreground" />}
|
|
34
|
+
<span class={linkCardTitle()}>{title}</span>
|
|
35
|
+
</span>
|
|
30
36
|
<svg
|
|
31
37
|
xmlns="http://www.w3.org/2000/svg"
|
|
32
38
|
width="16"
|
|
@@ -47,9 +47,10 @@ const groups = locationOrder
|
|
|
47
47
|
<div class={className}>
|
|
48
48
|
{groups.map((group) => (
|
|
49
49
|
<div class="mb-4 last:mb-0">
|
|
50
|
-
|
|
50
|
+
{/* A label, not a heading: under an embed's tag h2 an h4 skipped a level (axe heading-order). */}
|
|
51
|
+
<p class="mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
51
52
|
{group.label}
|
|
52
|
-
</
|
|
53
|
+
</p>
|
|
53
54
|
<div class="rounded-lg border bg-card">
|
|
54
55
|
{group.params.map((param) => (
|
|
55
56
|
<PropertyItem
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A nav state marker (changed / new / removed / moved).
|
|
4
|
+
*
|
|
5
|
+
* Accessibility contract — the reason this is a component and not a
|
|
6
|
+
* coloured dot inlined three times:
|
|
7
|
+
*
|
|
8
|
+
* - **Never colour alone** (WCAG 1.4.1): each kind gets a distinct
|
|
9
|
+
* GLYPH (+ • − →), so it is readable in greyscale and by anyone who
|
|
10
|
+
* does not perceive the hue difference. Colour only reinforces.
|
|
11
|
+
* - **Survives forced-colors** (Windows High Contrast strips
|
|
12
|
+
* background-color): the glyph is real text, so it always renders;
|
|
13
|
+
* `currentColor` keeps it visible when the palette is overridden.
|
|
14
|
+
* - **Announced, not implied**: a visually-hidden word rides along, so
|
|
15
|
+
* the row reads "MySQL, changed". `title` alone is not enough — it is
|
|
16
|
+
* unreliable in screen readers and invisible to touch users.
|
|
17
|
+
* - **aria-hidden on the glyph** so it is not announced twice.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
kind: "added" | "changed" | "removed" | "moved";
|
|
21
|
+
/** Announced text; defaults to the kind (or "contains X" for a subtree). */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** True when this summarizes DESCENDANTS (a collapsed group). */
|
|
24
|
+
subtree?: boolean;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { kind, label, subtree = false, class: className } = Astro.props;
|
|
29
|
+
|
|
30
|
+
const GLYPH: Record<Props["kind"], string> = {
|
|
31
|
+
added: "+",
|
|
32
|
+
changed: "•",
|
|
33
|
+
removed: "−",
|
|
34
|
+
moved: "→",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const text = label ?? (subtree ? `contains ${kind}` : kind);
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<span
|
|
41
|
+
class:list={["db-nav-mark", className]}
|
|
42
|
+
data-nav-mark={kind}
|
|
43
|
+
data-nav-mark-subtree={subtree ? "" : undefined}
|
|
44
|
+
title={text}
|
|
45
|
+
>
|
|
46
|
+
<span aria-hidden="true">{GLYPH[kind]}</span>
|
|
47
|
+
<span class="sr-only">{text}</span>
|
|
48
|
+
</span>
|
|
49
|
+
|
|
50
|
+
<style is:global>
|
|
51
|
+
.db-nav-mark {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
flex-shrink: 0;
|
|
56
|
+
width: 1rem;
|
|
57
|
+
height: 1rem;
|
|
58
|
+
margin-left: auto; /* right-aligned: labels stay flush-left and scannable */
|
|
59
|
+
border-radius: 99px;
|
|
60
|
+
font-size: 0.75rem;
|
|
61
|
+
line-height: 1;
|
|
62
|
+
font-weight: 700;
|
|
63
|
+
/* colour REINFORCES the glyph; it never carries the meaning alone */
|
|
64
|
+
color: var(--color-muted-foreground, currentColor);
|
|
65
|
+
}
|
|
66
|
+
.db-nav-mark[data-nav-mark="added"] { color: var(--color-success, oklch(0.72 0.19 150)); }
|
|
67
|
+
.db-nav-mark[data-nav-mark="changed"] { color: var(--color-warning, oklch(0.8 0.16 85)); }
|
|
68
|
+
.db-nav-mark[data-nav-mark="removed"] { color: var(--color-destructive, oklch(0.64 0.21 25)); }
|
|
69
|
+
.db-nav-mark[data-nav-mark="moved"] { color: var(--color-info, oklch(0.7 0.14 240)); }
|
|
70
|
+
/* A subtree marker is a hint, not a claim about this row — mute it. */
|
|
71
|
+
.db-nav-mark[data-nav-mark-subtree] { opacity: 0.55; }
|
|
72
|
+
|
|
73
|
+
/* Removed pages get a second non-colour signal — but ONLY a page that
|
|
74
|
+
is itself removed. A subtree aggregate is a claim about descendants;
|
|
75
|
+
striking through a surviving group would tell the reader the whole
|
|
76
|
+
section was deleted. */
|
|
77
|
+
[data-nav-mark-row="removed"]:not([data-nav-mark-subtree]) {
|
|
78
|
+
text-decoration: line-through;
|
|
79
|
+
opacity: 0.7;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@media (forced-colors: active) {
|
|
83
|
+
.db-nav-mark { color: CanvasText; }
|
|
84
|
+
}
|
|
85
|
+
</style>
|