@dogsbay/ui 0.2.0-beta.13 → 0.2.0-beta.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -12
- package/src/alert/CollapsibleAlert.astro +33 -7
- package/src/alert/icon.ts +8 -15
- 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/avatar/Avatar.astro +6 -2
- package/src/badge/Badge.astro +3 -10
- package/src/badge/variants.ts +39 -0
- package/src/code-block/CodeBlock.astro +38 -8
- package/src/code-rich/CodeRich.astro +149 -15
- package/src/code-rich/code-rich.css +54 -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/details-sheet/DetailsSheet.astro +28 -0
- package/src/details-sheet/index.ts +1 -0
- 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 +10 -3
- 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/steps/Steps.astro +5 -0
- package/src/steps/SubSteps.astro +5 -0
- 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
- package/src/youtube/YouTube.astro +2 -2
|
@@ -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,
|
|
@@ -32,6 +33,10 @@ interface Props {
|
|
|
32
33
|
lang?: string;
|
|
33
34
|
/** Optional title/filename displayed in the header bar */
|
|
34
35
|
title?: string;
|
|
36
|
+
/*
|
|
37
|
+
* A `caption` slot makes the block a figure: the header bar is its
|
|
38
|
+
* <figcaption>, showing the caption with the language beside it.
|
|
39
|
+
*/
|
|
35
40
|
/** Show copy button (default: true) */
|
|
36
41
|
copy?: boolean;
|
|
37
42
|
/** Show line numbers (default: false) */
|
|
@@ -44,6 +49,31 @@ interface Props {
|
|
|
44
49
|
wordHighlights?: string;
|
|
45
50
|
/** Shiki theme */
|
|
46
51
|
theme?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Override the copy-button text. Diff-annotated blocks copy the
|
|
54
|
+
* post-change code only — the default (all lines minus annotation
|
|
55
|
+
* comments) would paste removed lines into the reader's editor.
|
|
56
|
+
*/
|
|
57
|
+
copyText?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Diff lines by NUMBER (1-based, e.g. "2,5-6") instead of `[!code]`
|
|
60
|
+
* comment markers. Markers only work in languages whose grammar has
|
|
61
|
+
* comments — in `plaintext` (or any unknown language) Shiki cannot
|
|
62
|
+
* recognize them and they render as literal text. Line numbers are
|
|
63
|
+
* language-independent, so a generated diff never leaks markers.
|
|
64
|
+
*/
|
|
65
|
+
diffAdd?: string;
|
|
66
|
+
diffRemove?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Lines to fold behind a toggle, e.g. "3-8,20-25" (Expressive Code's `collapse`), numbered from the
|
|
69
|
+
* block's first line (not `startLine`). Each run shows as "N collapsed lines"; a click or Space
|
|
70
|
+
* shows or hides it. No script: a checkbox in the label, and
|
|
71
|
+
* code-rich.css (render-html's code blocks use the same markup).
|
|
72
|
+
*/
|
|
73
|
+
collapse?: string;
|
|
74
|
+
/** The toggle's text; `{n}` is the number of lines it hides. `collapsedLabelOne` for a single line. */
|
|
75
|
+
collapsedLabel?: string;
|
|
76
|
+
collapsedLabelOne?: string;
|
|
47
77
|
class?: string;
|
|
48
78
|
[key: string]: unknown;
|
|
49
79
|
}
|
|
@@ -58,10 +88,32 @@ const {
|
|
|
58
88
|
highlights,
|
|
59
89
|
wordHighlights,
|
|
60
90
|
theme = "github-dark-default",
|
|
91
|
+
copyText: copyTextOverride,
|
|
92
|
+
diffAdd,
|
|
93
|
+
diffRemove,
|
|
94
|
+
collapse,
|
|
95
|
+
collapsedLabel = "{n} collapsed lines",
|
|
96
|
+
collapsedLabelOne = "1 collapsed line",
|
|
61
97
|
class: className,
|
|
62
98
|
...rest
|
|
63
99
|
} = Astro.props;
|
|
64
100
|
|
|
101
|
+
// "2,5-6" → Set{2,5,6}
|
|
102
|
+
const parseLineSpec = (spec?: string): Set<number> => {
|
|
103
|
+
const out = new Set<number>();
|
|
104
|
+
for (const part of (spec ?? "").split(",")) {
|
|
105
|
+
const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
|
|
106
|
+
if (!range) continue;
|
|
107
|
+
const start = Number(range[1]);
|
|
108
|
+
const end = range[2] ? Number(range[2]) : start;
|
|
109
|
+
// Capped as render-html's parseLineRanges: `1-999999999` would fill the Set and stall the build
|
|
110
|
+
for (let n = start; n <= Math.min(end, start + 5000); n++) out.add(n);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
};
|
|
114
|
+
const addLines = parseLineSpec(diffAdd);
|
|
115
|
+
const removeLines = parseLineSpec(diffRemove);
|
|
116
|
+
|
|
65
117
|
// Build transformers list
|
|
66
118
|
const transformers: ShikiTransformer[] = [
|
|
67
119
|
transformerNotationDiff(),
|
|
@@ -71,6 +123,19 @@ const transformers: ShikiTransformer[] = [
|
|
|
71
123
|
transformerNotationWordHighlight(),
|
|
72
124
|
];
|
|
73
125
|
|
|
126
|
+
// Line-number diffs (language-independent — see the prop docs)
|
|
127
|
+
if (addLines.size || removeLines.size) {
|
|
128
|
+
transformers.push({
|
|
129
|
+
name: "line-diff",
|
|
130
|
+
line(node, line) {
|
|
131
|
+
const kind = addLines.has(line) ? "add" : removeLines.has(line) ? "remove" : null;
|
|
132
|
+
if (!kind) return;
|
|
133
|
+
const cls = node.properties.class;
|
|
134
|
+
node.properties.class = `${Array.isArray(cls) ? cls.join(" ") : (cls ?? "")} diff ${kind}`.trim();
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
74
139
|
// Meta-based highlights: ```ts {1,3-5}
|
|
75
140
|
if (highlights) {
|
|
76
141
|
transformers.push(transformerMetaHighlight());
|
|
@@ -131,19 +196,72 @@ transformers.push({
|
|
|
131
196
|
},
|
|
132
197
|
});
|
|
133
198
|
|
|
199
|
+
// Collapsed runs: after the gutter, so each folded line keeps its number. Lines are the
|
|
200
|
+
// `.line` spans of <code>, separated by "\n" text nodes (render-html's collapseLines does the same).
|
|
201
|
+
const collapseRuns: Array<[number, number]> = [];
|
|
202
|
+
for (const n of [...parseLineSpec(collapse)].sort((a, b) => a - b)) {
|
|
203
|
+
const last = collapseRuns[collapseRuns.length - 1];
|
|
204
|
+
if (last && n === last[1] + 1) last[1] = n;
|
|
205
|
+
else collapseRuns.push([n, n]);
|
|
206
|
+
}
|
|
207
|
+
if (collapseRuns.length) {
|
|
208
|
+
transformers.push({
|
|
209
|
+
name: "collapse",
|
|
210
|
+
code(node) {
|
|
211
|
+
const kids = node.children as any[];
|
|
212
|
+
const linePos = kids.flatMap((c, i) => (c.type === "element" && c.tagName === "span" ? [i] : []));
|
|
213
|
+
for (const [a, b] of [...collapseRuns].reverse()) {
|
|
214
|
+
if (a > linePos.length) continue;
|
|
215
|
+
const end = Math.min(b, linePos.length);
|
|
216
|
+
const from = linePos[a - 1];
|
|
217
|
+
const to = linePos[end - 1];
|
|
218
|
+
const label = (end === a ? collapsedLabelOne : collapsedLabel).replace("{n}", String(end - a + 1));
|
|
219
|
+
kids.splice(from, to - from + 1, {
|
|
220
|
+
type: "element",
|
|
221
|
+
tagName: "span",
|
|
222
|
+
properties: { class: "code-collapse" },
|
|
223
|
+
children: [
|
|
224
|
+
{
|
|
225
|
+
type: "element",
|
|
226
|
+
tagName: "label",
|
|
227
|
+
properties: { class: "code-collapse-label", "data-pagefind-ignore": "" },
|
|
228
|
+
children: [
|
|
229
|
+
{ type: "element", tagName: "input", properties: { type: "checkbox", class: "code-collapse-toggle" }, children: [] },
|
|
230
|
+
{ type: "text", value: label },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
{ type: "element", tagName: "span", properties: { class: "code-collapse-lines" }, children: kids.slice(from, to + 1) },
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
134
241
|
// Build meta string
|
|
135
242
|
let metaStr = "";
|
|
136
243
|
if (highlights) metaStr += `{${highlights}} `;
|
|
137
244
|
if (wordHighlights) metaStr += `/${wordHighlights}/ `;
|
|
138
245
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.trim();
|
|
246
|
+
// Copy text: explicit override (diff blocks pass the post-change code)
|
|
247
|
+
// or the block's own code — either way every annotation form is
|
|
248
|
+
// stripped (see ./markers.ts).
|
|
249
|
+
const copyText = stripMarkersForCopy(copyTextOverride ?? code);
|
|
144
250
|
|
|
145
|
-
|
|
146
|
-
|
|
251
|
+
// Unknown languages (package-install, mdx-ish doc tokens) fall back
|
|
252
|
+
// to plaintext instead of throwing — same behavior as CodeBlock's
|
|
253
|
+
// <Code>. The header still shows the declared language label.
|
|
254
|
+
const requestedLang = lang.toLowerCase();
|
|
255
|
+
const isPlainGrammar =
|
|
256
|
+
!(requestedLang in bundledLanguages) || ["plaintext", "text", "txt", "ansi"].includes(requestedLang);
|
|
257
|
+
const shikiLang = isPlainGrammar ? "plaintext" : requestedLang;
|
|
258
|
+
|
|
259
|
+
// Markers Shiki cannot process would render as literal text — strip
|
|
260
|
+
// exactly those, leave the rest for the notation transformers.
|
|
261
|
+
const highlightCode = stripUnprocessableMarkers(code, isPlainGrammar);
|
|
262
|
+
|
|
263
|
+
const html = await codeToHtml(highlightCode, {
|
|
264
|
+
lang: shikiLang as BundledLanguage,
|
|
147
265
|
themes: {
|
|
148
266
|
light: "github-light-default" as BundledTheme,
|
|
149
267
|
dark: (theme || "github-dark-default") as BundledTheme,
|
|
@@ -154,21 +272,36 @@ const html = await codeToHtml(code, {
|
|
|
154
272
|
});
|
|
155
273
|
|
|
156
274
|
// Show header bar only when there's a title or a declared language
|
|
157
|
-
const
|
|
275
|
+
const hasCaption = Astro.slots.has("caption");
|
|
276
|
+
const Wrapper = hasCaption ? "figure" : "div";
|
|
277
|
+
const Header = hasCaption ? "figcaption" : "div";
|
|
278
|
+
const hasLabel = hasCaption || !!title || (lang !== "plaintext");
|
|
158
279
|
const showHeader = hasLabel;
|
|
159
280
|
// When no header, copy button floats inside the code body
|
|
160
281
|
const floatingCopy = copy && !showHeader;
|
|
161
282
|
---
|
|
162
283
|
|
|
163
|
-
<
|
|
164
|
-
class={codeRichWrapper({ class: className })}
|
|
284
|
+
<Wrapper
|
|
285
|
+
class={codeRichWrapper({ class: [hasCaption && "m-0", className] })}
|
|
165
286
|
data-component="code-rich"
|
|
166
287
|
data-line-numbers={lineNumbers || undefined}
|
|
167
288
|
{...rest}
|
|
168
289
|
>
|
|
169
290
|
{showHeader && (
|
|
170
|
-
<
|
|
171
|
-
|
|
291
|
+
<Header class={codeRichHeader()}>
|
|
292
|
+
{/*
|
|
293
|
+
Left: what the code is (caption or filename title), or the language when that is
|
|
294
|
+
all there is. Right: the language beside the copy button, as secondary detail
|
|
295
|
+
*/}
|
|
296
|
+
{hasCaption ? (
|
|
297
|
+
<span class="min-w-0 font-medium text-code-fg" data-part="caption"><slot name="caption" /></span>
|
|
298
|
+
) : title ? (
|
|
299
|
+
<span class="min-w-0 font-medium text-code-fg" data-part="title">{title}</span>
|
|
300
|
+
) : (
|
|
301
|
+
<span class="select-none">{lang}</span>
|
|
302
|
+
)}
|
|
303
|
+
<span class="flex shrink-0 items-center gap-2">
|
|
304
|
+
{(hasCaption || title) && lang !== "plaintext" && <span class="select-none" data-part="language">{lang}</span>}
|
|
172
305
|
{copy && (
|
|
173
306
|
<button
|
|
174
307
|
class={codeRichCopy()}
|
|
@@ -211,7 +344,8 @@ const floatingCopy = copy && !showHeader;
|
|
|
211
344
|
</svg>
|
|
212
345
|
</button>
|
|
213
346
|
)}
|
|
214
|
-
|
|
347
|
+
</span>
|
|
348
|
+
</Header>
|
|
215
349
|
)}
|
|
216
350
|
<div class="code-rich-body relative overflow-x-auto [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!border-0 [&_pre]:!bg-transparent [&_pre]:px-4 [&_pre]:py-3">
|
|
217
351
|
<Fragment set:html={html} />
|
|
@@ -233,7 +367,7 @@ const floatingCopy = copy && !showHeader;
|
|
|
233
367
|
</button>
|
|
234
368
|
)}
|
|
235
369
|
</div>
|
|
236
|
-
</
|
|
370
|
+
</Wrapper>
|
|
237
371
|
|
|
238
372
|
<script>
|
|
239
373
|
function setupCodeRichCopy() {
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
/* ── All lines ────────────────────────────────────────── */
|
|
4
4
|
.code-rich-body .line {
|
|
5
|
-
|
|
5
|
+
/* inline-block, not inline-flex: a flex line makes each token span a flex item, and
|
|
6
|
+
browsers then copy a hand-made selection with a line break between tokens. */
|
|
7
|
+
display: inline-block;
|
|
6
8
|
width: 100%;
|
|
7
9
|
}
|
|
8
10
|
|
|
@@ -12,7 +14,12 @@
|
|
|
12
14
|
min-width: 4ch;
|
|
13
15
|
margin-right: 1.5ch;
|
|
14
16
|
text-align: right;
|
|
15
|
-
|
|
17
|
+
/* Theme-aware, and NO alpha. This was a fixed dark grey at 70%
|
|
18
|
+
opacity: fine on a light code background, 1.3:1 against the dark
|
|
19
|
+
one — line numbers were effectively invisible in dark mode.
|
|
20
|
+
Opacity compounds it, since it blends toward whatever is behind.
|
|
21
|
+
--muted-foreground already flips per theme (L 0.45 / 0.708). */
|
|
22
|
+
color: var(--muted-foreground);
|
|
16
23
|
user-select: none;
|
|
17
24
|
flex-shrink: 0;
|
|
18
25
|
}
|
|
@@ -23,6 +30,18 @@
|
|
|
23
30
|
color: oklch(0.55 0.2 25);
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/* Dark mode: the diff gutters sit on TINTED line backgrounds, not on
|
|
34
|
+
the plain code background, and at L0.55 they measure 2.89:1 (add)
|
|
35
|
+
and 2.63:1 (remove) there — both well under AA. Light mode is
|
|
36
|
+
unaffected, so these are overrides rather than a changed base.
|
|
37
|
+
Raised to L0.72 for headroom over the 4.5:1 floor. */
|
|
38
|
+
.dark .code-rich-body .gutter.add {
|
|
39
|
+
color: oklch(0.72 0.2 145);
|
|
40
|
+
}
|
|
41
|
+
.dark .code-rich-body .gutter.remove {
|
|
42
|
+
color: oklch(0.72 0.14 25);
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
/* ── Diff line backgrounds ────────────────────────────── */
|
|
27
46
|
.code-rich-body .line.diff.add {
|
|
28
47
|
background-color: oklch(0.75 0.18 145 / 0.15);
|
|
@@ -66,3 +85,36 @@
|
|
|
66
85
|
padding: 1px 4px;
|
|
67
86
|
margin: 0 -1px;
|
|
68
87
|
}
|
|
88
|
+
|
|
89
|
+
/* ── `collapse` fence option: line runs folded behind a toggle ──
|
|
90
|
+
No script: a checkbox inside the label. Lines hide only where :has() works,
|
|
91
|
+
so a browser without it shows every line rather than losing them. */
|
|
92
|
+
.code-rich-body .code-collapse-label {
|
|
93
|
+
display: inline-block;
|
|
94
|
+
width: 100%;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
user-select: none;
|
|
97
|
+
/* The code's own colour: a muted label fell under 4.5:1 on the code background */
|
|
98
|
+
border-block: 1px dashed color-mix(in oklab, currentColor 35%, transparent);
|
|
99
|
+
font-style: italic;
|
|
100
|
+
}
|
|
101
|
+
.code-rich-body .code-collapse-toggle {
|
|
102
|
+
position: absolute;
|
|
103
|
+
width: 1px;
|
|
104
|
+
height: 1px;
|
|
105
|
+
opacity: 0;
|
|
106
|
+
}
|
|
107
|
+
.code-rich-body .code-collapse-label:has(.code-collapse-toggle:focus-visible) {
|
|
108
|
+
outline: 2px solid var(--ring);
|
|
109
|
+
outline-offset: -2px;
|
|
110
|
+
}
|
|
111
|
+
/* The label stays when open, so focus stays on the checkbox and it folds again */
|
|
112
|
+
.code-rich-body .code-collapse-label::before {
|
|
113
|
+
content: "▸ ";
|
|
114
|
+
}
|
|
115
|
+
.code-rich-body .code-collapse-label:has(.code-collapse-toggle:checked)::before {
|
|
116
|
+
content: "▾ ";
|
|
117
|
+
}
|
|
118
|
+
.code-rich-body .code-collapse:has(.code-collapse-toggle:not(:checked)) > .code-collapse-lines {
|
|
119
|
+
display: none;
|
|
120
|
+
}
|
|
@@ -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) => (
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* DetailsSheet — wraps a CollapsibleAlert (a <details>) so it opens as a
|
|
4
|
+
* side sheet. The markup stays a working <details>: with no script the
|
|
5
|
+
* content is a normal collapsible block. `<db-details-sheet>` (from
|
|
6
|
+
* @dogsbay/elements) moves the body into a native modal <dialog> on the right
|
|
7
|
+
* while it is open.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <DetailsSheet>
|
|
11
|
+
* <CollapsibleAlert title="Advanced options">…</CollapsibleAlert>
|
|
12
|
+
* </DetailsSheet>
|
|
13
|
+
*/
|
|
14
|
+
interface Props {
|
|
15
|
+
/** The close button's accessible name */
|
|
16
|
+
closeLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { closeLabel = "Close" } = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<db-details-sheet close-label={closeLabel}>
|
|
23
|
+
<slot />
|
|
24
|
+
</db-details-sheet>
|
|
25
|
+
|
|
26
|
+
<script>
|
|
27
|
+
import "@dogsbay/elements/db-details-sheet";
|
|
28
|
+
</script>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as DetailsSheet } from "./DetailsSheet.astro";
|
|
@@ -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>
|