@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
package/package.json
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/ui",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.121",
|
|
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
11
|
},
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"@
|
|
15
|
-
"
|
|
16
|
-
"
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"vitest": "^4.1.10",
|
|
14
|
+
"@shikijs/transformers": "^4.3.1",
|
|
15
|
+
"katex": "^0.16.47",
|
|
16
|
+
"maplibre-gl": "^5.24.0",
|
|
17
|
+
"shiki": "^4.3.1"
|
|
17
18
|
},
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"@shikijs/transformers": "^4.
|
|
21
|
-
"
|
|
22
|
-
"
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"astro": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
21
|
+
"@shikijs/transformers": "^4.3.1",
|
|
22
|
+
"katex": "^0.16.47",
|
|
23
|
+
"maplibre-gl": "^5.24.0",
|
|
24
|
+
"shiki": "^4.3.1",
|
|
25
|
+
"@dogsbay/elements": "0.2.0-beta.121",
|
|
26
|
+
"@dogsbay/icons": "0.2.0-beta.121",
|
|
27
|
+
"@dogsbay/primitives": "0.2.0-beta.121"
|
|
23
28
|
},
|
|
24
29
|
"files": [
|
|
25
30
|
"src",
|
|
@@ -34,5 +39,25 @@
|
|
|
34
39
|
"homepage": "https://github.com/dogsbay/dogsbay/tree/main/packages/ui",
|
|
35
40
|
"bugs": {
|
|
36
41
|
"url": "https://github.com/dogsbay/dogsbay/issues"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"astro": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@shikijs/transformers": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"katex": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"maplibre-gl": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"shiki": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"test": "vitest run"
|
|
37
62
|
}
|
|
38
63
|
}
|
|
@@ -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>
|
|
@@ -55,9 +55,10 @@ const content = code || (json !== undefined && json !== null ? JSON.stringify(js
|
|
|
55
55
|
|
|
56
56
|
{content && (
|
|
57
57
|
<div class={className}>
|
|
58
|
-
|
|
58
|
+
{/* A label, not a heading: on an operation page it followed the h1 (axe heading-order). */}
|
|
59
|
+
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-white/60">
|
|
59
60
|
{title}
|
|
60
|
-
</
|
|
61
|
+
</p>
|
|
61
62
|
<CodeBlock code={content} lang={resolvedLang} copy={true} />
|
|
62
63
|
</div>
|
|
63
64
|
)}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/* API Symbol badges
|
|
1
|
+
/* API Symbol badges, coloured by kind. Text on its own 20% tint must reach
|
|
2
|
+
WCAG AA (4.5:1) on the page background: the light colours were L 0.5–0.6
|
|
3
|
+
(3.2–4.0:1) and are L 0.44 (5.0:1 or more); render-html's
|
|
4
|
+
tests/contrast.test.ts measures every kind in both modes. */
|
|
2
5
|
.api-badge {
|
|
3
6
|
display: inline-flex;
|
|
4
7
|
align-items: center;
|
|
@@ -13,8 +16,8 @@
|
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
.api-badge-class {
|
|
16
|
-
background-color: oklch(0.
|
|
17
|
-
color: oklch(0.
|
|
19
|
+
background-color: oklch(0.44 0.18 265 / 0.2);
|
|
20
|
+
color: oklch(0.44 0.18 265);
|
|
18
21
|
}
|
|
19
22
|
.dark .api-badge-class {
|
|
20
23
|
background-color: oklch(0.7 0.15 265 / 0.2);
|
|
@@ -22,8 +25,8 @@
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
.api-badge-meth {
|
|
25
|
-
background-color: oklch(0.
|
|
26
|
-
color: oklch(0.
|
|
28
|
+
background-color: oklch(0.44 0.15 155 / 0.2);
|
|
29
|
+
color: oklch(0.44 0.15 155);
|
|
27
30
|
}
|
|
28
31
|
.dark .api-badge-meth {
|
|
29
32
|
background-color: oklch(0.7 0.15 155 / 0.2);
|
|
@@ -31,8 +34,8 @@
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
.api-badge-attr {
|
|
34
|
-
background-color: oklch(0.
|
|
35
|
-
color: oklch(0.
|
|
37
|
+
background-color: oklch(0.44 0.15 55 / 0.2);
|
|
38
|
+
color: oklch(0.44 0.15 55);
|
|
36
39
|
}
|
|
37
40
|
.dark .api-badge-attr {
|
|
38
41
|
background-color: oklch(0.75 0.15 55 / 0.2);
|
|
@@ -40,8 +43,8 @@
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
.api-badge-func {
|
|
43
|
-
background-color: oklch(0.
|
|
44
|
-
color: oklch(0.
|
|
46
|
+
background-color: oklch(0.44 0.18 330 / 0.2);
|
|
47
|
+
color: oklch(0.44 0.18 330);
|
|
45
48
|
}
|
|
46
49
|
.dark .api-badge-func {
|
|
47
50
|
background-color: oklch(0.7 0.15 330 / 0.2);
|
|
@@ -49,8 +52,8 @@
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
.api-badge-module {
|
|
52
|
-
background-color: oklch(0.
|
|
53
|
-
color: oklch(0.
|
|
55
|
+
background-color: oklch(0.44 0.12 200 / 0.2);
|
|
56
|
+
color: oklch(0.44 0.12 200);
|
|
54
57
|
}
|
|
55
58
|
.dark .api-badge-module {
|
|
56
59
|
background-color: oklch(0.65 0.12 200 / 0.2);
|
package/src/badge/Badge.astro
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { tv, type VariantProps } from "tailwind-variants";
|
|
3
|
+
import { BADGE_BASE, BADGE_VARIANTS } from "./variants.js";
|
|
3
4
|
|
|
4
5
|
const badge = tv({
|
|
5
|
-
base:
|
|
6
|
-
variants: {
|
|
7
|
-
variant: {
|
|
8
|
-
default: "border-transparent bg-primary text-primary-foreground",
|
|
9
|
-
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
10
|
-
destructive:
|
|
11
|
-
"border-transparent bg-destructive text-destructive-foreground",
|
|
12
|
-
outline: "text-foreground",
|
|
13
|
-
},
|
|
14
|
-
},
|
|
6
|
+
base: BADGE_BASE,
|
|
7
|
+
variants: { variant: BADGE_VARIANTS },
|
|
15
8
|
defaultVariants: {
|
|
16
9
|
variant: "default",
|
|
17
10
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Badge classes, shared by Badge.astro, ContentRenderer and @dogsbay/render-html
|
|
3
|
+
* (which copies this file with scripts/sync-sources.mjs), so a badge
|
|
4
|
+
* looks the same however a page is produced.
|
|
5
|
+
*/
|
|
6
|
+
export const BADGE_BASE =
|
|
7
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors";
|
|
8
|
+
|
|
9
|
+
export const BADGE_VARIANTS = {
|
|
10
|
+
default: "border-transparent bg-primary text-primary-foreground",
|
|
11
|
+
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
12
|
+
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
|
13
|
+
outline: "text-foreground",
|
|
14
|
+
// Status colours, from the callout palette (`:badge[Beta]{variant="warning"}`).
|
|
15
|
+
// The TEXT is the accent mixed 60% toward --foreground; the tint keeps the
|
|
16
|
+
// accent at full strength. The accent used raw measured 1.85-2.6:1 in light
|
|
17
|
+
// mode (danger 3.3-3.9:1 in dark too), from painted pixels. --foreground is
|
|
18
|
+
// near-black in light and near-white in dark, so one rule serves both themes
|
|
19
|
+
// (worst case 4.95:1); a fixed dark colour would fail dark mode. The same
|
|
20
|
+
// rule as the in-cell admonitions in format-astro's theme-css.ts.
|
|
21
|
+
// The `color:` hint is required: tailwind-merge 2.x (Badge.astro's tv())
|
|
22
|
+
// reads a bare text-[... 60% ...] as a FONT SIZE and drops text-xs.
|
|
23
|
+
// Checked per preset by @dogsbay/render-html tests/contrast.test.ts, and
|
|
24
|
+
// through tv() by ui tests/badge.test.ts.
|
|
25
|
+
info: "border-transparent bg-info/15 text-[color:color-mix(in_oklab,var(--info)_60%,var(--foreground))]",
|
|
26
|
+
success: "border-transparent bg-success/15 text-[color:color-mix(in_oklab,var(--success)_60%,var(--foreground))]",
|
|
27
|
+
warning: "border-transparent bg-warning/15 text-[color:color-mix(in_oklab,var(--warning)_60%,var(--foreground))]",
|
|
28
|
+
danger: "border-transparent bg-danger/15 text-[color:color-mix(in_oklab,var(--danger)_60%,var(--foreground))]",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export type BadgeVariant = keyof typeof BADGE_VARIANTS;
|
|
32
|
+
|
|
33
|
+
/** The class list for a variant; an unknown variant gets the base classes only. */
|
|
34
|
+
export function badgeClass(variant: string | undefined): string {
|
|
35
|
+
const v = BADGE_VARIANTS[(variant ?? "default") as BadgeVariant];
|
|
36
|
+
return v ? `${BADGE_BASE} ${v}` : BADGE_BASE;
|
|
37
|
+
}
|
|
@@ -21,6 +21,10 @@ interface Props {
|
|
|
21
21
|
lang?: string;
|
|
22
22
|
/** Optional title displayed in the header bar */
|
|
23
23
|
title?: string;
|
|
24
|
+
/*
|
|
25
|
+
* A `caption` slot makes the block a figure: the header bar is its
|
|
26
|
+
* <figcaption>, showing the caption with the language beside it.
|
|
27
|
+
*/
|
|
24
28
|
/** Show copy button (default: true) */
|
|
25
29
|
copy?: boolean;
|
|
26
30
|
/** Show the header title bar. true = always, false = never, "auto" = only when title prop is set (default: true) */
|
|
@@ -42,26 +46,51 @@ const {
|
|
|
42
46
|
...rest
|
|
43
47
|
} = Astro.props;
|
|
44
48
|
|
|
49
|
+
// AsciiDoc/docs language tokens that are not Shiki grammars → the nearest real
|
|
50
|
+
// one, so the block actually highlights (and data-language is meaningful)
|
|
51
|
+
// instead of silently falling back to plaintext. The header still shows the
|
|
52
|
+
// original label (`lang` below), only the highlighter input is aliased. #009.
|
|
53
|
+
const LANG_ALIASES: Record<string, string> = {
|
|
54
|
+
terminal: "bash",
|
|
55
|
+
};
|
|
56
|
+
const shikiLang = LANG_ALIASES[lang.toLowerCase()] ?? lang.toLowerCase();
|
|
57
|
+
|
|
45
58
|
// Show header bar based on showTitle prop:
|
|
46
59
|
// - true: show when there's a title or a declared language (default, original behavior)
|
|
47
60
|
// - false: never show
|
|
48
61
|
// - "auto": only show when an explicit title is provided (not lang-only)
|
|
62
|
+
const hasCaption = Astro.slots.has("caption");
|
|
63
|
+
const Wrapper = hasCaption ? "figure" : "div";
|
|
64
|
+
const Header = hasCaption ? "figcaption" : "div";
|
|
49
65
|
const showHeader =
|
|
50
|
-
|
|
66
|
+
hasCaption ? true
|
|
67
|
+
: showTitle === false ? false
|
|
51
68
|
: showTitle === "auto" ? !!title
|
|
52
69
|
: !!title || lang !== "plaintext";
|
|
53
70
|
// When no header, copy button floats inside the code body
|
|
54
71
|
const floatingCopy = copy && !showHeader;
|
|
55
72
|
---
|
|
56
73
|
|
|
57
|
-
<
|
|
58
|
-
class={codeBlockWrapper({ class: className })}
|
|
74
|
+
<Wrapper
|
|
75
|
+
class={codeBlockWrapper({ class: [hasCaption && "m-0", className] })}
|
|
59
76
|
data-component="code-block"
|
|
60
77
|
{...rest}
|
|
61
78
|
>
|
|
62
79
|
{showHeader && (
|
|
63
|
-
<
|
|
64
|
-
|
|
80
|
+
<Header class={codeBlockHeader()}>
|
|
81
|
+
{/*
|
|
82
|
+
Left: what the code is (caption or filename title), or the language when that is
|
|
83
|
+
all there is. Right: the language beside the copy button, as secondary detail
|
|
84
|
+
*/}
|
|
85
|
+
{hasCaption ? (
|
|
86
|
+
<span class="min-w-0 font-medium text-code-fg" data-part="caption"><slot name="caption" /></span>
|
|
87
|
+
) : title ? (
|
|
88
|
+
<span class="min-w-0 font-medium text-code-fg" data-part="title">{title}</span>
|
|
89
|
+
) : (
|
|
90
|
+
<span class="select-none">{lang}</span>
|
|
91
|
+
)}
|
|
92
|
+
<span class="flex shrink-0 items-center gap-2">
|
|
93
|
+
{(hasCaption || title) && lang !== "plaintext" && <span class="select-none" data-part="language">{lang}</span>}
|
|
65
94
|
{copy && (
|
|
66
95
|
<button
|
|
67
96
|
class={codeBlockCopy()}
|
|
@@ -104,10 +133,11 @@ const floatingCopy = copy && !showHeader;
|
|
|
104
133
|
</svg>
|
|
105
134
|
</button>
|
|
106
135
|
)}
|
|
107
|
-
|
|
136
|
+
</span>
|
|
137
|
+
</Header>
|
|
108
138
|
)}
|
|
109
139
|
<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={
|
|
140
|
+
<Code code={code} lang={shikiLang} themes={{ light: "github-light-default", dark: theme }} defaultColor={false} />
|
|
111
141
|
{floatingCopy && (
|
|
112
142
|
<button
|
|
113
143
|
class:list={[codeBlockCopy(), "absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"]}
|
|
@@ -126,7 +156,7 @@ const floatingCopy = copy && !showHeader;
|
|
|
126
156
|
</button>
|
|
127
157
|
)}
|
|
128
158
|
</div>
|
|
129
|
-
</
|
|
159
|
+
</Wrapper>
|
|
130
160
|
|
|
131
161
|
<script>
|
|
132
162
|
function setupCodeBlockCopy() {
|
|
@@ -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,21 @@ 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;
|
|
47
67
|
class?: string;
|
|
48
68
|
[key: string]: unknown;
|
|
49
69
|
}
|
|
@@ -58,10 +78,28 @@ const {
|
|
|
58
78
|
highlights,
|
|
59
79
|
wordHighlights,
|
|
60
80
|
theme = "github-dark-default",
|
|
81
|
+
copyText: copyTextOverride,
|
|
82
|
+
diffAdd,
|
|
83
|
+
diffRemove,
|
|
61
84
|
class: className,
|
|
62
85
|
...rest
|
|
63
86
|
} = Astro.props;
|
|
64
87
|
|
|
88
|
+
// "2,5-6" → Set{2,5,6}
|
|
89
|
+
const parseLineSpec = (spec?: string): Set<number> => {
|
|
90
|
+
const out = new Set<number>();
|
|
91
|
+
for (const part of (spec ?? "").split(",")) {
|
|
92
|
+
const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
|
|
93
|
+
if (!range) continue;
|
|
94
|
+
const start = Number(range[1]);
|
|
95
|
+
const end = range[2] ? Number(range[2]) : start;
|
|
96
|
+
for (let n = start; n <= end; n++) out.add(n);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
const addLines = parseLineSpec(diffAdd);
|
|
101
|
+
const removeLines = parseLineSpec(diffRemove);
|
|
102
|
+
|
|
65
103
|
// Build transformers list
|
|
66
104
|
const transformers: ShikiTransformer[] = [
|
|
67
105
|
transformerNotationDiff(),
|
|
@@ -71,6 +109,19 @@ const transformers: ShikiTransformer[] = [
|
|
|
71
109
|
transformerNotationWordHighlight(),
|
|
72
110
|
];
|
|
73
111
|
|
|
112
|
+
// Line-number diffs (language-independent — see the prop docs)
|
|
113
|
+
if (addLines.size || removeLines.size) {
|
|
114
|
+
transformers.push({
|
|
115
|
+
name: "line-diff",
|
|
116
|
+
line(node, line) {
|
|
117
|
+
const kind = addLines.has(line) ? "add" : removeLines.has(line) ? "remove" : null;
|
|
118
|
+
if (!kind) return;
|
|
119
|
+
const cls = node.properties.class;
|
|
120
|
+
node.properties.class = `${Array.isArray(cls) ? cls.join(" ") : (cls ?? "")} diff ${kind}`.trim();
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
74
125
|
// Meta-based highlights: ```ts {1,3-5}
|
|
75
126
|
if (highlights) {
|
|
76
127
|
transformers.push(transformerMetaHighlight());
|
|
@@ -136,14 +187,25 @@ let metaStr = "";
|
|
|
136
187
|
if (highlights) metaStr += `{${highlights}} `;
|
|
137
188
|
if (wordHighlights) metaStr += `/${wordHighlights}/ `;
|
|
138
189
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.trim();
|
|
190
|
+
// Copy text: explicit override (diff blocks pass the post-change code)
|
|
191
|
+
// or the block's own code — either way every annotation form is
|
|
192
|
+
// stripped (see ./markers.ts).
|
|
193
|
+
const copyText = stripMarkersForCopy(copyTextOverride ?? code);
|
|
144
194
|
|
|
145
|
-
|
|
146
|
-
|
|
195
|
+
// Unknown languages (package-install, mdx-ish doc tokens) fall back
|
|
196
|
+
// to plaintext instead of throwing — same behavior as CodeBlock's
|
|
197
|
+
// <Code>. The header still shows the declared language label.
|
|
198
|
+
const requestedLang = lang.toLowerCase();
|
|
199
|
+
const isPlainGrammar =
|
|
200
|
+
!(requestedLang in bundledLanguages) || ["plaintext", "text", "txt", "ansi"].includes(requestedLang);
|
|
201
|
+
const shikiLang = isPlainGrammar ? "plaintext" : requestedLang;
|
|
202
|
+
|
|
203
|
+
// Markers Shiki cannot process would render as literal text — strip
|
|
204
|
+
// exactly those, leave the rest for the notation transformers.
|
|
205
|
+
const highlightCode = stripUnprocessableMarkers(code, isPlainGrammar);
|
|
206
|
+
|
|
207
|
+
const html = await codeToHtml(highlightCode, {
|
|
208
|
+
lang: shikiLang as BundledLanguage,
|
|
147
209
|
themes: {
|
|
148
210
|
light: "github-light-default" as BundledTheme,
|
|
149
211
|
dark: (theme || "github-dark-default") as BundledTheme,
|
|
@@ -154,21 +216,36 @@ const html = await codeToHtml(code, {
|
|
|
154
216
|
});
|
|
155
217
|
|
|
156
218
|
// Show header bar only when there's a title or a declared language
|
|
157
|
-
const
|
|
219
|
+
const hasCaption = Astro.slots.has("caption");
|
|
220
|
+
const Wrapper = hasCaption ? "figure" : "div";
|
|
221
|
+
const Header = hasCaption ? "figcaption" : "div";
|
|
222
|
+
const hasLabel = hasCaption || !!title || (lang !== "plaintext");
|
|
158
223
|
const showHeader = hasLabel;
|
|
159
224
|
// When no header, copy button floats inside the code body
|
|
160
225
|
const floatingCopy = copy && !showHeader;
|
|
161
226
|
---
|
|
162
227
|
|
|
163
|
-
<
|
|
164
|
-
class={codeRichWrapper({ class: className })}
|
|
228
|
+
<Wrapper
|
|
229
|
+
class={codeRichWrapper({ class: [hasCaption && "m-0", className] })}
|
|
165
230
|
data-component="code-rich"
|
|
166
231
|
data-line-numbers={lineNumbers || undefined}
|
|
167
232
|
{...rest}
|
|
168
233
|
>
|
|
169
234
|
{showHeader && (
|
|
170
|
-
<
|
|
171
|
-
|
|
235
|
+
<Header class={codeRichHeader()}>
|
|
236
|
+
{/*
|
|
237
|
+
Left: what the code is (caption or filename title), or the language when that is
|
|
238
|
+
all there is. Right: the language beside the copy button, as secondary detail
|
|
239
|
+
*/}
|
|
240
|
+
{hasCaption ? (
|
|
241
|
+
<span class="min-w-0 font-medium text-code-fg" data-part="caption"><slot name="caption" /></span>
|
|
242
|
+
) : title ? (
|
|
243
|
+
<span class="min-w-0 font-medium text-code-fg" data-part="title">{title}</span>
|
|
244
|
+
) : (
|
|
245
|
+
<span class="select-none">{lang}</span>
|
|
246
|
+
)}
|
|
247
|
+
<span class="flex shrink-0 items-center gap-2">
|
|
248
|
+
{(hasCaption || title) && lang !== "plaintext" && <span class="select-none" data-part="language">{lang}</span>}
|
|
172
249
|
{copy && (
|
|
173
250
|
<button
|
|
174
251
|
class={codeRichCopy()}
|
|
@@ -211,7 +288,8 @@ const floatingCopy = copy && !showHeader;
|
|
|
211
288
|
</svg>
|
|
212
289
|
</button>
|
|
213
290
|
)}
|
|
214
|
-
|
|
291
|
+
</span>
|
|
292
|
+
</Header>
|
|
215
293
|
)}
|
|
216
294
|
<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
295
|
<Fragment set:html={html} />
|
|
@@ -233,7 +311,7 @@ const floatingCopy = copy && !showHeader;
|
|
|
233
311
|
</button>
|
|
234
312
|
)}
|
|
235
313
|
</div>
|
|
236
|
-
</
|
|
314
|
+
</Wrapper>
|
|
237
315
|
|
|
238
316
|
<script>
|
|
239
317
|
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);
|