@marianmeres/stuic 3.191.0 → 3.192.0
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/AGENTS.md +2 -2
- package/API.md +29 -0
- package/dist/components/CodeBlock/CodeBlock.fixture.svelte +57 -0
- package/dist/components/CodeBlock/CodeBlock.fixture.svelte.d.ts +10 -0
- package/dist/components/CodeBlock/CodeBlock.svelte +441 -0
- package/dist/components/CodeBlock/CodeBlock.svelte.d.ts +113 -0
- package/dist/components/CodeBlock/README.md +336 -0
- package/dist/components/CodeBlock/_internal/lines.d.ts +13 -0
- package/dist/components/CodeBlock/_internal/lines.js +37 -0
- package/dist/components/CodeBlock/_internal/normalize-code.d.ts +9 -0
- package/dist/components/CodeBlock/_internal/normalize-code.js +39 -0
- package/dist/components/CodeBlock/_internal/paint.d.ts +20 -0
- package/dist/components/CodeBlock/_internal/paint.js +81 -0
- package/dist/components/CodeBlock/highlight/http.d.ts +6 -0
- package/dist/components/CodeBlock/highlight/http.js +62 -0
- package/dist/components/CodeBlock/highlight/index.d.ts +14 -0
- package/dist/components/CodeBlock/highlight/index.js +29 -0
- package/dist/components/CodeBlock/highlight/json.d.ts +9 -0
- package/dist/components/CodeBlock/highlight/json.js +74 -0
- package/dist/components/CodeBlock/highlight/shell.d.ts +8 -0
- package/dist/components/CodeBlock/highlight/shell.js +230 -0
- package/dist/components/CodeBlock/highlight/types.d.ts +17 -0
- package/dist/components/CodeBlock/highlight/types.js +1 -0
- package/dist/components/CodeBlock/i18n-sk.d.ts +17 -0
- package/dist/components/CodeBlock/i18n-sk.js +22 -0
- package/dist/components/CodeBlock/i18n.d.ts +37 -0
- package/dist/components/CodeBlock/i18n.js +34 -0
- package/dist/components/CodeBlock/index.css +470 -0
- package/dist/components/CodeBlock/index.d.ts +4 -0
- package/dist/components/CodeBlock/index.js +4 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/domains/components.md +59 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
```
|
|
25
25
|
src/lib/
|
|
26
|
-
├── components/ #
|
|
26
|
+
├── components/ # 83 component directories
|
|
27
27
|
├── actions/ # 16 Svelte actions (use: directives)
|
|
28
28
|
├── attachments/ # Svelte attachments ({@attach} — preferred for new DOM helpers)
|
|
29
29
|
├── utils/ # 55 utility modules (48 on the barrel)
|
|
@@ -146,7 +146,7 @@ so it is the only confusable pair — do not "fix" one into the other.
|
|
|
146
146
|
|
|
147
147
|
### Domain Docs
|
|
148
148
|
|
|
149
|
-
- [Components](./docs/domains/components.md) —
|
|
149
|
+
- [Components](./docs/domains/components.md) — 83 component directories, Props pattern, snippets
|
|
150
150
|
- [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
|
|
151
151
|
- [CSS presets](./docs/domains/css-presets.md) — ratio-locked frame (letterbox), safe-area, scrollbar
|
|
152
152
|
- [Actions](./docs/domains/actions.md) — 16 Svelte directives
|
package/API.md
CHANGED
|
@@ -1273,6 +1273,35 @@ Horizontal schedule chart — project plans (task per row, progress, milestones)
|
|
|
1273
1273
|
|
|
1274
1274
|
The geometry is exported separately for axis-aligned overlays: `buildGanttAxis`, `placeRange`, `placePoint`, `dayToFraction`, `boundsOf`.
|
|
1275
1275
|
|
|
1276
|
+
#### `CodeBlock`
|
|
1277
|
+
|
|
1278
|
+
Copyable code sample: a bordered box with a header (the language or a file name, or tabs for several samples, + a borderless `CopyButton`) over a `<pre><code>`. Rendered as text; copies exactly what is shown. JSON, HTTP and shell are syntax-highlighted by default with the CSS Custom Highlight API (no markup, no dependency; plain text where unsupported). The root carries `not-prose`.
|
|
1279
|
+
|
|
1280
|
+
| Prop | Type | Default | Description |
|
|
1281
|
+
| ----------------- | --------------------------------- | ------- | ------------------------------------------------------------------------------------- |
|
|
1282
|
+
| `code` | `string` | — | The sample (ignored with `samples`) |
|
|
1283
|
+
| `lang` | `string` | — | Header label, what is highlighted, `data-lang`, `language-{lang}` class |
|
|
1284
|
+
| `title` | `THC` | — | Header label in place of `lang`; with `samples`, shown before the tabs |
|
|
1285
|
+
| `samples` | `CodeBlockSample[]` | — | `{ code, lang?, label?, id?, highlightLines?, copyText? }[]` behind tabs |
|
|
1286
|
+
| `active` | `string` | — | Shown sample's id (bindable) — share one bound value to sync blocks |
|
|
1287
|
+
| `highlight` | `boolean \| CodeBlockHighlighter` | `true` | Built-in `highlightCode`, your own `(code, lang) => [start, end, type][]`, or off |
|
|
1288
|
+
| `lineNumbers` | `boolean` | `false` | Line numbers (not selectable, not copied); `lineNumbersStart` offsets them |
|
|
1289
|
+
| `highlightLines` | `number[] \| string` | — | Lines to mark, as positions in the sample (`"1, 3-5"`) |
|
|
1290
|
+
| `collapsedLines` | `number` | — | Collapse longer samples to N lines with a "Show all N lines" toggle (`bind:expanded`) |
|
|
1291
|
+
| `verbatim` | `boolean` | `false` | Keep `code` as given (default: drop blank end lines and the shared indent) |
|
|
1292
|
+
| `wrap` | `boolean` | `false` | Soft-wrap instead of scrolling horizontally |
|
|
1293
|
+
| `copy` | `boolean` | `true` | Render the copy button |
|
|
1294
|
+
| `copyButtonProps` | `Partial<CopyButtonProps>` | — | Pass-through to the `CopyButton`; its `text` overrides what is copied |
|
|
1295
|
+
| `t` | `TranslateFn` | English | `createCodeBlockT(CODE_BLOCK_MESSAGES_SK)` — also localizes the copy button |
|
|
1296
|
+
|
|
1297
|
+
Class slots: `class`, `classHeader`, `classTitle`, `classTabs`, `classTab`, `classPre`, `classCode`, `classLine`, `classFooter`, `classToggle`. Also exported: `highlightCode`, `highlightJson`, `highlightHttp`, `highlightShell`, `HIGHLIGHT_CODE_LANGS`, types `CodeBlockSample`, `CodeBlockToken`, `CodeBlockTokenType`, `CodeBlockHighlighter`.
|
|
1298
|
+
|
|
1299
|
+
```svelte
|
|
1300
|
+
<CodeBlock lang="bash" code={quickstart} />
|
|
1301
|
+
<CodeBlock lang="json" title="deno.json" code={config} lineNumbers highlightLines="2-3" />
|
|
1302
|
+
<CodeBlock title="Create an item" {samples} bind:active={language} collapsedLines={15} />
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1276
1305
|
#### `ListGroup`
|
|
1277
1306
|
|
|
1278
1307
|
Bordered, rounded box of rows split by hairlines, with an optional header (title + aside) and footer. Each row is one wrapping flex line of parts; `data-grow` on the part that should absorb the slack. Generic over the item type.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Harness for what `rerender()` can't drive: `rerender()` re-signals every prop (so it
|
|
3
|
+
// would re-run the measuring and painting effects on its own and hide a missing
|
|
4
|
+
// dependency on the code), and it can't observe a written `$bindable`.
|
|
5
|
+
import CodeBlock, { type CodeBlockSample } from "./CodeBlock.svelte";
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
mode = "swap",
|
|
9
|
+
initial = "",
|
|
10
|
+
next = "",
|
|
11
|
+
lang,
|
|
12
|
+
}: {
|
|
13
|
+
mode?: "swap" | "sync" | "expanded";
|
|
14
|
+
initial?: string;
|
|
15
|
+
next?: string;
|
|
16
|
+
lang?: string;
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
// swap: `code` alone changes, from inside
|
|
20
|
+
// svelte-ignore state_referenced_locally
|
|
21
|
+
let code = $state(initial);
|
|
22
|
+
let lineNumbers = $state(false);
|
|
23
|
+
|
|
24
|
+
// sync: two tabbed blocks share one bound `active`
|
|
25
|
+
let active = $state<string | undefined>();
|
|
26
|
+
const first: CodeBlockSample[] = [
|
|
27
|
+
{ label: "curl", lang: "bash", code: "curl https://x.test" },
|
|
28
|
+
{ label: "Python", lang: "python", code: "requests.get('https://x.test')" },
|
|
29
|
+
];
|
|
30
|
+
const second: CodeBlockSample[] = [
|
|
31
|
+
{ label: "curl", lang: "bash", code: "curl -X POST https://x.test" },
|
|
32
|
+
{ label: "fetch", lang: "js", code: "await fetch('https://x.test')" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// expanded: a written bindable, mirrored out
|
|
36
|
+
let expanded = $state(false);
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
{#if mode === "swap"}
|
|
40
|
+
<button type="button" onclick={() => (code = code === initial ? next : initial)}
|
|
41
|
+
>swap</button
|
|
42
|
+
>
|
|
43
|
+
<button type="button" onclick={() => (lineNumbers = !lineNumbers)}>numbers</button>
|
|
44
|
+
<CodeBlock {code} {lang} {lineNumbers} copy={false} style="width: 240px" />
|
|
45
|
+
{:else if mode === "sync"}
|
|
46
|
+
<output data-testid="active">{active ?? ""}</output>
|
|
47
|
+
<CodeBlock samples={first} bind:active copy={false} data-testid="first" />
|
|
48
|
+
<CodeBlock samples={second} bind:active copy={false} data-testid="second" />
|
|
49
|
+
{:else}
|
|
50
|
+
<output data-testid="expanded">{expanded}</output>
|
|
51
|
+
<CodeBlock
|
|
52
|
+
code={Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n")}
|
|
53
|
+
collapsedLines={4}
|
|
54
|
+
bind:expanded
|
|
55
|
+
copy={false}
|
|
56
|
+
/>
|
|
57
|
+
{/if}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import CodeBlock from "./CodeBlock.svelte";
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
mode?: "swap" | "sync" | "expanded";
|
|
4
|
+
initial?: string;
|
|
5
|
+
next?: string;
|
|
6
|
+
lang?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const CodeBlock: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type CodeBlock = ReturnType<typeof CodeBlock>;
|
|
10
|
+
export default CodeBlock;
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import type { THC } from "../Thc/Thc.svelte";
|
|
4
|
+
import type { Props as CopyButtonProps } from "../CopyButton/CopyButton.svelte";
|
|
5
|
+
import type { TranslateFn } from "../../types.js";
|
|
6
|
+
import type { CodeBlockHighlighter, CodeBlockToken } from "./highlight/types.js";
|
|
7
|
+
|
|
8
|
+
/** One sample of a tabbed block — the same thing in another language or tool. */
|
|
9
|
+
export interface CodeBlockSample {
|
|
10
|
+
/** The sample (rendered as text) */
|
|
11
|
+
code: string;
|
|
12
|
+
/** What it is written in — the tab label (unless `label`), and what is highlighted */
|
|
13
|
+
lang?: string;
|
|
14
|
+
/** The tab label, in place of `lang` */
|
|
15
|
+
label?: THC;
|
|
16
|
+
/**
|
|
17
|
+
* Identity for `active`. Defaults to a string `label`, else `lang`, else the index —
|
|
18
|
+
* so blocks sharing one bound `active` stay in sync by what the reader sees.
|
|
19
|
+
*/
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Lines to highlight in this sample (overrides the block's `highlightLines`) */
|
|
22
|
+
highlightLines?: number[] | string;
|
|
23
|
+
/** What the copy button copies for this sample, in place of the displayed text */
|
|
24
|
+
copyText?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Props extends Omit<
|
|
28
|
+
HTMLAttributes<HTMLDivElement>,
|
|
29
|
+
"children" | "title"
|
|
30
|
+
> {
|
|
31
|
+
/** The sample. Always rendered as text, never as HTML. Ignored with `samples`. */
|
|
32
|
+
code?: string;
|
|
33
|
+
/**
|
|
34
|
+
* What the sample is written in. A label, and the highlighter's hint: it is the header
|
|
35
|
+
* label (unless `title` is set), `data-lang` on the root, and a `language-*` class on
|
|
36
|
+
* the `<code>`. Ignored with `samples` (each has its own).
|
|
37
|
+
*/
|
|
38
|
+
lang?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Header label in place of `lang` — e.g. a file name. `""` hides the label. With
|
|
41
|
+
* `samples` it is shown before the tabs and names the tab list.
|
|
42
|
+
*/
|
|
43
|
+
title?: THC;
|
|
44
|
+
/** Several samples of the same thing (curl / fetch / Python…), switched by tabs */
|
|
45
|
+
samples?: CodeBlockSample[];
|
|
46
|
+
/**
|
|
47
|
+
* The id of the shown sample (see `CodeBlockSample.id`); bindable. Unset → the first
|
|
48
|
+
* sample. An id this block doesn't have leaves it on the sample it showed, and the
|
|
49
|
+
* value is never rewritten — so several blocks can share one bound `active`.
|
|
50
|
+
*/
|
|
51
|
+
active?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Render the code exactly as given. By default the blank lines at both ends and the
|
|
54
|
+
* indentation all lines share are dropped (from the display and the copy alike).
|
|
55
|
+
*/
|
|
56
|
+
verbatim?: boolean;
|
|
57
|
+
/** Soft-wrap long lines instead of scrolling horizontally */
|
|
58
|
+
wrap?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Syntax highlighting. `true` (default): the built-in `highlightCode` (JSON, HTTP,
|
|
61
|
+
* shell — other languages stay plain). A function: your own tokenizer. `false`: off.
|
|
62
|
+
* Painted with the CSS Custom Highlight API — no markup; browsers without it show
|
|
63
|
+
* plain text.
|
|
64
|
+
*/
|
|
65
|
+
highlight?: boolean | CodeBlockHighlighter;
|
|
66
|
+
/** Show line numbers (not selectable, not copied) */
|
|
67
|
+
lineNumbers?: boolean;
|
|
68
|
+
/** The first line's number (default `1`) */
|
|
69
|
+
lineNumbersStart?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Lines to highlight: 1-based positions in the sample (not the displayed numbers),
|
|
72
|
+
* as an array or a string like `"1, 3-5"`.
|
|
73
|
+
*/
|
|
74
|
+
highlightLines?: number[] | string;
|
|
75
|
+
/**
|
|
76
|
+
* Collapse samples longer than this many lines to this many, with a "Show all N
|
|
77
|
+
* lines" toggle.
|
|
78
|
+
*/
|
|
79
|
+
collapsedLines?: number;
|
|
80
|
+
/** Whether a collapsible sample is expanded; bindable */
|
|
81
|
+
expanded?: boolean;
|
|
82
|
+
/** Render the copy button (default `true`) */
|
|
83
|
+
copy?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Props for the copy button (a `CopyButton`: `label`, `variant`, `onCopied`, …). A
|
|
86
|
+
* `text` here overrides what gets copied — e.g. a shell sample without its `$ `
|
|
87
|
+
* prompts (a sample's own `copyText` wins over it).
|
|
88
|
+
*/
|
|
89
|
+
copyButtonProps?: Partial<CopyButtonProps>;
|
|
90
|
+
/** i18n translate function (see `createCodeBlockT`); also passed to the copy button */
|
|
91
|
+
t?: TranslateFn;
|
|
92
|
+
/** Skip all default styling */
|
|
93
|
+
unstyled?: boolean;
|
|
94
|
+
/** Additional CSS classes for the root */
|
|
95
|
+
class?: string;
|
|
96
|
+
/** Class for the header row */
|
|
97
|
+
classHeader?: string;
|
|
98
|
+
/** Class for the header label */
|
|
99
|
+
classTitle?: string;
|
|
100
|
+
/** Class for the tab list */
|
|
101
|
+
classTabs?: string;
|
|
102
|
+
/** Class for every tab */
|
|
103
|
+
classTab?: string;
|
|
104
|
+
/** Class for the `<pre>` */
|
|
105
|
+
classPre?: string;
|
|
106
|
+
/** Class for the `<code>` */
|
|
107
|
+
classCode?: string;
|
|
108
|
+
/** Class for every line (only rendered with `lineNumbers` / `highlightLines`) */
|
|
109
|
+
classLine?: string;
|
|
110
|
+
/** Class for the footer holding the collapse toggle */
|
|
111
|
+
classFooter?: string;
|
|
112
|
+
/** Class for the collapse toggle */
|
|
113
|
+
classToggle?: string;
|
|
114
|
+
/** Bindable root element reference */
|
|
115
|
+
el?: HTMLDivElement;
|
|
116
|
+
}
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<script lang="ts">
|
|
120
|
+
import { tick } from "svelte";
|
|
121
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
122
|
+
import { getId } from "../../utils/get-id.js";
|
|
123
|
+
import { iconChevronDown } from "../../icons/index.js";
|
|
124
|
+
import Thc, { isTHCNotEmpty } from "../Thc/Thc.svelte";
|
|
125
|
+
import Button from "../Button/Button.svelte";
|
|
126
|
+
import CopyButton from "../CopyButton/CopyButton.svelte";
|
|
127
|
+
import { highlightCode } from "./highlight/index.js";
|
|
128
|
+
import { normalizeCode } from "./_internal/normalize-code.js";
|
|
129
|
+
import { parseLineSet, splitLines } from "./_internal/lines.js";
|
|
130
|
+
import { paintTokens } from "./_internal/paint.js";
|
|
131
|
+
import { t_default } from "./i18n.js";
|
|
132
|
+
|
|
133
|
+
let {
|
|
134
|
+
code,
|
|
135
|
+
lang,
|
|
136
|
+
title,
|
|
137
|
+
samples,
|
|
138
|
+
active = $bindable(),
|
|
139
|
+
verbatim = false,
|
|
140
|
+
wrap = false,
|
|
141
|
+
highlight = true,
|
|
142
|
+
lineNumbers = false,
|
|
143
|
+
lineNumbersStart = 1,
|
|
144
|
+
highlightLines,
|
|
145
|
+
collapsedLines,
|
|
146
|
+
expanded = $bindable(false),
|
|
147
|
+
copy = true,
|
|
148
|
+
copyButtonProps,
|
|
149
|
+
t = t_default,
|
|
150
|
+
unstyled = false,
|
|
151
|
+
class: classProp,
|
|
152
|
+
classHeader: classHeaderProp,
|
|
153
|
+
classTitle: classTitleProp,
|
|
154
|
+
classTabs: classTabsProp,
|
|
155
|
+
classTab: classTabProp,
|
|
156
|
+
classPre: classPreProp,
|
|
157
|
+
classCode: classCodeProp,
|
|
158
|
+
classLine: classLineProp,
|
|
159
|
+
classFooter: classFooterProp,
|
|
160
|
+
classToggle: classToggleProp,
|
|
161
|
+
el = $bindable(),
|
|
162
|
+
...rest
|
|
163
|
+
}: Props = $props();
|
|
164
|
+
|
|
165
|
+
const uid = getId("stuic-code-block-");
|
|
166
|
+
const preId = `${uid}-code`;
|
|
167
|
+
const titleId = `${uid}-title`;
|
|
168
|
+
const tabId = (i: number) => `${uid}-tab-${i}`;
|
|
169
|
+
|
|
170
|
+
// --- samples (tabs) ---------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
let _samples = $derived(samples?.length ? samples : undefined);
|
|
173
|
+
|
|
174
|
+
// Ids as documented, deduplicated so every tab stays selectable.
|
|
175
|
+
let sampleIds = $derived.by(() => {
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
return (_samples ?? []).map((s, i) => {
|
|
178
|
+
let id =
|
|
179
|
+
s.id ?? (typeof s.label === "string" && s.label ? s.label : s.lang) ?? `${i}`;
|
|
180
|
+
if (seen.has(id)) id = `${id}-${i}`;
|
|
181
|
+
seen.add(id);
|
|
182
|
+
return id;
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// An `active` this block doesn't have (another block's language, when several share
|
|
187
|
+
// one bound value) keeps showing what it showed — initially the first sample.
|
|
188
|
+
let lastIndex = 0;
|
|
189
|
+
let activeIndex = $derived.by(() => {
|
|
190
|
+
if (!_samples) return -1;
|
|
191
|
+
const i = sampleIds.indexOf(active as string);
|
|
192
|
+
if (i >= 0) lastIndex = i;
|
|
193
|
+
return i >= 0 ? i : Math.min(lastIndex, _samples.length - 1);
|
|
194
|
+
});
|
|
195
|
+
let current = $derived(_samples?.[activeIndex]);
|
|
196
|
+
|
|
197
|
+
// Keyed by index, written through `bind:this`; only read in handlers.
|
|
198
|
+
const tabEls: HTMLButtonElement[] = [];
|
|
199
|
+
|
|
200
|
+
function selectTab(i: number, focus = false) {
|
|
201
|
+
active = sampleIds[i];
|
|
202
|
+
if (focus) tabEls[i]?.focus();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function onTabKeydown(e: KeyboardEvent, i: number) {
|
|
206
|
+
const n = sampleIds.length;
|
|
207
|
+
const next =
|
|
208
|
+
e.key === "ArrowRight"
|
|
209
|
+
? (i + 1) % n
|
|
210
|
+
: e.key === "ArrowLeft"
|
|
211
|
+
? (i - 1 + n) % n
|
|
212
|
+
: e.key === "Home"
|
|
213
|
+
? 0
|
|
214
|
+
: e.key === "End"
|
|
215
|
+
? n - 1
|
|
216
|
+
: -1;
|
|
217
|
+
if (next < 0) return;
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
selectTab(next, true);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- the displayed text -------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
let _code = $derived(current ? current.code : (code ?? ""));
|
|
225
|
+
let _lang = $derived(current ? current.lang : lang);
|
|
226
|
+
let text = $derived(verbatim ? (_code ?? "") : normalizeCode(_code));
|
|
227
|
+
let lines = $derived(splitLines(text));
|
|
228
|
+
|
|
229
|
+
let _highlighted = $derived(
|
|
230
|
+
parseLineSet(current?.highlightLines ?? highlightLines, lines.length)
|
|
231
|
+
);
|
|
232
|
+
// one <span> per line only when something needs it; otherwise a single text node
|
|
233
|
+
let lined = $derived(lineNumbers || _highlighted.size > 0);
|
|
234
|
+
let digits = $derived(String(lineNumbersStart + Math.max(lines.length, 1) - 1).length);
|
|
235
|
+
|
|
236
|
+
let _title = $derived(_samples ? title : (title ?? lang));
|
|
237
|
+
let hasTitle = $derived(isTHCNotEmpty(_title));
|
|
238
|
+
|
|
239
|
+
let copyText = $derived(current?.copyText ?? copyButtonProps?.text ?? text);
|
|
240
|
+
|
|
241
|
+
// --- collapse ---------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
let collapsible = $derived(
|
|
244
|
+
!!collapsedLines && collapsedLines > 0 && lines.length > collapsedLines
|
|
245
|
+
);
|
|
246
|
+
let collapsed = $derived(collapsible && !expanded);
|
|
247
|
+
|
|
248
|
+
async function toggle() {
|
|
249
|
+
expanded = !expanded;
|
|
250
|
+
if (!expanded) {
|
|
251
|
+
// collapsing a long sample can leave the reader far below the block
|
|
252
|
+
await tick();
|
|
253
|
+
if (el && el.getBoundingClientRect().top < 0)
|
|
254
|
+
el.scrollIntoView({ block: "nearest" });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- keyboard reachability ----------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
// A scroll container must be reachable by keyboard, or its overflow can't be read
|
|
261
|
+
// without a mouse. Measured rather than always on, so a sample that fits costs no tab
|
|
262
|
+
// stop. Re-measured on resize and whenever the content or the layout changes (a longer
|
|
263
|
+
// single line does not resize the box). A collapsed sample's vertical overflow is
|
|
264
|
+
// hidden, not scrollable — the toggle is its way in. A tab panel is always a tab stop.
|
|
265
|
+
let preEl = $state<HTMLPreElement>();
|
|
266
|
+
let scrollable = $state(false);
|
|
267
|
+
$effect(() => {
|
|
268
|
+
const pre = preEl;
|
|
269
|
+
if (!pre) return;
|
|
270
|
+
void text;
|
|
271
|
+
void wrap;
|
|
272
|
+
void lined;
|
|
273
|
+
const isCollapsed = collapsed;
|
|
274
|
+
const measure = () => {
|
|
275
|
+
scrollable =
|
|
276
|
+
pre.scrollWidth > pre.clientWidth ||
|
|
277
|
+
(!isCollapsed && pre.scrollHeight > pre.clientHeight);
|
|
278
|
+
};
|
|
279
|
+
measure();
|
|
280
|
+
const ro = new ResizeObserver(measure);
|
|
281
|
+
ro.observe(pre);
|
|
282
|
+
return () => ro.disconnect();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- syntax highlighting ------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
let codeEl = $state<HTMLElement>();
|
|
288
|
+
$effect(() => {
|
|
289
|
+
const root = codeEl;
|
|
290
|
+
const fn = highlight === true ? highlightCode : highlight || undefined;
|
|
291
|
+
if (!root || !fn) return;
|
|
292
|
+
void lined; // the DOM structure the ranges point into
|
|
293
|
+
// the ranges index the rendered text — never paint a DOM that disagrees with it
|
|
294
|
+
if (root.textContent !== text) return;
|
|
295
|
+
let tokens: CodeBlockToken[];
|
|
296
|
+
try {
|
|
297
|
+
tokens = fn(text, _lang);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
// a failing highlighter must not take the sample down with it
|
|
300
|
+
console.error(e);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
return paintTokens(root, tokens ?? []);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// --- classes ------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
// Under `unstyled` a part keeps only the consumer's classes — `undefined` rather than
|
|
309
|
+
// `class=""` when there are none.
|
|
310
|
+
const _cls = (base: string, ...extra: (string | undefined)[]) =>
|
|
311
|
+
(unstyled ? twMerge(...extra) : twMerge(base, ...extra)) || undefined;
|
|
312
|
+
|
|
313
|
+
// `not-prose`: a code block is the component most likely to sit in a typography
|
|
314
|
+
// column, whose plugin would otherwise restyle the <pre>, the <code> (adding
|
|
315
|
+
// backticks) and the button. Inert where the plugin isn't installed.
|
|
316
|
+
let _class = $derived(
|
|
317
|
+
unstyled ? classProp : twMerge("stuic-code-block not-prose", classProp)
|
|
318
|
+
);
|
|
319
|
+
let _classHeader = $derived(_cls("stuic-code-block-header", classHeaderProp));
|
|
320
|
+
let _classTitle = $derived(_cls("stuic-code-block-title", classTitleProp));
|
|
321
|
+
let _classTabs = $derived(_cls("stuic-code-block-tabs", classTabsProp));
|
|
322
|
+
let _classTab = $derived(_cls("stuic-code-block-tab", classTabProp));
|
|
323
|
+
let _classPre = $derived(_cls("stuic-code-block-pre", classPreProp));
|
|
324
|
+
// the `language-*` class is semantics, not styling — it survives `unstyled`
|
|
325
|
+
let _classCode = $derived(
|
|
326
|
+
_cls(
|
|
327
|
+
"stuic-code-block-code",
|
|
328
|
+
_lang ? `language-${_lang.trim().replace(/\s+/g, "-")}` : undefined,
|
|
329
|
+
classCodeProp
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
let _classLine = $derived(_cls("stuic-code-block-line", classLineProp));
|
|
333
|
+
let _classFooter = $derived(_cls("stuic-code-block-footer", classFooterProp));
|
|
334
|
+
let _classToggle = $derived(_cls("stuic-code-block-toggle", classToggleProp));
|
|
335
|
+
let _classCopy = $derived(_cls("stuic-code-block-copy", copyButtonProps?.class));
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<div
|
|
339
|
+
bind:this={el}
|
|
340
|
+
class={_class}
|
|
341
|
+
data-lang={_lang || undefined}
|
|
342
|
+
data-wrap={wrap ? "true" : undefined}
|
|
343
|
+
data-samples={_samples ? "true" : undefined}
|
|
344
|
+
data-copy={copy ? "true" : undefined}
|
|
345
|
+
data-lines={lined ? "true" : undefined}
|
|
346
|
+
data-line-numbers={lined && lineNumbers ? "true" : undefined}
|
|
347
|
+
data-collapsed={collapsed ? "true" : undefined}
|
|
348
|
+
{...rest}
|
|
349
|
+
>
|
|
350
|
+
<!-- A <div>, not a <header>: outside <main>/sectioning content a <header> is a
|
|
351
|
+
`banner` landmark, one per block. -->
|
|
352
|
+
{#if hasTitle || _samples || copy}
|
|
353
|
+
<div class={_classHeader}>
|
|
354
|
+
{#if hasTitle}
|
|
355
|
+
<span id={_samples ? titleId : undefined} class={_classTitle}
|
|
356
|
+
><Thc thc={_title!} /></span
|
|
357
|
+
>
|
|
358
|
+
{/if}
|
|
359
|
+
{#if _samples}
|
|
360
|
+
<div
|
|
361
|
+
role="tablist"
|
|
362
|
+
aria-labelledby={hasTitle ? titleId : undefined}
|
|
363
|
+
class={_classTabs}
|
|
364
|
+
>
|
|
365
|
+
{#each _samples as sample, i (sampleIds[i])}
|
|
366
|
+
<button
|
|
367
|
+
bind:this={tabEls[i]}
|
|
368
|
+
type="button"
|
|
369
|
+
role="tab"
|
|
370
|
+
id={tabId(i)}
|
|
371
|
+
class={_classTab}
|
|
372
|
+
aria-selected={i === activeIndex}
|
|
373
|
+
aria-controls={preId}
|
|
374
|
+
tabindex={i === activeIndex ? 0 : -1}
|
|
375
|
+
data-active={i === activeIndex ? "true" : undefined}
|
|
376
|
+
onclick={() => selectTab(i)}
|
|
377
|
+
onkeydown={(e) => onTabKeydown(e, i)}
|
|
378
|
+
>
|
|
379
|
+
<Thc thc={sample.label ?? sample.lang ?? `${i + 1}`} />
|
|
380
|
+
</button>
|
|
381
|
+
{/each}
|
|
382
|
+
</div>
|
|
383
|
+
{/if}
|
|
384
|
+
{#if copy}
|
|
385
|
+
<CopyButton
|
|
386
|
+
label
|
|
387
|
+
variant="ghost"
|
|
388
|
+
size="sm"
|
|
389
|
+
{t}
|
|
390
|
+
{unstyled}
|
|
391
|
+
{...copyButtonProps}
|
|
392
|
+
text={copyText}
|
|
393
|
+
class={_classCopy}
|
|
394
|
+
/>
|
|
395
|
+
{/if}
|
|
396
|
+
</div>
|
|
397
|
+
{/if}
|
|
398
|
+
<!-- tabindex: while it scrolls (axe `scrollable-region-focusable`), or as a tab panel -->
|
|
399
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
400
|
+
<pre
|
|
401
|
+
bind:this={preEl}
|
|
402
|
+
id={preId}
|
|
403
|
+
class={_classPre}
|
|
404
|
+
role={_samples ? "tabpanel" : undefined}
|
|
405
|
+
aria-labelledby={_samples ? tabId(activeIndex) : undefined}
|
|
406
|
+
tabindex={_samples || scrollable ? 0 : undefined}
|
|
407
|
+
style:--_collapsed-lines={collapsed ? collapsedLines : undefined}
|
|
408
|
+
style:--_digits={lined && lineNumbers ? digits : undefined}><code
|
|
409
|
+
bind:this={codeEl}
|
|
410
|
+
class={_classCode}
|
|
411
|
+
>{#if lined}{#each lines as line, i}<span
|
|
412
|
+
class={_classLine}
|
|
413
|
+
data-line={lineNumbers ? lineNumbersStart + i : undefined}
|
|
414
|
+
data-highlighted={_highlighted.has(i + 1) ? "true" : undefined}>{line}</span
|
|
415
|
+
>{/each}{:else}{text}{/if}</code
|
|
416
|
+
></pre>
|
|
417
|
+
{#if collapsible}
|
|
418
|
+
<div class={_classFooter}>
|
|
419
|
+
<Button
|
|
420
|
+
type="button"
|
|
421
|
+
variant="ghost"
|
|
422
|
+
size="sm"
|
|
423
|
+
class={_classToggle}
|
|
424
|
+
aria-expanded={expanded}
|
|
425
|
+
aria-controls={preId}
|
|
426
|
+
{unstyled}
|
|
427
|
+
onclick={toggle}
|
|
428
|
+
>
|
|
429
|
+
<span
|
|
430
|
+
class={unstyled ? undefined : "stuic-code-block-toggle-icon"}
|
|
431
|
+
aria-hidden="true">{@html iconChevronDown()}</span
|
|
432
|
+
>
|
|
433
|
+
<span
|
|
434
|
+
>{expanded
|
|
435
|
+
? t("show_less")
|
|
436
|
+
: t("show_all_lines", { count: lines.length })}</span
|
|
437
|
+
>
|
|
438
|
+
</Button>
|
|
439
|
+
</div>
|
|
440
|
+
{/if}
|
|
441
|
+
</div>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
2
|
+
import type { THC } from "../Thc/Thc.svelte";
|
|
3
|
+
import type { Props as CopyButtonProps } from "../CopyButton/CopyButton.svelte";
|
|
4
|
+
import type { TranslateFn } from "../../types.js";
|
|
5
|
+
import type { CodeBlockHighlighter } from "./highlight/types.js";
|
|
6
|
+
/** One sample of a tabbed block — the same thing in another language or tool. */
|
|
7
|
+
export interface CodeBlockSample {
|
|
8
|
+
/** The sample (rendered as text) */
|
|
9
|
+
code: string;
|
|
10
|
+
/** What it is written in — the tab label (unless `label`), and what is highlighted */
|
|
11
|
+
lang?: string;
|
|
12
|
+
/** The tab label, in place of `lang` */
|
|
13
|
+
label?: THC;
|
|
14
|
+
/**
|
|
15
|
+
* Identity for `active`. Defaults to a string `label`, else `lang`, else the index —
|
|
16
|
+
* so blocks sharing one bound `active` stay in sync by what the reader sees.
|
|
17
|
+
*/
|
|
18
|
+
id?: string;
|
|
19
|
+
/** Lines to highlight in this sample (overrides the block's `highlightLines`) */
|
|
20
|
+
highlightLines?: number[] | string;
|
|
21
|
+
/** What the copy button copies for this sample, in place of the displayed text */
|
|
22
|
+
copyText?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "title"> {
|
|
25
|
+
/** The sample. Always rendered as text, never as HTML. Ignored with `samples`. */
|
|
26
|
+
code?: string;
|
|
27
|
+
/**
|
|
28
|
+
* What the sample is written in. A label, and the highlighter's hint: it is the header
|
|
29
|
+
* label (unless `title` is set), `data-lang` on the root, and a `language-*` class on
|
|
30
|
+
* the `<code>`. Ignored with `samples` (each has its own).
|
|
31
|
+
*/
|
|
32
|
+
lang?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Header label in place of `lang` — e.g. a file name. `""` hides the label. With
|
|
35
|
+
* `samples` it is shown before the tabs and names the tab list.
|
|
36
|
+
*/
|
|
37
|
+
title?: THC;
|
|
38
|
+
/** Several samples of the same thing (curl / fetch / Python…), switched by tabs */
|
|
39
|
+
samples?: CodeBlockSample[];
|
|
40
|
+
/**
|
|
41
|
+
* The id of the shown sample (see `CodeBlockSample.id`); bindable. Unset → the first
|
|
42
|
+
* sample. An id this block doesn't have leaves it on the sample it showed, and the
|
|
43
|
+
* value is never rewritten — so several blocks can share one bound `active`.
|
|
44
|
+
*/
|
|
45
|
+
active?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Render the code exactly as given. By default the blank lines at both ends and the
|
|
48
|
+
* indentation all lines share are dropped (from the display and the copy alike).
|
|
49
|
+
*/
|
|
50
|
+
verbatim?: boolean;
|
|
51
|
+
/** Soft-wrap long lines instead of scrolling horizontally */
|
|
52
|
+
wrap?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Syntax highlighting. `true` (default): the built-in `highlightCode` (JSON, HTTP,
|
|
55
|
+
* shell — other languages stay plain). A function: your own tokenizer. `false`: off.
|
|
56
|
+
* Painted with the CSS Custom Highlight API — no markup; browsers without it show
|
|
57
|
+
* plain text.
|
|
58
|
+
*/
|
|
59
|
+
highlight?: boolean | CodeBlockHighlighter;
|
|
60
|
+
/** Show line numbers (not selectable, not copied) */
|
|
61
|
+
lineNumbers?: boolean;
|
|
62
|
+
/** The first line's number (default `1`) */
|
|
63
|
+
lineNumbersStart?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Lines to highlight: 1-based positions in the sample (not the displayed numbers),
|
|
66
|
+
* as an array or a string like `"1, 3-5"`.
|
|
67
|
+
*/
|
|
68
|
+
highlightLines?: number[] | string;
|
|
69
|
+
/**
|
|
70
|
+
* Collapse samples longer than this many lines to this many, with a "Show all N
|
|
71
|
+
* lines" toggle.
|
|
72
|
+
*/
|
|
73
|
+
collapsedLines?: number;
|
|
74
|
+
/** Whether a collapsible sample is expanded; bindable */
|
|
75
|
+
expanded?: boolean;
|
|
76
|
+
/** Render the copy button (default `true`) */
|
|
77
|
+
copy?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Props for the copy button (a `CopyButton`: `label`, `variant`, `onCopied`, …). A
|
|
80
|
+
* `text` here overrides what gets copied — e.g. a shell sample without its `$ `
|
|
81
|
+
* prompts (a sample's own `copyText` wins over it).
|
|
82
|
+
*/
|
|
83
|
+
copyButtonProps?: Partial<CopyButtonProps>;
|
|
84
|
+
/** i18n translate function (see `createCodeBlockT`); also passed to the copy button */
|
|
85
|
+
t?: TranslateFn;
|
|
86
|
+
/** Skip all default styling */
|
|
87
|
+
unstyled?: boolean;
|
|
88
|
+
/** Additional CSS classes for the root */
|
|
89
|
+
class?: string;
|
|
90
|
+
/** Class for the header row */
|
|
91
|
+
classHeader?: string;
|
|
92
|
+
/** Class for the header label */
|
|
93
|
+
classTitle?: string;
|
|
94
|
+
/** Class for the tab list */
|
|
95
|
+
classTabs?: string;
|
|
96
|
+
/** Class for every tab */
|
|
97
|
+
classTab?: string;
|
|
98
|
+
/** Class for the `<pre>` */
|
|
99
|
+
classPre?: string;
|
|
100
|
+
/** Class for the `<code>` */
|
|
101
|
+
classCode?: string;
|
|
102
|
+
/** Class for every line (only rendered with `lineNumbers` / `highlightLines`) */
|
|
103
|
+
classLine?: string;
|
|
104
|
+
/** Class for the footer holding the collapse toggle */
|
|
105
|
+
classFooter?: string;
|
|
106
|
+
/** Class for the collapse toggle */
|
|
107
|
+
classToggle?: string;
|
|
108
|
+
/** Bindable root element reference */
|
|
109
|
+
el?: HTMLDivElement;
|
|
110
|
+
}
|
|
111
|
+
declare const CodeBlock: import("svelte").Component<Props, {}, "el" | "active" | "expanded">;
|
|
112
|
+
type CodeBlock = ReturnType<typeof CodeBlock>;
|
|
113
|
+
export default CodeBlock;
|