@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.
Files changed (43) hide show
  1. package/AGENTS.md +2 -2
  2. package/API.md +29 -0
  3. package/dist/components/CodeBlock/CodeBlock.fixture.svelte +57 -0
  4. package/dist/components/CodeBlock/CodeBlock.fixture.svelte.d.ts +10 -0
  5. package/dist/components/CodeBlock/CodeBlock.svelte +441 -0
  6. package/dist/components/CodeBlock/CodeBlock.svelte.d.ts +113 -0
  7. package/dist/components/CodeBlock/README.md +336 -0
  8. package/dist/components/CodeBlock/_internal/lines.d.ts +13 -0
  9. package/dist/components/CodeBlock/_internal/lines.js +37 -0
  10. package/dist/components/CodeBlock/_internal/normalize-code.d.ts +9 -0
  11. package/dist/components/CodeBlock/_internal/normalize-code.js +39 -0
  12. package/dist/components/CodeBlock/_internal/paint.d.ts +20 -0
  13. package/dist/components/CodeBlock/_internal/paint.js +81 -0
  14. package/dist/components/CodeBlock/highlight/http.d.ts +6 -0
  15. package/dist/components/CodeBlock/highlight/http.js +62 -0
  16. package/dist/components/CodeBlock/highlight/index.d.ts +14 -0
  17. package/dist/components/CodeBlock/highlight/index.js +29 -0
  18. package/dist/components/CodeBlock/highlight/json.d.ts +9 -0
  19. package/dist/components/CodeBlock/highlight/json.js +74 -0
  20. package/dist/components/CodeBlock/highlight/shell.d.ts +8 -0
  21. package/dist/components/CodeBlock/highlight/shell.js +230 -0
  22. package/dist/components/CodeBlock/highlight/types.d.ts +17 -0
  23. package/dist/components/CodeBlock/highlight/types.js +1 -0
  24. package/dist/components/CodeBlock/i18n-sk.d.ts +17 -0
  25. package/dist/components/CodeBlock/i18n-sk.js +22 -0
  26. package/dist/components/CodeBlock/i18n.d.ts +37 -0
  27. package/dist/components/CodeBlock/i18n.js +34 -0
  28. package/dist/components/CodeBlock/index.css +470 -0
  29. package/dist/components/CodeBlock/index.d.ts +4 -0
  30. package/dist/components/CodeBlock/index.js +4 -0
  31. package/dist/components/RangeSlider/README.md +22 -22
  32. package/dist/components/RangeSlider/RangeSlider.svelte +4 -4
  33. package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +2 -2
  34. package/dist/components/RangeSlider/index.css +9 -6
  35. package/dist/components/Slider/README.md +23 -24
  36. package/dist/components/Slider/Slider.svelte +4 -4
  37. package/dist/components/Slider/Slider.svelte.d.ts +2 -2
  38. package/dist/components/Slider/index.css +6 -3
  39. package/dist/index.css +1 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +1 -0
  42. package/docs/domains/components.md +59 -1
  43. 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;