@marianmeres/stuic 3.190.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/components/RangeSlider/README.md +22 -22
- package/dist/components/RangeSlider/RangeSlider.svelte +4 -4
- package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +2 -2
- package/dist/components/RangeSlider/index.css +9 -6
- package/dist/components/Slider/README.md +23 -24
- package/dist/components/Slider/Slider.svelte +4 -4
- package/dist/components/Slider/Slider.svelte.d.ts +2 -2
- package/dist/components/Slider/index.css +6 -3
- 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
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# CodeBlock
|
|
2
|
+
|
|
3
|
+
A copyable code sample for developer docs and API guides: a bordered, rounded box with a
|
|
4
|
+
header (the language or a file name, or tabs for several samples, and a `CopyButton`) over a
|
|
5
|
+
`<pre><code>`. JSON, HTTP and shell are syntax-highlighted out of the box, without markup and
|
|
6
|
+
without a dependency. Optional line numbers, highlighted lines, and a "Show all N lines"
|
|
7
|
+
collapse for long samples.
|
|
8
|
+
|
|
9
|
+
Renders `<div>` › optional header `<div>` › `<pre>` › `<code>` › optional footer `<div>`. The
|
|
10
|
+
code is always rendered as **text** — never as HTML — and what the button copies is exactly
|
|
11
|
+
what is shown.
|
|
12
|
+
|
|
13
|
+
## Props
|
|
14
|
+
|
|
15
|
+
| Prop | Type | Default | Description |
|
|
16
|
+
| ------------------ | --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `code` | `string` | - | The sample. Rendered as text. Ignored with `samples` |
|
|
18
|
+
| `lang` | `string` | - | What it is written in: the header label (unless `title` is set), what gets highlighted, `data-lang`, `language-{lang}` class |
|
|
19
|
+
| `title` | `THC` | - | Header label in place of `lang` — e.g. a file name. `""` hides it. With `samples`: shown before the tabs, names the tab list |
|
|
20
|
+
| `samples` | `CodeBlockSample[]` | - | Several samples of the same thing (curl / fetch / Python…), switched by tabs — see [Samples](#samples-tabs) |
|
|
21
|
+
| `active` | `string` | - | The shown sample's id (bindable) — see [Samples](#samples-tabs) |
|
|
22
|
+
| `highlight` | `boolean \| CodeBlockHighlighter` | `true` | Syntax highlighting: `true` = built-in `highlightCode`, a function = yours, `false` = off |
|
|
23
|
+
| `lineNumbers` | `boolean` | `false` | Show line numbers (never selected or copied) |
|
|
24
|
+
| `lineNumbersStart` | `number` | `1` | The first line's number |
|
|
25
|
+
| `highlightLines` | `number[] \| string` | - | Lines to highlight — 1-based **positions in the sample**, e.g. `"1, 3-5"`, whatever `lineNumbersStart` says |
|
|
26
|
+
| `collapsedLines` | `number` | - | Collapse samples longer than this many lines to this many, with a toggle |
|
|
27
|
+
| `expanded` | `boolean` | `false` | Whether a collapsible sample is expanded (bindable) |
|
|
28
|
+
| `verbatim` | `boolean` | `false` | Render `code` exactly as given (see [Normalization](#normalization)) |
|
|
29
|
+
| `wrap` | `boolean` | `false` | Soft-wrap long lines instead of scrolling horizontally |
|
|
30
|
+
| `copy` | `boolean` | `true` | Render the copy button |
|
|
31
|
+
| `copyButtonProps` | `Partial<CopyButtonProps>` | - | Props for the `CopyButton` (`variant`, `label`, `onCopied`, …). A `text` here overrides what gets copied |
|
|
32
|
+
| `t` | `TranslateFn` | English | i18n (see `createCodeBlockT`) — also localizes the copy button |
|
|
33
|
+
| `unstyled` | `boolean` | `false` | Skip all default styling (no `stuic-*` classes, no `not-prose`; the buttons are unstyled too) |
|
|
34
|
+
| `class` | `string` | - | Additional CSS classes on the root (merged via twMerge) |
|
|
35
|
+
| `classHeader` | `string` | - | The header row |
|
|
36
|
+
| `classTitle` | `string` | - | The header label |
|
|
37
|
+
| `classTabs` | `string` | - | The tab list |
|
|
38
|
+
| `classTab` | `string` | - | Every tab |
|
|
39
|
+
| `classPre` | `string` | - | The `<pre>` |
|
|
40
|
+
| `classCode` | `string` | - | The `<code>` |
|
|
41
|
+
| `classLine` | `string` | - | Every line (rendered only with `lineNumbers` / `highlightLines`) |
|
|
42
|
+
| `classFooter` | `string` | - | The footer holding the collapse toggle |
|
|
43
|
+
| `classToggle` | `string` | - | The collapse toggle |
|
|
44
|
+
| `el` | `HTMLDivElement` | - | Root element reference (bindable) |
|
|
45
|
+
|
|
46
|
+
Any other attribute (`data-*`, `style`, `id`, …) goes to the root `<div>`. `title` is omitted
|
|
47
|
+
from the root's HTML attributes because the prop is a `THC`, not the tooltip.
|
|
48
|
+
|
|
49
|
+
The header renders when there is a label, tabs or a copy button; with `copy={false}` and no
|
|
50
|
+
label the block is just the `<pre>`. The copy button (borderless — `variant: "ghost"` — by
|
|
51
|
+
default) sits flush in the header's end corner: with it, the header keeps only its start
|
|
52
|
+
padding (where the label or the tabs begin), and the button is a square-cornered cell with an
|
|
53
|
+
inset focus ring, like a tab. Without it, the header is padded all around.
|
|
54
|
+
|
|
55
|
+
### Types
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
interface CodeBlockSample {
|
|
59
|
+
code: string;
|
|
60
|
+
lang?: string; // the tab label unless `label`; what gets highlighted
|
|
61
|
+
label?: THC; // the tab label
|
|
62
|
+
id?: string; // identity for `active` (default: a string `label`, else `lang`, else the index)
|
|
63
|
+
highlightLines?: number[] | string; // overrides the block's
|
|
64
|
+
copyText?: string; // what the copy button copies for this sample
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type CodeBlockTokenType =
|
|
68
|
+
| "comment"
|
|
69
|
+
| "string"
|
|
70
|
+
| "number"
|
|
71
|
+
| "literal"
|
|
72
|
+
| "keyword"
|
|
73
|
+
| "property"
|
|
74
|
+
| "variable"
|
|
75
|
+
| "function"
|
|
76
|
+
| "parameter"
|
|
77
|
+
| "operator"
|
|
78
|
+
| "punctuation"
|
|
79
|
+
| "meta";
|
|
80
|
+
/** [start, end) offsets into the displayed text, and the type (custom types allowed) */
|
|
81
|
+
type CodeBlockToken = [start: number, end: number, type: CodeBlockTokenType | string];
|
|
82
|
+
type CodeBlockHighlighter = (code: string, lang?: string) => CodeBlockToken[];
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Usage
|
|
86
|
+
|
|
87
|
+
```svelte
|
|
88
|
+
<script lang="ts">
|
|
89
|
+
import { CodeBlock } from "@marianmeres/stuic";
|
|
90
|
+
|
|
91
|
+
const quickstart = `curl -H "Authorization: Bearer $TOKEN" \\
|
|
92
|
+
https://api.example.com/v1/me`;
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<CodeBlock lang="bash" code={quickstart} />
|
|
96
|
+
<CodeBlock lang="json" title="deno.json" code={config} />
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Syntax highlighting
|
|
100
|
+
|
|
101
|
+
On by default for the languages the built-in `highlightCode` knows (case insensitive):
|
|
102
|
+
|
|
103
|
+
| Language | `lang` | Tokens |
|
|
104
|
+
| -------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| JSON | `json`, `jsonc`, `json5` | keys, strings, numbers, `true`/`false`/`null`, punctuation, `//` and `/* */` comments |
|
|
106
|
+
| HTTP | `http` | request / status line, header names and values, a JSON body after the blank line |
|
|
107
|
+
| Shell | `bash`, `sh`, `shell`, `zsh`, `console`, `terminal`, `shell-session` | `$ ` prompts, comments, strings (and the `$VARS` in them), expansions, commands, flags, operators |
|
|
108
|
+
|
|
109
|
+
Any other `lang` renders plain. The tokenizers are a reading aid, not parsers — tolerant of
|
|
110
|
+
placeholders (`...`), unterminated strings and comments, never throwing.
|
|
111
|
+
|
|
112
|
+
**How it is painted:** with the CSS Custom Highlight API (`CSS.highlights` + `::highlight()`),
|
|
113
|
+
not with markup. The `<code>` keeps plain text nodes, so selecting and copying are untouched,
|
|
114
|
+
the server-rendered HTML is the plain sample, and a browser without the API (all current
|
|
115
|
+
engines have it) simply shows the plain text. The colors appear once the page hydrates. The
|
|
116
|
+
API only allows color-like properties: no bold or italic tokens.
|
|
117
|
+
|
|
118
|
+
**Your own language** — any function returning `[start, end, type]` tokens; compose it with
|
|
119
|
+
the built-in one:
|
|
120
|
+
|
|
121
|
+
```svelte
|
|
122
|
+
<script lang="ts">
|
|
123
|
+
import {
|
|
124
|
+
CodeBlock,
|
|
125
|
+
highlightCode,
|
|
126
|
+
type CodeBlockHighlighter,
|
|
127
|
+
} from "@marianmeres/stuic";
|
|
128
|
+
|
|
129
|
+
const highlight: CodeBlockHighlighter = (code, lang) =>
|
|
130
|
+
lang === "ts" ? myTsTokens(code) : highlightCode(code, lang);
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<CodeBlock lang="ts" code={sample} {highlight} />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A highlighter that throws is caught (and logged) — the sample renders plain. A custom token
|
|
137
|
+
type `"regex"` is painted by `::highlight(stuic-code-block-regex)`, which you style:
|
|
138
|
+
|
|
139
|
+
```css
|
|
140
|
+
:is(.stuic-code-block-code, .stuic-code-block-code *)::highlight(stuic-code-block-regex) {
|
|
141
|
+
color: darkorange;
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
(The descendant form matters: Firefox styles a highlight by the element that holds the text,
|
|
146
|
+
which is a line `<span>` when lines are rendered.) `highlightJson`, `highlightHttp` and
|
|
147
|
+
`highlightShell` are exported for reuse; `HIGHLIGHT_CODE_LANGS` lists the known `lang`s.
|
|
148
|
+
|
|
149
|
+
## Samples (tabs)
|
|
150
|
+
|
|
151
|
+
`samples` shows the same thing in several languages behind a tab list — the header's label
|
|
152
|
+
becomes the tabs; `title`, if given, is shown before them and names the tab list.
|
|
153
|
+
|
|
154
|
+
```svelte
|
|
155
|
+
<script lang="ts">
|
|
156
|
+
import { CodeBlock, type CodeBlockSample } from "@marianmeres/stuic";
|
|
157
|
+
|
|
158
|
+
const samples: CodeBlockSample[] = [
|
|
159
|
+
{ label: "curl", lang: "bash", code: curlSample },
|
|
160
|
+
{ label: "JavaScript", lang: "js", code: fetchSample, highlightLines: "2-4" },
|
|
161
|
+
{ label: "Python", lang: "python", code: pythonSample },
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
// one bound value keeps every block on the page on the reader's language
|
|
165
|
+
let language = $state<string>();
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<CodeBlock title="Create an item" {samples} bind:active={language} />
|
|
169
|
+
<CodeBlock title="Read it back" samples={otherSamples} bind:active={language} />
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **Identity:** a sample's id is its `id`, else a string `label`, else `lang`, else its index
|
|
173
|
+
(duplicates get the index appended). `active` holds that id.
|
|
174
|
+
- **Sync:** blocks bound to the same `active` switch together. A block that lacks the picked
|
|
175
|
+
id keeps showing the sample it showed (initially its first) and never rewrites the value, so
|
|
176
|
+
"Python" picked in one block survives a block that has only curl and fetch. Persist the
|
|
177
|
+
choice across visits with `bind:active={lang.value}`, where
|
|
178
|
+
`const lang = localStorageState("code-lang", "curl")`.
|
|
179
|
+
- **Per sample:** `highlightLines` and `copyText` override the block's; `lang` drives the
|
|
180
|
+
highlighting, `data-lang` and the `language-*` class.
|
|
181
|
+
- In a narrow block the title moves to its own row above the tabs; many tabs scroll sideways.
|
|
182
|
+
|
|
183
|
+
## Lines
|
|
184
|
+
|
|
185
|
+
```svelte
|
|
186
|
+
<CodeBlock lang="bash" code={script} lineNumbers highlightLines="5-8" />
|
|
187
|
+
<CodeBlock lang="json" code={excerpt} lineNumbers lineNumbersStart={120} />
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
With `lineNumbers` or `highlightLines`, each line is rendered as its own block `<span>` that
|
|
191
|
+
**keeps its `\n`**, so the text content, a selected-and-copied range and the highlight offsets
|
|
192
|
+
are still exactly the sample. The numbers are `::before` content from `data-line` —
|
|
193
|
+
`user-select: none`, never part of a copy. `highlightLines` counts positions in the sample
|
|
194
|
+
(1 = its first line), not the displayed numbers. A highlighted line's band spans the full
|
|
195
|
+
scrolled width; with `wrap`, a wrapped line continues in the code column, not under the
|
|
196
|
+
numbers. Without either prop the `<code>` holds a single text node.
|
|
197
|
+
|
|
198
|
+
## Collapse
|
|
199
|
+
|
|
200
|
+
```svelte
|
|
201
|
+
<CodeBlock lang="json" code={bigResponse} collapsedLines={12} />
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A sample longer than `collapsedLines` shows that many lines (padding included, measured in
|
|
205
|
+
`lh`), fades out, and gets a footer toggle — "Show all N lines" / "Show less" — with
|
|
206
|
+
`aria-expanded` and `aria-controls`. `expanded` is bindable. The hidden lines are behind the
|
|
207
|
+
toggle, not a scroll, so they don't make the `<pre>` a tab stop. Collapsing a block whose top
|
|
208
|
+
the reader has scrolled past scrolls it back into view. Independent of
|
|
209
|
+
`--stuic-code-block-max-height` (a scroll cap), which applies once expanded.
|
|
210
|
+
|
|
211
|
+
## Copy something other than what is shown
|
|
212
|
+
|
|
213
|
+
`copyButtonProps` reach the underlying `CopyButton`; its `text` overrides the copied string
|
|
214
|
+
(a sample's own `copyText` wins over it). Here the shell prompts are shown but not copied:
|
|
215
|
+
|
|
216
|
+
```svelte
|
|
217
|
+
<CodeBlock
|
|
218
|
+
lang="shell"
|
|
219
|
+
code={session}
|
|
220
|
+
copyButtonProps={{
|
|
221
|
+
text: session.replace(/^\$ /gm, ""),
|
|
222
|
+
onCopied: () => notifications.success("Copied"),
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## i18n
|
|
228
|
+
|
|
229
|
+
```svelte
|
|
230
|
+
<script lang="ts">
|
|
231
|
+
import {
|
|
232
|
+
CodeBlock,
|
|
233
|
+
createCodeBlockT,
|
|
234
|
+
CODE_BLOCK_MESSAGES_SK,
|
|
235
|
+
} from "@marianmeres/stuic";
|
|
236
|
+
const t = createCodeBlockT(CODE_BLOCK_MESSAGES_SK);
|
|
237
|
+
</script>
|
|
238
|
+
|
|
239
|
+
<CodeBlock lang="bash" code={sample} collapsedLines={10} {t} />
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Keys: `copy`, `copied`, `copy_failed` (the `CopyButton`'s — one `t` localizes both),
|
|
243
|
+
`show_all_lines` (`{count}`), `show_less`. A partial catalog falls back to English.
|
|
244
|
+
|
|
245
|
+
## Inside a typography column
|
|
246
|
+
|
|
247
|
+
The root carries `not-prose`, so inside a `prose` container the Tailwind typography plugin does
|
|
248
|
+
not restyle the `<pre>`, the `<code>` (it would add backticks) or the buttons. The class is
|
|
249
|
+
inert where the plugin isn't installed. Prose spaces its own elements, not a `<div>`, and the
|
|
250
|
+
component declares no outer margin — so give the block its own:
|
|
251
|
+
|
|
252
|
+
```svelte
|
|
253
|
+
<div class="prose">
|
|
254
|
+
<p>…</p>
|
|
255
|
+
<CodeBlock class="my-6" lang="bash" code={sample} />
|
|
256
|
+
</div>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Normalization
|
|
260
|
+
|
|
261
|
+
By default the sample is normalized before it is displayed **and** copied:
|
|
262
|
+
|
|
263
|
+
- line endings become `\n`;
|
|
264
|
+
- blank (or whitespace-only) lines at both ends are dropped;
|
|
265
|
+
- the indentation every non-blank line shares is removed. The first line keeps its indentation
|
|
266
|
+
relative to the rest (unlike `String#trim`), and tabs and spaces are never treated as the same
|
|
267
|
+
indentation.
|
|
268
|
+
|
|
269
|
+
So a sample can sit indented in a template. `verbatim` turns all of it off.
|
|
270
|
+
|
|
271
|
+
## CSS Variables
|
|
272
|
+
|
|
273
|
+
| Variable | Default | Description |
|
|
274
|
+
| ------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
275
|
+
| `--stuic-code-block-bg` | `transparent` | Box background |
|
|
276
|
+
| `--stuic-code-block-border-color` | `var(--stuic-color-border)` | Box border color |
|
|
277
|
+
| `--stuic-code-block-border-width` | `var(--stuic-border-width)` | Box border width (not declared — tier fallback at the usage site) |
|
|
278
|
+
| `--stuic-code-block-radius` | `var(--stuic-radius-container)` | Box radius (not declared — tier fallback at the usage site) |
|
|
279
|
+
| `--stuic-code-block-rule-color` | `var(--stuic-color-border)` | The rules under the header and above the footer |
|
|
280
|
+
| `--stuic-code-block-rule-width` | `1px` | Their width (own token, so a theme that zeroes box borders keeps them) |
|
|
281
|
+
| `--stuic-code-block-text` | inherited color | Code color (not declared — falls back to `currentColor`) |
|
|
282
|
+
| `--stuic-code-block-padding-x` | `1rem` | Code inline padding — also the header's and the lines' |
|
|
283
|
+
| `--stuic-code-block-padding-y` | `1rem` | Code block padding |
|
|
284
|
+
| `--stuic-code-block-font-family` | `var(--font-mono, ui-monospace, monospace)` | Code, label and tab font |
|
|
285
|
+
| `--stuic-code-block-font-size` | `var(--text-sm)` | Code, label, tab and toggle size |
|
|
286
|
+
| `--stuic-code-block-line-height` | `1.625` | Code line height |
|
|
287
|
+
| `--stuic-code-block-code-tab-size` | `4` | Width of a tab character |
|
|
288
|
+
| `--stuic-code-block-max-height` | `none` | Cap on the `<pre>` height (padding included); the rest scrolls |
|
|
289
|
+
| `--stuic-code-block-ring-width` | `2px` | Focus ring of a scrollable `<pre>` and of the tabs (drawn inset) |
|
|
290
|
+
| `--stuic-code-block-ring-color` | `var(--stuic-color-ring)` | Its color |
|
|
291
|
+
| `--stuic-code-block-transition` | `var(--stuic-transition)` | Tab hover, toggle icon (not declared — tier fallback at the usage site) |
|
|
292
|
+
| `--stuic-code-block-header-bg` | `var(--stuic-color-surface)` | Header background |
|
|
293
|
+
| `--stuic-code-block-header-padding-x` | `--stuic-code-block-padding-x` | Header inline padding — only the start side with a copy button (not declared) |
|
|
294
|
+
| `--stuic-code-block-header-padding-y` | `0.25rem` | Header block padding — none with a copy button |
|
|
295
|
+
| `--stuic-code-block-header-gap` | `0.5rem` | Gap between the header's parts |
|
|
296
|
+
| `--stuic-code-block-title-text` | `var(--stuic-color-surface-foreground)` | Label color |
|
|
297
|
+
| `--stuic-code-block-tab-text` | `var(--stuic-color-surface-foreground)` | Tab color (active and inactive alike) |
|
|
298
|
+
| `--stuic-code-block-tab-padding-x` | `0.75rem` | Tab inline padding |
|
|
299
|
+
| `--stuic-code-block-tab-bg-hover` | 8% surface-foreground | Tab hover background |
|
|
300
|
+
| `--stuic-code-block-tab-font-weight-active` | `var(--font-weight-semibold, 600)` | Active tab weight |
|
|
301
|
+
| `--stuic-code-block-tab-indicator-color` | `var(--stuic-color-surface-foreground)` | Active tab's bar |
|
|
302
|
+
| `--stuic-code-block-tab-indicator-width` | `2px` | Its thickness |
|
|
303
|
+
| `--stuic-code-block-line-number-text` | 65% foreground over background | Line number color |
|
|
304
|
+
| `--stuic-code-block-line-number-gap` | `1.25rem` | Between the numbers and the code |
|
|
305
|
+
| `--stuic-code-block-line-bg-highlighted` | 12% primary | Highlighted line band |
|
|
306
|
+
| `--stuic-code-block-line-marker-color` | `var(--stuic-color-primary)` | Highlighted line's start-edge marker |
|
|
307
|
+
| `--stuic-code-block-line-marker-width` | `3px` | Its width |
|
|
308
|
+
| `--stuic-code-block-fade-size` | `2lh` | The fade over a collapsed sample's cut-off |
|
|
309
|
+
| `--stuic-code-block-token-{type}-text` | a GitHub-like palette, light and dark | Syntax colors — `comment`, `meta`, `string`, `number`, `literal`, `parameter`, `keyword`, `operator`, `property`, `variable`, `function` |
|
|
310
|
+
|
|
311
|
+
**Contrast.** The header label and the tabs use `surface-foreground` on `surface` (at least
|
|
312
|
+
7.3:1 light, 5.7:1 dark across the bundled themes); `muted-foreground` on `surface` falls below
|
|
313
|
+
4.5:1 in almost all of them, so it is not used. Inactive tabs are not dimmed for the same
|
|
314
|
+
reason — the active one is marked by its bar and weight. The line numbers (65% foreground) and
|
|
315
|
+
every syntax color keep at least 4.5:1 on the background of every bundled theme. The syntax
|
|
316
|
+
palette switches under `:root.dark`; a block with its own dark background sets the token
|
|
317
|
+
colors itself (the demo page has an example).
|
|
318
|
+
|
|
319
|
+
The copy button and the collapse toggle are `Button`s: theme them through the
|
|
320
|
+
`--stuic-button-*` tokens, or `copyButtonProps` / `classToggle`.
|
|
321
|
+
|
|
322
|
+
## Accessibility
|
|
323
|
+
|
|
324
|
+
- An overflowing `<pre>` (a long line, or a capped height) gets `tabindex="0"`, so its hidden
|
|
325
|
+
part can be scrolled by keyboard (axe `scrollable-region-focusable`). It is **measured**, not
|
|
326
|
+
always on: a sample that fits costs no tab stop.
|
|
327
|
+
- Tabs follow the WAI-ARIA tabs pattern: `role="tablist"` / `tab` / `tabpanel` (the `<pre>`),
|
|
328
|
+
`aria-selected`, `aria-controls`, a roving `tabindex`, automatic activation on ←/→ (wrapping),
|
|
329
|
+
Home and End. The tab panel is always a tab stop.
|
|
330
|
+
- The collapse toggle has `aria-expanded` and `aria-controls`.
|
|
331
|
+
- Line numbers and syntax colors are presentation only: neither is in the text a screen reader
|
|
332
|
+
or a copy gets.
|
|
333
|
+
- The header is a `<div>`, not a `<header>`: outside `<main>` or sectioning content a `<header>`
|
|
334
|
+
is a `banner` landmark, one per block.
|
|
335
|
+
- `unstyled` keeps the `language-*` class, the roles, the tab stop and the buttons — they are
|
|
336
|
+
semantics, not styling.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits the displayed text into lines that each KEEP their trailing `\n`, so the lines
|
|
3
|
+
* concatenate back to the exact text (the DOM's text content, a copied selection and the
|
|
4
|
+
* highlight offsets all stay the text itself). A final `\n` does not start an extra,
|
|
5
|
+
* empty line — just as it renders no extra line in a plain `<pre>`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function splitLines(text: string): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Parses a set of 1-based line positions: an array of numbers, or a string like
|
|
10
|
+
* `"1, 3-5"`. Invalid or out-of-range parts (< 1, > `max`) are dropped; a reversed range
|
|
11
|
+
* (`"5-3"`) is read as `"3-5"`. `max` also bounds the work a huge range can cause.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseLineSet(spec: number[] | string | undefined | null, max?: number): Set<number>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits the displayed text into lines that each KEEP their trailing `\n`, so the lines
|
|
3
|
+
* concatenate back to the exact text (the DOM's text content, a copied selection and the
|
|
4
|
+
* highlight offsets all stay the text itself). A final `\n` does not start an extra,
|
|
5
|
+
* empty line — just as it renders no extra line in a plain `<pre>`.
|
|
6
|
+
*/
|
|
7
|
+
export function splitLines(text) {
|
|
8
|
+
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parses a set of 1-based line positions: an array of numbers, or a string like
|
|
12
|
+
* `"1, 3-5"`. Invalid or out-of-range parts (< 1, > `max`) are dropped; a reversed range
|
|
13
|
+
* (`"5-3"`) is read as `"3-5"`. `max` also bounds the work a huge range can cause.
|
|
14
|
+
*/
|
|
15
|
+
export function parseLineSet(spec, max = 100_000) {
|
|
16
|
+
const out = new Set();
|
|
17
|
+
const add = (n) => {
|
|
18
|
+
if (Number.isInteger(n) && n >= 1 && n <= max)
|
|
19
|
+
out.add(n);
|
|
20
|
+
};
|
|
21
|
+
if (Array.isArray(spec)) {
|
|
22
|
+
spec.forEach(add);
|
|
23
|
+
}
|
|
24
|
+
else if (typeof spec === "string") {
|
|
25
|
+
for (const part of spec.split(",")) {
|
|
26
|
+
const m = part.trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
|
|
27
|
+
if (!m)
|
|
28
|
+
continue;
|
|
29
|
+
const a = Number(m[1]);
|
|
30
|
+
const b = m[2] === undefined ? a : Number(m[2]);
|
|
31
|
+
const [from, to] = a <= b ? [a, b] : [b, a];
|
|
32
|
+
for (let k = from; k <= Math.min(to, max); k++)
|
|
33
|
+
add(k);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prepares a code sample for display (and copying): normalizes line endings, drops the
|
|
3
|
+
* blank lines at both ends and removes the indentation every non-blank line shares — so
|
|
4
|
+
* a sample written inline in an indented template renders flush left.
|
|
5
|
+
*
|
|
6
|
+
* Unlike `String.prototype.trim`, the first line keeps its indentation relative to the
|
|
7
|
+
* rest: `" a\n b"` becomes `"a\n b"`, not `"a\n b"`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function normalizeCode(code: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prepares a code sample for display (and copying): normalizes line endings, drops the
|
|
3
|
+
* blank lines at both ends and removes the indentation every non-blank line shares — so
|
|
4
|
+
* a sample written inline in an indented template renders flush left.
|
|
5
|
+
*
|
|
6
|
+
* Unlike `String.prototype.trim`, the first line keeps its indentation relative to the
|
|
7
|
+
* rest: `" a\n b"` becomes `"a\n b"`, not `"a\n b"`.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeCode(code) {
|
|
10
|
+
const lines = (code ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
11
|
+
const isBlank = (line) => !line.trim();
|
|
12
|
+
let start = 0;
|
|
13
|
+
let end = lines.length;
|
|
14
|
+
while (start < end && isBlank(lines[start]))
|
|
15
|
+
start++;
|
|
16
|
+
while (end > start && isBlank(lines[end - 1]))
|
|
17
|
+
end--;
|
|
18
|
+
const body = lines.slice(start, end);
|
|
19
|
+
// The longest leading-whitespace prefix shared by all non-blank lines. Compared as a
|
|
20
|
+
// string, so a tab and spaces never count as the same indentation.
|
|
21
|
+
let prefix;
|
|
22
|
+
for (const line of body) {
|
|
23
|
+
if (isBlank(line))
|
|
24
|
+
continue;
|
|
25
|
+
const indent = line.match(/^[ \t]*/)[0];
|
|
26
|
+
if (prefix === undefined)
|
|
27
|
+
prefix = indent;
|
|
28
|
+
else
|
|
29
|
+
while (!indent.startsWith(prefix))
|
|
30
|
+
prefix = prefix.slice(0, -1);
|
|
31
|
+
if (!prefix)
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
const cut = prefix?.length ?? 0;
|
|
35
|
+
if (!cut)
|
|
36
|
+
return body.join("\n");
|
|
37
|
+
// every non-blank line starts with `prefix`; a whitespace-only line may be shorter
|
|
38
|
+
return body.map((line) => (isBlank(line) ? "" : line.slice(cut))).join("\n");
|
|
39
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CodeBlockToken } from "../highlight/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Syntax colors are painted with the CSS Custom Highlight API: tokens become `Range`s in
|
|
4
|
+
* named `Highlight`s, styled by `::highlight(stuic-code-block-<type>)`. No markup is
|
|
5
|
+
* added — the code stays plain text nodes, so selecting, copying and the server-rendered
|
|
6
|
+
* HTML are untouched, and a browser without the API simply shows the plain text.
|
|
7
|
+
*
|
|
8
|
+
* The highlight registry is global: every block adds its own ranges to the shared
|
|
9
|
+
* `Highlight` of a type and removes exactly those on cleanup.
|
|
10
|
+
*/
|
|
11
|
+
export declare const HIGHLIGHT_PREFIX = "stuic-code-block-";
|
|
12
|
+
export declare function supportsCustomHighlights(): boolean;
|
|
13
|
+
/** The `::highlight()` name a token type is painted by */
|
|
14
|
+
export declare const highlightName: (type: string) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Paints `tokens` — offsets into `root`'s text content — and returns the cleanup that
|
|
17
|
+
* removes them. Offsets outside the text are clamped; empty tokens are skipped. A token
|
|
18
|
+
* may span several text nodes (a line-per-`<span>` rendering).
|
|
19
|
+
*/
|
|
20
|
+
export declare function paintTokens(root: Node, tokens: CodeBlockToken[]): () => void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax colors are painted with the CSS Custom Highlight API: tokens become `Range`s in
|
|
3
|
+
* named `Highlight`s, styled by `::highlight(stuic-code-block-<type>)`. No markup is
|
|
4
|
+
* added — the code stays plain text nodes, so selecting, copying and the server-rendered
|
|
5
|
+
* HTML are untouched, and a browser without the API simply shows the plain text.
|
|
6
|
+
*
|
|
7
|
+
* The highlight registry is global: every block adds its own ranges to the shared
|
|
8
|
+
* `Highlight` of a type and removes exactly those on cleanup.
|
|
9
|
+
*/
|
|
10
|
+
export const HIGHLIGHT_PREFIX = "stuic-code-block-";
|
|
11
|
+
export function supportsCustomHighlights() {
|
|
12
|
+
return (typeof CSS !== "undefined" &&
|
|
13
|
+
"highlights" in CSS &&
|
|
14
|
+
typeof globalThis.Highlight === "function");
|
|
15
|
+
}
|
|
16
|
+
/** The `::highlight()` name a token type is painted by */
|
|
17
|
+
export const highlightName = (type) => HIGHLIGHT_PREFIX +
|
|
18
|
+
String(type)
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9-]/g, "-");
|
|
21
|
+
/**
|
|
22
|
+
* Paints `tokens` — offsets into `root`'s text content — and returns the cleanup that
|
|
23
|
+
* removes them. Offsets outside the text are clamped; empty tokens are skipped. A token
|
|
24
|
+
* may span several text nodes (a line-per-`<span>` rendering).
|
|
25
|
+
*/
|
|
26
|
+
export function paintTokens(root, tokens) {
|
|
27
|
+
if (!tokens.length || !supportsCustomHighlights())
|
|
28
|
+
return () => { };
|
|
29
|
+
const nodes = [];
|
|
30
|
+
const starts = [];
|
|
31
|
+
let total = 0;
|
|
32
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
33
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
34
|
+
nodes.push(n);
|
|
35
|
+
starts.push(total);
|
|
36
|
+
total += n.data.length;
|
|
37
|
+
}
|
|
38
|
+
if (!nodes.length)
|
|
39
|
+
return () => { };
|
|
40
|
+
// The last node whose start is <= offset (a start) or < offset (an end), so a start at
|
|
41
|
+
// a node boundary lands in the node that holds the character, and an end in the one
|
|
42
|
+
// that holds the character before it. Zero-length nodes are skipped either way.
|
|
43
|
+
const locate = (offset, isEnd) => {
|
|
44
|
+
let lo = 0;
|
|
45
|
+
let hi = nodes.length - 1;
|
|
46
|
+
let found = 0;
|
|
47
|
+
while (lo <= hi) {
|
|
48
|
+
const mid = (lo + hi) >> 1;
|
|
49
|
+
if (isEnd ? starts[mid] < offset : starts[mid] <= offset) {
|
|
50
|
+
found = mid;
|
|
51
|
+
lo = mid + 1;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
hi = mid - 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [nodes[found], Math.min(offset - starts[found], nodes[found].data.length)];
|
|
58
|
+
};
|
|
59
|
+
const added = [];
|
|
60
|
+
for (const [start, end, type] of tokens) {
|
|
61
|
+
const s = Math.max(0, start);
|
|
62
|
+
const e = Math.min(total, end);
|
|
63
|
+
if (!(e > s))
|
|
64
|
+
continue;
|
|
65
|
+
const range = new Range();
|
|
66
|
+
range.setStart(...locate(s, false));
|
|
67
|
+
range.setEnd(...locate(e, true));
|
|
68
|
+
const name = highlightName(type);
|
|
69
|
+
let highlight = CSS.highlights.get(name);
|
|
70
|
+
if (!highlight) {
|
|
71
|
+
highlight = new Highlight();
|
|
72
|
+
CSS.highlights.set(name, highlight);
|
|
73
|
+
}
|
|
74
|
+
highlight.add(range);
|
|
75
|
+
added.push([highlight, range]);
|
|
76
|
+
}
|
|
77
|
+
return () => {
|
|
78
|
+
for (const [highlight, range] of added)
|
|
79
|
+
highlight.delete(range);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CodeBlockToken } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Highlights an HTTP message — a request or status line, headers, and a JSON body (after
|
|
4
|
+
* the blank line) — or just a block of headers. A non-JSON body is left plain.
|
|
5
|
+
*/
|
|
6
|
+
export declare function highlightHttp(code: string): CodeBlockToken[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { tokenizeJsonInto } from "./json.js";
|
|
2
|
+
const METHODS = "GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE";
|
|
3
|
+
const REQUEST_LINE = new RegExp(`^(${METHODS})([ \\t]+)(\\S+)(?:([ \\t]+)(HTTP\\/[\\d.]+))?[ \\t]*$`);
|
|
4
|
+
const STATUS_LINE = /^(HTTP\/[\d.]+)([ \t]+)(\d{3})\b/;
|
|
5
|
+
const HEADER = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+)(:)([ \t]*)(.*?)[ \t]*$/;
|
|
6
|
+
/**
|
|
7
|
+
* Highlights an HTTP message — a request or status line, headers, and a JSON body (after
|
|
8
|
+
* the blank line) — or just a block of headers. A non-JSON body is left plain.
|
|
9
|
+
*/
|
|
10
|
+
export function highlightHttp(code) {
|
|
11
|
+
const out = [];
|
|
12
|
+
let pos = 0;
|
|
13
|
+
let started = false;
|
|
14
|
+
while (pos <= code.length) {
|
|
15
|
+
const nl = code.indexOf("\n", pos);
|
|
16
|
+
const end = nl < 0 ? code.length : nl;
|
|
17
|
+
const line = code.slice(pos, end).replace(/\r$/, "");
|
|
18
|
+
if (!line.trim()) {
|
|
19
|
+
// the blank line after the head: the rest is the body
|
|
20
|
+
if (started) {
|
|
21
|
+
const body = code.slice(end + 1);
|
|
22
|
+
if (/^\s*[{[]/.test(body))
|
|
23
|
+
tokenizeJsonInto(body, end + 1, out);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
started = true;
|
|
29
|
+
let m;
|
|
30
|
+
if ((m = REQUEST_LINE.exec(line))) {
|
|
31
|
+
const [, method, gap1, target, gap2, version] = m;
|
|
32
|
+
let at = pos;
|
|
33
|
+
out.push([at, (at += method.length), "keyword"]);
|
|
34
|
+
at += gap1.length;
|
|
35
|
+
out.push([at, (at += target.length), "string"]);
|
|
36
|
+
if (version) {
|
|
37
|
+
at += gap2.length;
|
|
38
|
+
out.push([at, at + version.length, "meta"]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if ((m = STATUS_LINE.exec(line))) {
|
|
42
|
+
const [, version, gap, status] = m;
|
|
43
|
+
out.push([pos, pos + version.length, "meta"]);
|
|
44
|
+
const at = pos + version.length + gap.length;
|
|
45
|
+
out.push([at, at + status.length, "number"]);
|
|
46
|
+
}
|
|
47
|
+
else if ((m = HEADER.exec(line))) {
|
|
48
|
+
const [, name, colon, gap, value] = m;
|
|
49
|
+
let at = pos;
|
|
50
|
+
out.push([at, (at += name.length), "property"]);
|
|
51
|
+
out.push([at, (at += colon.length), "punctuation"]);
|
|
52
|
+
at += gap.length;
|
|
53
|
+
if (value)
|
|
54
|
+
out.push([at, at + value.length, /^\d+$/.test(value) ? "number" : "string"]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (nl < 0)
|
|
58
|
+
break;
|
|
59
|
+
pos = nl + 1;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CodeBlockHighlighter } from "./types.js";
|
|
2
|
+
import { highlightJson } from "./json.js";
|
|
3
|
+
import { highlightHttp } from "./http.js";
|
|
4
|
+
import { highlightShell } from "./shell.js";
|
|
5
|
+
export type { CodeBlockHighlighter, CodeBlockToken, CodeBlockTokenType, } from "./types.js";
|
|
6
|
+
export { highlightJson, highlightHttp, highlightShell };
|
|
7
|
+
/** The `lang`s `highlightCode` knows (lower case). */
|
|
8
|
+
export declare const HIGHLIGHT_CODE_LANGS: readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* `CodeBlock`'s built-in highlighter: JSON, HTTP and shell, picked by `lang` (case
|
|
11
|
+
* insensitive — see `HIGHLIGHT_CODE_LANGS`). Any other `lang` gets no tokens. Compose it
|
|
12
|
+
* to add a language: `(code, lang) => lang === "ts" ? myTs(code) : highlightCode(code, lang)`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const highlightCode: CodeBlockHighlighter;
|