@dogsbay/ui 0.2.0-beta.10 → 0.2.0-beta.100
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 +16 -10
- package/src/alert/CollapsibleAlert.astro +19 -3
- package/src/api-layout/ApiLayout.astro +28 -4
- package/src/code-block/CodeBlock.astro +10 -1
- package/src/code-rich/CodeRich.astro +66 -8
- package/src/code-rich/code-rich.css +18 -1
- package/src/code-rich/markers.ts +64 -0
- package/src/content-renderer/ContentRenderer.astro +39 -5
- package/src/content-renderer/InlineRenderer.astro +19 -1
- package/src/docs-layout/DocsLayout.astro +36 -4
- package/src/icon/Icon.astro +84 -0
- package/src/icon/index.ts +1 -0
- package/src/image/Image.astro +23 -2
- package/src/sidebar/SidebarNavMark.astro +85 -0
- package/src/sidebar/SidebarNavTree.astro +111 -7
- package/src/table/Table.astro +4 -1
- package/src/table/TableCell.astro +1 -1
- package/src/table/TableHead.astro +1 -1
package/package.json
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/ui",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.100",
|
|
4
4
|
"description": "Accessible UI components for Astro, inspired by Base UI and shadcn",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./*": "./src/*"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"tailwind-variants": "^0.3.
|
|
10
|
+
"tailwind-variants": "^0.3.1"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"vitest": "^4.1.10"
|
|
11
14
|
},
|
|
12
15
|
"peerDependencies": {
|
|
13
|
-
"astro": "^5.0.0 || ^6.0.0",
|
|
14
|
-
"@dogsbay/
|
|
15
|
-
"@dogsbay/icons": "0.2.0-beta.
|
|
16
|
-
"@dogsbay/
|
|
16
|
+
"astro": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
17
|
+
"@dogsbay/elements": "0.2.0-beta.100",
|
|
18
|
+
"@dogsbay/icons": "0.2.0-beta.100",
|
|
19
|
+
"@dogsbay/primitives": "0.2.0-beta.100"
|
|
17
20
|
},
|
|
18
21
|
"optionalDependencies": {
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"maplibre-gl": "^5.
|
|
22
|
-
"
|
|
22
|
+
"@shikijs/transformers": "^4.3.1",
|
|
23
|
+
"katex": "^0.16.47",
|
|
24
|
+
"maplibre-gl": "^5.24.0",
|
|
25
|
+
"shiki": "^4.3.1"
|
|
23
26
|
},
|
|
24
27
|
"files": [
|
|
25
28
|
"src",
|
|
@@ -34,5 +37,8 @@
|
|
|
34
37
|
"homepage": "https://github.com/dogsbay/dogsbay/tree/main/packages/ui",
|
|
35
38
|
"bugs": {
|
|
36
39
|
"url": "https://github.com/dogsbay/dogsbay/issues"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "vitest run"
|
|
37
43
|
}
|
|
38
44
|
}
|
|
@@ -18,6 +18,11 @@ const wrapper = tv({
|
|
|
18
18
|
variants: {
|
|
19
19
|
variant: {
|
|
20
20
|
default: "border-border",
|
|
21
|
+
// `plain` = a disclosure that is CHROME, not an alert: no tint, no
|
|
22
|
+
// icon, no ARIA role. Used for collapsed context (e.g. the
|
|
23
|
+
// comparison view's unchanged-section folds), where announcing a
|
|
24
|
+
// "note" or a live-region "status" would be a lie.
|
|
25
|
+
plain: "border-border",
|
|
21
26
|
destructive: "border-destructive/50",
|
|
22
27
|
note: "border-note/50",
|
|
23
28
|
abstract: "border-abstract/50",
|
|
@@ -41,6 +46,7 @@ type Variant = VariantProps<typeof wrapper>["variant"];
|
|
|
41
46
|
/** Title bar: stronger tint (~10%) */
|
|
42
47
|
const titleBg: Record<string, string> = {
|
|
43
48
|
default: "bg-muted",
|
|
49
|
+
plain: "bg-transparent",
|
|
44
50
|
destructive: "bg-destructive/10 text-destructive",
|
|
45
51
|
note: "bg-note/10", abstract: "bg-abstract/10", info: "bg-info/10",
|
|
46
52
|
tip: "bg-tip/10", success: "bg-success/10", question: "bg-question/10",
|
|
@@ -51,6 +57,7 @@ const titleBg: Record<string, string> = {
|
|
|
51
57
|
/** Body: subtle tint (~3%) */
|
|
52
58
|
const bodyBg: Record<string, string> = {
|
|
53
59
|
default: "bg-background",
|
|
60
|
+
plain: "bg-transparent",
|
|
54
61
|
destructive: "bg-destructive/3",
|
|
55
62
|
note: "bg-note/3", abstract: "bg-abstract/3", info: "bg-info/3",
|
|
56
63
|
tip: "bg-tip/3", success: "bg-success/3", question: "bg-question/3",
|
|
@@ -79,14 +86,23 @@ interface Props {
|
|
|
79
86
|
const { variant = "note", title, open = false, class: className } = Astro.props;
|
|
80
87
|
const v = variant || "note";
|
|
81
88
|
|
|
82
|
-
|
|
89
|
+
// `plain` is chrome — a disclosure, nothing more. No role at all:
|
|
90
|
+
// role="note"/"status" on collapsed context misinforms assistive tech
|
|
91
|
+
// (and "status" would make it a live region).
|
|
92
|
+
const role = v === "plain" ? undefined : contentVariants.has(v) ? "note" : "status";
|
|
83
93
|
const icon = contentVariants.has(v) ? icons[v] : null;
|
|
84
94
|
---
|
|
85
95
|
|
|
96
|
+
{/*
|
|
97
|
+
The role belongs on the CONTENT, not on <details>. A <details> element
|
|
98
|
+
has an implicit `group` role, and ARIA does not permit overriding it
|
|
99
|
+
with `note`/`status` — axe flags it (aria-allowed-role), which is
|
|
100
|
+
exactly what an accessibility pass found on the comparison site's
|
|
101
|
+
collapsed sections. Moving it to the body keeps the semantics legal.
|
|
102
|
+
*/}
|
|
86
103
|
<details
|
|
87
104
|
class:list={[wrapper({ variant, class: className }), "group/collapsible"]}
|
|
88
105
|
data-variant={variant}
|
|
89
|
-
role={role}
|
|
90
106
|
open={open}
|
|
91
107
|
>
|
|
92
108
|
<summary class:list={[
|
|
@@ -97,7 +113,7 @@ const icon = contentVariants.has(v) ? icons[v] : null;
|
|
|
97
113
|
<span class="flex-1 text-foreground" data-part="title">{title || v.charAt(0).toUpperCase() + v.slice(1)}</span>
|
|
98
114
|
<span class="shrink-0 text-muted-foreground group-open/collapsible:rotate-180 transition-transform duration-200" set:html={chevronSvg} />
|
|
99
115
|
</summary>
|
|
100
|
-
<div class:list={["px-4 py-3 text-sm [&_p]:leading-relaxed", bodyBg[v]]}>
|
|
116
|
+
<div class:list={["px-4 py-3 text-sm [&_p]:leading-relaxed", bodyBg[v]]} role={role}>
|
|
101
117
|
<slot />
|
|
102
118
|
</div>
|
|
103
119
|
</details>
|
|
@@ -12,17 +12,41 @@
|
|
|
12
12
|
*/
|
|
13
13
|
interface Props {
|
|
14
14
|
class?: string;
|
|
15
|
+
/**
|
|
16
|
+
* "page" (default): a dedicated API operation page — full-viewport
|
|
17
|
+
* min-height, sticky code rail.
|
|
18
|
+
* "embedded": the layout sits inline among prose (imported MDX docs
|
|
19
|
+
* with several endpoints per page) — natural height, no sticky.
|
|
20
|
+
*/
|
|
21
|
+
variant?: "page" | "embedded";
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
const { class: className } = Astro.props;
|
|
24
|
+
const { class: className, variant = "page" } = Astro.props;
|
|
25
|
+
const isPage = variant === "page";
|
|
18
26
|
---
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
{/* dba-api marks an API-reference region: the generated .docs-prose
|
|
29
|
+
typography rules skip everything inside it via :not(.dba-api *), so
|
|
30
|
+
endpoint cards keep their own utility-class typography even when the
|
|
31
|
+
page body is wrapped in the prose article. */}
|
|
32
|
+
<div
|
|
33
|
+
class:list={[
|
|
34
|
+
"dba-api grid grid-cols-1 lg:grid-cols-2",
|
|
35
|
+
isPage ? "lg:min-h-[calc(100vh-3rem)]" : "my-6 rounded-lg border overflow-hidden",
|
|
36
|
+
className,
|
|
37
|
+
]}
|
|
38
|
+
>
|
|
39
|
+
<div class:list={[isPage ? "px-6 py-10" : "p-6", "lg:border-r"]}>
|
|
22
40
|
<slot name="description" />
|
|
23
41
|
</div>
|
|
24
42
|
<div class="bg-[var(--api-panel-bg)] text-[var(--api-panel-fg)]">
|
|
25
|
-
<div
|
|
43
|
+
<div
|
|
44
|
+
class:list={[
|
|
45
|
+
isPage
|
|
46
|
+
? "px-6 py-10 lg:sticky lg:top-12 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto"
|
|
47
|
+
: "p-6",
|
|
48
|
+
]}
|
|
49
|
+
>
|
|
26
50
|
<slot name="code" />
|
|
27
51
|
</div>
|
|
28
52
|
</div>
|
|
@@ -42,6 +42,15 @@ const {
|
|
|
42
42
|
...rest
|
|
43
43
|
} = Astro.props;
|
|
44
44
|
|
|
45
|
+
// AsciiDoc/docs language tokens that are not Shiki grammars → the nearest real
|
|
46
|
+
// one, so the block actually highlights (and data-language is meaningful)
|
|
47
|
+
// instead of silently falling back to plaintext. The header still shows the
|
|
48
|
+
// original label (`lang` below), only the highlighter input is aliased. #009.
|
|
49
|
+
const LANG_ALIASES: Record<string, string> = {
|
|
50
|
+
terminal: "bash",
|
|
51
|
+
};
|
|
52
|
+
const shikiLang = LANG_ALIASES[lang.toLowerCase()] ?? lang.toLowerCase();
|
|
53
|
+
|
|
45
54
|
// Show header bar based on showTitle prop:
|
|
46
55
|
// - true: show when there's a title or a declared language (default, original behavior)
|
|
47
56
|
// - false: never show
|
|
@@ -107,7 +116,7 @@ const floatingCopy = copy && !showHeader;
|
|
|
107
116
|
</div>
|
|
108
117
|
)}
|
|
109
118
|
<div class="relative overflow-x-auto [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!border-0 [&_pre]:px-4 [&_pre]:py-3">
|
|
110
|
-
<Code code={code} lang={
|
|
119
|
+
<Code code={code} lang={shikiLang} themes={{ light: "github-light-default", dark: theme }} defaultColor={false} />
|
|
111
120
|
{floatingCopy && (
|
|
112
121
|
<button
|
|
113
122
|
class:list={[codeBlockCopy(), "absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import "./code-rich.css";
|
|
3
|
-
import {
|
|
3
|
+
import { stripMarkersForCopy, stripUnprocessableMarkers } from "./markers";
|
|
4
|
+
import { codeToHtml, bundledLanguages, type BundledLanguage, type BundledTheme } from "shiki";
|
|
4
5
|
import {
|
|
5
6
|
transformerNotationDiff,
|
|
6
7
|
transformerNotationHighlight,
|
|
@@ -44,6 +45,21 @@ interface Props {
|
|
|
44
45
|
wordHighlights?: string;
|
|
45
46
|
/** Shiki theme */
|
|
46
47
|
theme?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Override the copy-button text. Diff-annotated blocks copy the
|
|
50
|
+
* post-change code only — the default (all lines minus annotation
|
|
51
|
+
* comments) would paste removed lines into the reader's editor.
|
|
52
|
+
*/
|
|
53
|
+
copyText?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Diff lines by NUMBER (1-based, e.g. "2,5-6") instead of `[!code]`
|
|
56
|
+
* comment markers. Markers only work in languages whose grammar has
|
|
57
|
+
* comments — in `plaintext` (or any unknown language) Shiki cannot
|
|
58
|
+
* recognize them and they render as literal text. Line numbers are
|
|
59
|
+
* language-independent, so a generated diff never leaks markers.
|
|
60
|
+
*/
|
|
61
|
+
diffAdd?: string;
|
|
62
|
+
diffRemove?: string;
|
|
47
63
|
class?: string;
|
|
48
64
|
[key: string]: unknown;
|
|
49
65
|
}
|
|
@@ -58,10 +74,28 @@ const {
|
|
|
58
74
|
highlights,
|
|
59
75
|
wordHighlights,
|
|
60
76
|
theme = "github-dark-default",
|
|
77
|
+
copyText: copyTextOverride,
|
|
78
|
+
diffAdd,
|
|
79
|
+
diffRemove,
|
|
61
80
|
class: className,
|
|
62
81
|
...rest
|
|
63
82
|
} = Astro.props;
|
|
64
83
|
|
|
84
|
+
// "2,5-6" → Set{2,5,6}
|
|
85
|
+
const parseLineSpec = (spec?: string): Set<number> => {
|
|
86
|
+
const out = new Set<number>();
|
|
87
|
+
for (const part of (spec ?? "").split(",")) {
|
|
88
|
+
const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
|
|
89
|
+
if (!range) continue;
|
|
90
|
+
const start = Number(range[1]);
|
|
91
|
+
const end = range[2] ? Number(range[2]) : start;
|
|
92
|
+
for (let n = start; n <= end; n++) out.add(n);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
const addLines = parseLineSpec(diffAdd);
|
|
97
|
+
const removeLines = parseLineSpec(diffRemove);
|
|
98
|
+
|
|
65
99
|
// Build transformers list
|
|
66
100
|
const transformers: ShikiTransformer[] = [
|
|
67
101
|
transformerNotationDiff(),
|
|
@@ -71,6 +105,19 @@ const transformers: ShikiTransformer[] = [
|
|
|
71
105
|
transformerNotationWordHighlight(),
|
|
72
106
|
];
|
|
73
107
|
|
|
108
|
+
// Line-number diffs (language-independent — see the prop docs)
|
|
109
|
+
if (addLines.size || removeLines.size) {
|
|
110
|
+
transformers.push({
|
|
111
|
+
name: "line-diff",
|
|
112
|
+
line(node, line) {
|
|
113
|
+
const kind = addLines.has(line) ? "add" : removeLines.has(line) ? "remove" : null;
|
|
114
|
+
if (!kind) return;
|
|
115
|
+
const cls = node.properties.class;
|
|
116
|
+
node.properties.class = `${Array.isArray(cls) ? cls.join(" ") : (cls ?? "")} diff ${kind}`.trim();
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
74
121
|
// Meta-based highlights: ```ts {1,3-5}
|
|
75
122
|
if (highlights) {
|
|
76
123
|
transformers.push(transformerMetaHighlight());
|
|
@@ -136,14 +183,25 @@ let metaStr = "";
|
|
|
136
183
|
if (highlights) metaStr += `{${highlights}} `;
|
|
137
184
|
if (wordHighlights) metaStr += `/${wordHighlights}/ `;
|
|
138
185
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
// Copy text: explicit override (diff blocks pass the post-change code)
|
|
187
|
+
// or the block's own code — either way every annotation form is
|
|
188
|
+
// stripped (see ./markers.ts).
|
|
189
|
+
const copyText = stripMarkersForCopy(copyTextOverride ?? code);
|
|
190
|
+
|
|
191
|
+
// Unknown languages (package-install, mdx-ish doc tokens) fall back
|
|
192
|
+
// to plaintext instead of throwing — same behavior as CodeBlock's
|
|
193
|
+
// <Code>. The header still shows the declared language label.
|
|
194
|
+
const requestedLang = lang.toLowerCase();
|
|
195
|
+
const isPlainGrammar =
|
|
196
|
+
!(requestedLang in bundledLanguages) || ["plaintext", "text", "txt", "ansi"].includes(requestedLang);
|
|
197
|
+
const shikiLang = isPlainGrammar ? "plaintext" : requestedLang;
|
|
198
|
+
|
|
199
|
+
// Markers Shiki cannot process would render as literal text — strip
|
|
200
|
+
// exactly those, leave the rest for the notation transformers.
|
|
201
|
+
const highlightCode = stripUnprocessableMarkers(code, isPlainGrammar);
|
|
144
202
|
|
|
145
|
-
const html = await codeToHtml(
|
|
146
|
-
lang:
|
|
203
|
+
const html = await codeToHtml(highlightCode, {
|
|
204
|
+
lang: shikiLang as BundledLanguage,
|
|
147
205
|
themes: {
|
|
148
206
|
light: "github-light-default" as BundledTheme,
|
|
149
207
|
dark: (theme || "github-dark-default") as BundledTheme,
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
min-width: 4ch;
|
|
13
13
|
margin-right: 1.5ch;
|
|
14
14
|
text-align: right;
|
|
15
|
-
|
|
15
|
+
/* Theme-aware, and NO alpha. This was a fixed dark grey at 70%
|
|
16
|
+
opacity: fine on a light code background, 1.3:1 against the dark
|
|
17
|
+
one — line numbers were effectively invisible in dark mode.
|
|
18
|
+
Opacity compounds it, since it blends toward whatever is behind.
|
|
19
|
+
--muted-foreground already flips per theme (L 0.45 / 0.708). */
|
|
20
|
+
color: var(--muted-foreground);
|
|
16
21
|
user-select: none;
|
|
17
22
|
flex-shrink: 0;
|
|
18
23
|
}
|
|
@@ -23,6 +28,18 @@
|
|
|
23
28
|
color: oklch(0.55 0.2 25);
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
/* Dark mode: the diff gutters sit on TINTED line backgrounds, not on
|
|
32
|
+
the plain code background, and at L0.55 they measure 2.89:1 (add)
|
|
33
|
+
and 2.63:1 (remove) there — both well under AA. Light mode is
|
|
34
|
+
unaffected, so these are overrides rather than a changed base.
|
|
35
|
+
Raised to L0.72 for headroom over the 4.5:1 floor. */
|
|
36
|
+
.dark .code-rich-body .gutter.add {
|
|
37
|
+
color: oklch(0.72 0.2 145);
|
|
38
|
+
}
|
|
39
|
+
.dark .code-rich-body .gutter.remove {
|
|
40
|
+
color: oklch(0.72 0.14 25);
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/* ── Diff line backgrounds ────────────────────────────── */
|
|
27
44
|
.code-rich-body .line.diff.add {
|
|
28
45
|
background-color: oklch(0.75 0.18 145 / 0.15);
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
return code
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((line) => {
|
|
42
|
+
// No grammar → no comment tokens → Shiki sees nothing to strip.
|
|
43
|
+
if (isPlainGrammar) return line.replace(MARKER, "").replace(BARE_MARKER, "");
|
|
44
|
+
let out = "";
|
|
45
|
+
let last = 0;
|
|
46
|
+
for (const m of line.matchAll(MARKER)) {
|
|
47
|
+
const end = m.index + m[0].length;
|
|
48
|
+
if (line.slice(end).trim() === "") break; // ends the line — Shiki handles it
|
|
49
|
+
out += line.slice(last, m.index);
|
|
50
|
+
last = end;
|
|
51
|
+
}
|
|
52
|
+
return out + line.slice(last);
|
|
53
|
+
})
|
|
54
|
+
.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Strip EVERY annotation form — markers must never reach the clipboard. */
|
|
58
|
+
export function stripMarkersForCopy(code: string): string {
|
|
59
|
+
return code
|
|
60
|
+
.replace(MARKER, "")
|
|
61
|
+
.replace(BARE_MARKER, "")
|
|
62
|
+
.replace(/\s*\/\/\s*![a-z]+.*$/gm, "")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
@@ -126,7 +126,7 @@ function inlineToHtml(nodes: InlineNode[]): string {
|
|
|
126
126
|
case "code": return `<code>${n.text || ""}</code>`;
|
|
127
127
|
case "highlight": return `<mark>${inlineToHtml((n.children || []) as InlineNode[])}</mark>`;
|
|
128
128
|
case "link": return `<a href="${n.href || ""}">${inlineToHtml((n.children || []) as InlineNode[])}</a>`;
|
|
129
|
-
case "image": return `<img src="${n.src
|
|
129
|
+
case "image": return `<img src="${withBaseUrl(n.src as string | undefined)}" alt="${n.alt || ""}">`;
|
|
130
130
|
case "kbd": return (n.keys as string[] || []).map((k: string) => `<kbd>${k}</kbd>`).join("+");
|
|
131
131
|
case "html-inline": return String(n.html || "");
|
|
132
132
|
case "break": return "<br>";
|
|
@@ -142,6 +142,26 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
142
142
|
const clean = normalized.replace(/#.*$/, "");
|
|
143
143
|
return images[clean];
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Prefix Astro's `base` config (= urlBase from site.url's path)
|
|
148
|
+
* onto absolute image paths so they resolve on subpath-mounted
|
|
149
|
+
* deploys. Skip for external / data: / already-prefixed URLs.
|
|
150
|
+
* See plans/content-assets-folder.md.
|
|
151
|
+
*/
|
|
152
|
+
function withBaseUrl(src: string | undefined): string {
|
|
153
|
+
if (!src) return "";
|
|
154
|
+
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
155
|
+
if (
|
|
156
|
+
!baseUrl ||
|
|
157
|
+
!src.startsWith("/") ||
|
|
158
|
+
src.startsWith(`${baseUrl}/`) ||
|
|
159
|
+
/^(?:[a-z]+:|\/\/)/i.test(src)
|
|
160
|
+
) {
|
|
161
|
+
return src;
|
|
162
|
+
}
|
|
163
|
+
return `${baseUrl}${src}`;
|
|
164
|
+
}
|
|
145
165
|
---
|
|
146
166
|
|
|
147
167
|
{/* ── Prose ──────────────────────────────────────────────── */}
|
|
@@ -157,7 +177,7 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
157
177
|
<Fragment set:html={
|
|
158
178
|
`<h${node.props!.level} id="${node.props!.slug || ''}" class="group scroll-mt-20">` +
|
|
159
179
|
`${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>` +
|
|
180
|
+
`<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
181
|
`</h${node.props!.level}>`
|
|
162
182
|
} />
|
|
163
183
|
)}
|
|
@@ -275,7 +295,18 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
275
295
|
{/* ── Table ──────────────────────────────────────────────── */}
|
|
276
296
|
{node.type === "table" && (
|
|
277
297
|
<Table class="my-4">
|
|
278
|
-
{
|
|
298
|
+
{(node.props?.headerRows as string[][] | undefined)?.length ? (
|
|
299
|
+
<TableHeader>
|
|
300
|
+
{(node.props!.headerRows as string[][]).map((row) => (
|
|
301
|
+
<TableRow>{row.map((cell) => <TableHead set:html={cell} />)}</TableRow>
|
|
302
|
+
))}
|
|
303
|
+
</TableHeader>
|
|
304
|
+
) : null}
|
|
305
|
+
<TableBody>
|
|
306
|
+
{((node.props?.bodyRows as string[][] | undefined) ?? []).map((row) => (
|
|
307
|
+
<TableRow>{row.map((cell) => <TableCell set:html={cell} />)}</TableRow>
|
|
308
|
+
))}
|
|
309
|
+
</TableBody>
|
|
279
310
|
</Table>
|
|
280
311
|
)}
|
|
281
312
|
{node.type === "thead" && <TableHeader>{children.map(child => <Astro.self node={child} images={images} icons={icons} />)}</TableHeader>}
|
|
@@ -296,11 +327,14 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
296
327
|
{node.type === "hr" && <hr class="my-8 border-border" />}
|
|
297
328
|
{node.type === "html" && (() => {
|
|
298
329
|
let html = node.html || "";
|
|
299
|
-
if (html.includes("<img")
|
|
330
|
+
if (html.includes("<img")) {
|
|
300
331
|
html = html.replace(/src="([^"]+)"/g, (_match: string, src: string) => {
|
|
301
332
|
const resolved = resolveImage(src);
|
|
302
333
|
if (resolved) return `src="${resolved.src}" width="${resolved.width}" height="${resolved.height}"`;
|
|
303
|
-
|
|
334
|
+
// Not in the glob (SVG / PDF / build-without-optimization) —
|
|
335
|
+
// prefix BASE_URL so absolute paths resolve on subpath deploys.
|
|
336
|
+
const prefixed = withBaseUrl(src);
|
|
337
|
+
return prefixed === src ? _match : `src="${prefixed}"`;
|
|
304
338
|
});
|
|
305
339
|
}
|
|
306
340
|
return <Fragment set:html={html} />;
|
|
@@ -47,6 +47,24 @@ function resolveImage(src: string): ImageMetadata | undefined {
|
|
|
47
47
|
|
|
48
48
|
const resolvedImg = node.type === "image" ? resolveImage(node.src as string || "") : undefined;
|
|
49
49
|
|
|
50
|
+
// When the image isn't in the glob map (SVG, PDF, or built with
|
|
51
|
+
// imageOptimization: false), the `<img src={node.src}>` fallback
|
|
52
|
+
// fires below. Prefix Astro's `base` for absolute paths so they
|
|
53
|
+
// resolve on subpath-mounted deploys. See plans/content-assets-folder.md.
|
|
54
|
+
function withBaseUrl(src: string | undefined): string {
|
|
55
|
+
if (!src) return "";
|
|
56
|
+
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
57
|
+
if (
|
|
58
|
+
!baseUrl ||
|
|
59
|
+
!src.startsWith("/") ||
|
|
60
|
+
src.startsWith(`${baseUrl}/`) ||
|
|
61
|
+
/^(?:[a-z]+:|\/\/)/i.test(src)
|
|
62
|
+
) {
|
|
63
|
+
return src;
|
|
64
|
+
}
|
|
65
|
+
return `${baseUrl}${src}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
// Icon resolution
|
|
51
69
|
const iconSvg = node.type === "icon" && icons
|
|
52
70
|
? icons.resolve(node.library || "emoji", node.name, node.variant, node.size) as string | null
|
|
@@ -90,7 +108,7 @@ const isEmojiIcon = node.type === "icon" && (node.library === "emoji" || (iconSv
|
|
|
90
108
|
)}
|
|
91
109
|
{node.type === "image" && !resolvedImg && (
|
|
92
110
|
<img
|
|
93
|
-
src={node.src}
|
|
111
|
+
src={withBaseUrl(node.src as string | undefined)}
|
|
94
112
|
alt={node.alt || ""}
|
|
95
113
|
title={node.title}
|
|
96
114
|
width={node.width}
|
|
@@ -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>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Icon — render an icon by name from any Iconify pack (defaults
|
|
4
|
+
* to Lucide).
|
|
5
|
+
*
|
|
6
|
+
* Resolves at BUILD time via `@dogsbay/icons`'s registry — the
|
|
7
|
+
* SVG is inlined into the static HTML, so there's no client-side
|
|
8
|
+
* fetch and no FOUC. Tree-shakeable: only icons actually used in
|
|
9
|
+
* pages get inlined, since Astro statically evaluates the
|
|
10
|
+
* resolution per template instance.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
*
|
|
14
|
+
* <Icon name="rocket" /> // bare → Lucide rocket
|
|
15
|
+
* <Icon name="lucide:book-open" /> // explicit pack
|
|
16
|
+
* <Icon name="mdi:home" /> // Material Design Icons
|
|
17
|
+
* <Icon name="simple-icons:github" /> // brand icons
|
|
18
|
+
* <Icon name="rocket" class="size-5 text-primary" />
|
|
19
|
+
*
|
|
20
|
+
* Bare names check the emoji map first (so `rocket` → 🚀
|
|
21
|
+
* matches existing inline-icon behaviour), then fall through to
|
|
22
|
+
* Lucide. When the icon doesn't exist anywhere, the component
|
|
23
|
+
* renders nothing — silent fallback rather than a broken-image
|
|
24
|
+
* box, since icons are decorative and consistent typography is
|
|
25
|
+
* preferable to a placeholder.
|
|
26
|
+
*
|
|
27
|
+
* Accessibility: when `alt` is supplied, the icon is announced
|
|
28
|
+
* to screen readers as a labelled image. Without `alt` (the
|
|
29
|
+
* default), the SVG is `aria-hidden` because most icons in this
|
|
30
|
+
* codebase are paired with adjacent text that already conveys
|
|
31
|
+
* the meaning. Pass `alt=""` explicitly to force the
|
|
32
|
+
* decorative-only state when needed by external tooling.
|
|
33
|
+
*/
|
|
34
|
+
import { resolveIcon } from "@dogsbay/icons";
|
|
35
|
+
|
|
36
|
+
interface Props {
|
|
37
|
+
/**
|
|
38
|
+
* Icon shorthand. Either bare (`rocket`, `book-open`) or
|
|
39
|
+
* namespaced (`pack:name`). See file header for examples.
|
|
40
|
+
*/
|
|
41
|
+
name: string;
|
|
42
|
+
/** Tailwind class string. Defaults to `size-4` for inline-text sizing. */
|
|
43
|
+
class?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Accessible label. Set to a non-empty string for icons that
|
|
46
|
+
* convey information without surrounding text. Leave undefined
|
|
47
|
+
* (or pass empty string) for decorative icons paired with a
|
|
48
|
+
* text label.
|
|
49
|
+
*/
|
|
50
|
+
alt?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { name, class: className = "size-4", alt } = Astro.props;
|
|
54
|
+
const svg = resolveIcon(name);
|
|
55
|
+
const labelled = typeof alt === "string" && alt.length > 0;
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
svg ? (
|
|
60
|
+
svg.startsWith("<") ? (
|
|
61
|
+
<span
|
|
62
|
+
class:list={["inline-flex shrink-0", className]}
|
|
63
|
+
role={labelled ? "img" : undefined}
|
|
64
|
+
aria-label={labelled ? alt : undefined}
|
|
65
|
+
aria-hidden={labelled ? undefined : "true"}
|
|
66
|
+
set:html={svg}
|
|
67
|
+
data-icon={name}
|
|
68
|
+
/>
|
|
69
|
+
) : (
|
|
70
|
+
// Emoji fallback — `svg` is actually a Unicode character
|
|
71
|
+
// when the name resolved against the emoji map (e.g.
|
|
72
|
+
// `rocket` → 🚀). Render it as text so font emoji shows.
|
|
73
|
+
<span
|
|
74
|
+
class:list={["inline-flex shrink-0", className]}
|
|
75
|
+
role={labelled ? "img" : undefined}
|
|
76
|
+
aria-label={labelled ? alt : undefined}
|
|
77
|
+
aria-hidden={labelled ? undefined : "true"}
|
|
78
|
+
data-icon={name}
|
|
79
|
+
>
|
|
80
|
+
{svg}
|
|
81
|
+
</span>
|
|
82
|
+
)
|
|
83
|
+
) : null
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Icon } from "./Icon.astro";
|
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}
|
|
@@ -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>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import SidebarNavMark from "./SidebarNavMark.astro";
|
|
2
3
|
/**
|
|
3
4
|
* Recursive sidebar navigation tree.
|
|
4
5
|
* Renders a data-driven tree with native <details>/<summary> for zero-JS expand/collapse.
|
|
@@ -8,11 +9,18 @@
|
|
|
8
9
|
* <SidebarNavTree items={navItems} currentPath={Astro.url.pathname} />
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
interface NavMark {
|
|
13
|
+
kind: "added" | "changed" | "removed" | "moved";
|
|
14
|
+
label?: string;
|
|
15
|
+
subtree?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
interface NavItem {
|
|
12
19
|
label: string;
|
|
13
20
|
href?: string;
|
|
14
21
|
icon?: string; // Raw SVG string (rendered via set:html)
|
|
15
22
|
children?: NavItem[];
|
|
23
|
+
mark?: NavMark;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
interface Props {
|
|
@@ -24,6 +32,13 @@ interface Props {
|
|
|
24
32
|
|
|
25
33
|
const { items, currentPath, level = 0, class: className } = Astro.props;
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Markers are rendered by SidebarNavMark, which owns the accessibility
|
|
37
|
+
* contract (distinct glyph so colour is never the sole channel,
|
|
38
|
+
* visually-hidden text so the row announces "MySQL, changed",
|
|
39
|
+
* forced-colors survival). See that component.
|
|
40
|
+
*/
|
|
41
|
+
|
|
27
42
|
const normalizedPath = currentPath.replace(/\/$/, "") || "/";
|
|
28
43
|
|
|
29
44
|
function isActive(href: string) {
|
|
@@ -54,24 +69,78 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
54
69
|
const active = item.href ? isActive(item.href) : false;
|
|
55
70
|
const hasChildren = item.children && item.children.length > 0;
|
|
56
71
|
const shouldOpen = hasChildren && hasActiveDescendant(item);
|
|
72
|
+
const open = shouldOpen || active;
|
|
73
|
+
// A branch that is ALSO a page (has an href) is a section landing
|
|
74
|
+
// page; it uses the button-driven disclosure below and needs a
|
|
75
|
+
// stable id linking its toggle button to its submenu.
|
|
76
|
+
const submenuId = item.href
|
|
77
|
+
? `nav-sub-${item.href.replace(/[^a-z0-9]+/gi, "-")}-${level}`
|
|
78
|
+
: undefined;
|
|
57
79
|
|
|
58
80
|
return (
|
|
59
81
|
<li data-sidebar="nav-tree-item">
|
|
60
|
-
{hasChildren ? (
|
|
82
|
+
{hasChildren && item.href ? (
|
|
83
|
+
/*
|
|
84
|
+
Section landing page: APG "disclosure navigation" pattern — a
|
|
85
|
+
real link (navigates) + a SEPARATE toggle <button> (expands),
|
|
86
|
+
as siblings. Nesting a focusable <a> inside the interactive
|
|
87
|
+
<summary> is an axe `nested-interactive` violation, and native
|
|
88
|
+
<details> can't show a header link when collapsed without that
|
|
89
|
+
nesting. Progressive enhancement: the submenu's server state
|
|
90
|
+
(hidden unless open) is correct with no JS — the link still
|
|
91
|
+
works and the active section is expanded — and the inline
|
|
92
|
+
script wires the toggle button when JS is available.
|
|
93
|
+
*/
|
|
94
|
+
<Fragment>
|
|
95
|
+
<div
|
|
96
|
+
class:list={[
|
|
97
|
+
"flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
98
|
+
level === 0 ? "h-8" : "h-7",
|
|
99
|
+
active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
|
|
100
|
+
]}
|
|
101
|
+
style={`padding-left: ${paddingLeft};`}
|
|
102
|
+
>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
data-nav-toggle
|
|
106
|
+
aria-controls={submenuId}
|
|
107
|
+
aria-expanded={open ? "true" : "false"}
|
|
108
|
+
aria-label={`Toggle ${item.label} section`}
|
|
109
|
+
class="shrink-0 rounded outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
110
|
+
>
|
|
111
|
+
<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 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6" /></svg>
|
|
112
|
+
</button>
|
|
113
|
+
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
114
|
+
<a
|
|
115
|
+
href={item.href}
|
|
116
|
+
class="min-w-0 flex-1 truncate text-inherit no-underline outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
117
|
+
data-nav-href={item.href}
|
|
118
|
+
data-active={active || undefined}
|
|
119
|
+
data-nav-mark-row={item.mark?.subtree ? undefined : item.mark?.kind}
|
|
120
|
+
>{item.label}</a>
|
|
121
|
+
{item.mark && (
|
|
122
|
+
<SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
<div id={submenuId} data-nav-submenu hidden={!open}>
|
|
126
|
+
<Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
|
|
127
|
+
</div>
|
|
128
|
+
</Fragment>
|
|
129
|
+
) : hasChildren ? (
|
|
61
130
|
<details open={shouldOpen || active}>
|
|
62
131
|
<summary
|
|
63
132
|
class:list={[
|
|
64
133
|
"flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none [list-style:none] ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&::-webkit-details-marker]:hidden",
|
|
65
134
|
level === 0 ? "h-8" : "h-7",
|
|
66
|
-
active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
|
|
67
135
|
]}
|
|
68
136
|
style={`padding-left: ${paddingLeft};`}
|
|
69
|
-
data-nav-href={item.href || undefined}
|
|
70
|
-
data-active={active || undefined}
|
|
71
137
|
>
|
|
72
138
|
<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 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6" /></svg>
|
|
73
139
|
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
74
|
-
<span class="truncate">{item.label}</span>
|
|
140
|
+
<span class="min-w-0 flex-1 truncate">{item.label}</span>
|
|
141
|
+
{item.mark && (
|
|
142
|
+
<SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
|
|
143
|
+
)}
|
|
75
144
|
</summary>
|
|
76
145
|
<Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
|
|
77
146
|
</details>
|
|
@@ -86,12 +155,22 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
86
155
|
style={`padding-left: ${paddingLeft};`}
|
|
87
156
|
data-active={active || undefined}
|
|
88
157
|
data-nav-href={item.href}
|
|
158
|
+
{/*
|
|
159
|
+
A SUBTREE mark is a claim about descendants, never about
|
|
160
|
+
this row — striking through a surviving group because one
|
|
161
|
+
child was deleted tells the reader the whole section is
|
|
162
|
+
gone (code review, 2026-07-13).
|
|
163
|
+
*/}
|
|
164
|
+
data-nav-mark-row={item.mark?.subtree ? undefined : item.mark?.kind}
|
|
89
165
|
>
|
|
90
166
|
{/* Spacer to align with chevron in branch items */}
|
|
91
167
|
<span class="size-4 shrink-0" />
|
|
92
168
|
{/* Icon (level 0 only) */}
|
|
93
169
|
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
94
|
-
<span class="truncate">{item.label}</span>
|
|
170
|
+
<span class="min-w-0 flex-1 truncate">{item.label}</span>
|
|
171
|
+
{item.mark && (
|
|
172
|
+
<SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
|
|
173
|
+
)}
|
|
95
174
|
</a>
|
|
96
175
|
)}
|
|
97
176
|
</li>
|
|
@@ -100,7 +179,32 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
100
179
|
</ul>
|
|
101
180
|
|
|
102
181
|
<style is:global>
|
|
103
|
-
details[open] > summary [data-chevron]
|
|
182
|
+
details[open] > summary [data-chevron],
|
|
183
|
+
[data-nav-toggle][aria-expanded="true"] [data-chevron] {
|
|
104
184
|
transform: rotate(90deg);
|
|
105
185
|
}
|
|
106
186
|
</style>
|
|
187
|
+
|
|
188
|
+
<script>
|
|
189
|
+
// Wire the section-landing disclosure toggles (APG disclosure-nav).
|
|
190
|
+
// The link navigates on its own; this button just expands/collapses
|
|
191
|
+
// the sibling submenu. Server-rendered state is already correct, so
|
|
192
|
+
// with no JS the link still works and the active section is open.
|
|
193
|
+
// Idempotent + re-run after view transitions.
|
|
194
|
+
function wireNavToggles() {
|
|
195
|
+
document
|
|
196
|
+
.querySelectorAll<HTMLButtonElement>("[data-nav-toggle]:not([data-wired])")
|
|
197
|
+
.forEach((btn) => {
|
|
198
|
+
btn.dataset.wired = "1";
|
|
199
|
+
const id = btn.getAttribute("aria-controls");
|
|
200
|
+
const submenu = id ? document.getElementById(id) : null;
|
|
201
|
+
btn.addEventListener("click", () => {
|
|
202
|
+
const isOpen = btn.getAttribute("aria-expanded") === "true";
|
|
203
|
+
btn.setAttribute("aria-expanded", String(!isOpen));
|
|
204
|
+
if (submenu) submenu.hidden = isOpen;
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
wireNavToggles();
|
|
209
|
+
document.addEventListener("astro:page-load", wireNavToggles);
|
|
210
|
+
</script>
|
package/src/table/Table.astro
CHANGED
|
@@ -11,14 +11,17 @@ const table = tv({
|
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
class?: string;
|
|
14
|
+
/** Optional table caption, rendered as a `<caption>` (bottom-aligned). */
|
|
15
|
+
caption?: string;
|
|
14
16
|
[key: string]: unknown;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
const { class: className, ...rest } = Astro.props;
|
|
19
|
+
const { class: className, caption, ...rest } = Astro.props;
|
|
18
20
|
---
|
|
19
21
|
|
|
20
22
|
<div class={tableContainer()}>
|
|
21
23
|
<table class={table({ class: className })} {...rest}>
|
|
24
|
+
{caption && <caption class="text-sm text-muted-foreground mt-2">{caption}</caption>}
|
|
22
25
|
<slot />
|
|
23
26
|
</table>
|
|
24
27
|
</div>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { tv } from "tailwind-variants";
|
|
3
3
|
|
|
4
4
|
const tableHead = tv({
|
|
5
|
-
base: "
|
|
5
|
+
base: "px-2 py-2 text-left align-bottom font-semibold text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
interface Props {
|