From 0fc3022c3a4306fb73c5005705b5f4522c88b5be Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Wed, 29 Apr 2026 06:26:02 +0200 Subject: [PATCH 1/2] Introduce codeblock --- ARCHITECTURE.md | 10 ++- IMPLEMENTATION_PLAN.md | 16 +++++ src/lib/demo_doc.js | 35 ++++++++++ src/lib/document_schema.js | 23 ++++++- src/lib/server/migrations.js | 88 +++++++++++++++++++++++++- src/routes/commands.svelte.js | 15 +++++ src/routes/components/Codeblock.svelte | 25 ++++++++ src/routes/components/Line.svelte | 14 ++++ src/routes/create_session.js | 53 +++++++++++++++- 9 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 src/routes/components/Codeblock.svelte create mode 100644 src/routes/components/Line.svelte diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c17fcaf8..2096a813 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -407,10 +407,18 @@ This model is intentionally simple, but it still needs basic hardening: ### Document types -- **Page documents** — each page on the site (e.g. home, about, blog posts). Contains all the page-specific nodes: prose, features, galleries, figures, etc. Also contains references to shared documents (nav, footer) via node properties. +- **Page documents** — each page on the site (e.g. home, about, blog posts). Contains all the page-specific nodes: prose, features, galleries, figures, codeblocks, etc. Also contains references to shared documents (nav, footer) via node properties. - **Nav document** — a single shared document containing the navigation structure (`nav` node + `nav_item` nodes). Referenced by page documents via the `nav` property on the `page` node. - **Footer document** — a single shared document containing the footer structure (`footer` node + `footer_link_column` + `footer_link` nodes). Referenced by page documents via the `footer` property on the `page` node. +### Codeblock nodes + +`codeblock` is a page body block node for displaying editable code samples. + +A `codeblock` has a `lines` property with type `node_array`. The allowed child node type is `line`, and the default child node type is `line`. Each line is represented as one `line` node of kind `text`, so codeblocks behave like lists structurally while preserving line-level editing. + +Codeblock rendering should use monospace text, preserve whitespace, prevent line wrapping, and allow horizontal overflow scrolling. This keeps long code lines on a single visual line instead of wrapping within the block. Codeblock lines should not allow embedded newline characters, so Shift+Enter must not insert newlines inside `line` nodes. + ### Page ids, slugs, and routes Page documents continue to have a stable internal `document_id`, but public page URLs are **slug-based** for non-home pages. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index d9ad3860..31c69a34 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -2,6 +2,22 @@ This document tracks what to implement next. One step at a time. All implementation must conform to the design decisions in [ARCHITECTURE.md](ARCHITECTURE.md) — if a conflict arises, update the architecture first, then implement. +## Next implementation draft — codeblock node + +Add a `codeblock` page body node for editable code samples. + +Implementation steps: + +1. Add `codeblock` to the page `body` node array in `src/lib/document_schema.js`. +2. Add a `line` schema entry of kind `text` with an annotated text `content` property that does not allow newlines. +3. Add a `codeblock` schema entry with a `lines` node array that accepts `line` nodes and defaults to `line`. +4. Add a `Codeblock.svelte` node component that renders `lines` with `NodeArrayProperty`, monospace styling, preserved whitespace, no wrapping, and horizontal overflow scrolling. +5. Register `Codeblock` in `src/routes/create_session.js`. +6. Add a `codeblock` inserter that creates one `codeblock` containing one or more `line` nodes. +7. Add the requested sample code as a seeded `codeblock` in `src/lib/demo_doc.js` using `line` nodes. +8. Add a migration that inserts the requested sample `codeblock` into existing seeded page documents using `line` nodes. +9. Add a migration that converts existing `codeblock.lines` children from `text` nodes to `line` nodes. + ## Next implementation draft — admin authentication This step adds simple owner authentication for editing and private page-management features. diff --git a/src/lib/demo_doc.js b/src/lib/demo_doc.js index 47c0a248..6de43a04 100644 --- a/src/lib/demo_doc.js +++ b/src/lib/demo_doc.js @@ -1292,6 +1292,40 @@ const FULL_DOC = { "focal_point_y": 0.5, "object_fit": "contain" }, + "codeblock_sample_line_1": { + "id": "codeblock_sample_line_1", + "type": "line", + "content": { + "text": "function hello(str) {", + "annotations": [] + } + }, + "codeblock_sample_line_2": { + "id": "codeblock_sample_line_2", + "type": "line", + "content": { + "text": " return 'Hello ' + str;", + "annotations": [] + } + }, + "codeblock_sample_line_3": { + "id": "codeblock_sample_line_3", + "type": "line", + "content": { + "text": "}", + "annotations": [] + } + }, + "codeblock_sample_1": { + "id": "codeblock_sample_1", + "type": "codeblock", + "colorset": 0, + "lines": [ + "codeblock_sample_line_1", + "codeblock_sample_line_2", + "codeblock_sample_line_3" + ] + }, "page_1": { "id": "page_1", "type": "page", @@ -1307,6 +1341,7 @@ const FULL_DOC = { "body": [ "hero_1", "RtYpQwXsZvNmKjHgFdSaLe", + "codeblock_sample_1", "xKmNqPrStVwYzAbCdEfGh", "BPdekRaDEUcQZqtEwPwBvyu", "jLnPqRsTuVwXyZaBcDeFg", diff --git a/src/lib/document_schema.js b/src/lib/document_schema.js index 303375e4..5ef19577 100644 --- a/src/lib/document_schema.js +++ b/src/lib/document_schema.js @@ -24,7 +24,7 @@ export const document_schema = define_document_schema({ }, body: { type: 'node_array', - node_types: ['prose', 'figure', 'gallery', 'feature', 'link_collection', 'hero'], + node_types: ['prose', 'figure', 'gallery', 'feature', 'link_collection', 'hero', 'codeblock'], default_node_type: 'prose' }, nav: { @@ -163,6 +163,27 @@ export const document_schema = define_document_schema({ } } }, + codeblock: { + kind: 'block', + properties: { + colorset: { type: 'integer', default: 0 }, + lines: { + type: 'node_array', + node_types: ['line'], + default_node_type: 'line' + } + } + }, + line: { + kind: 'text', + properties: { + content: { + type: 'annotated_text', + node_types: [], + allow_newlines: false + } + } + }, text: { kind: 'text', properties: { diff --git a/src/lib/server/migrations.js b/src/lib/server/migrations.js index 90b5ee17..3e37fee1 100644 --- a/src/lib/server/migrations.js +++ b/src/lib/server/migrations.js @@ -178,5 +178,91 @@ export default [ updated_at = COALESCE(updated_at, ?) ` ).run(now, now); + }, + function add_seeded_codeblock_sample({ db }) { + const row = db + .prepare('SELECT document_id, data FROM documents WHERE document_id = ?') + .get('page_1'); + if (!row) return; + + const doc = JSON.parse(row.data); + const page_node = doc?.nodes?.[doc.document_id]; + + if (!page_node || page_node.type !== 'page' || !Array.isArray(page_node.body)) return; + if (doc.nodes.codeblock_sample_1 || page_node.body.includes('codeblock_sample_1')) return; + + doc.nodes.codeblock_sample_line_1 = { + id: 'codeblock_sample_line_1', + type: 'line', + content: { + text: 'function hello(str) {', + annotations: [] + } + }; + doc.nodes.codeblock_sample_line_2 = { + id: 'codeblock_sample_line_2', + type: 'line', + content: { + text: " return 'Hello ' + str;", + annotations: [] + } + }; + doc.nodes.codeblock_sample_line_3 = { + id: 'codeblock_sample_line_3', + type: 'line', + content: { + text: '}', + annotations: [] + } + }; + doc.nodes.codeblock_sample_1 = { + id: 'codeblock_sample_1', + type: 'codeblock', + colorset: 0, + lines: [ + 'codeblock_sample_line_1', + 'codeblock_sample_line_2', + 'codeblock_sample_line_3' + ] + }; + + const after_node_id = 'RtYpQwXsZvNmKjHgFdSaLe'; + const insert_index = page_node.body.indexOf(after_node_id); + page_node.body.splice( + insert_index >= 0 ? insert_index + 1 : 1, + 0, + 'codeblock_sample_1' + ); + + db.prepare('UPDATE documents SET data = ? WHERE document_id = ?').run( + JSON.stringify(doc), + row.document_id + ); + }, + function convert_codeblock_text_children_to_lines({ db }) { + const rows = db.prepare('SELECT document_id, data FROM documents').all(); + const update_doc = db.prepare('UPDATE documents SET data = ? WHERE document_id = ?'); + + for (const row of rows) { + const doc = JSON.parse(row.data); + let did_change = false; + + for (const node of Object.values(doc.nodes ?? {})) { + if (node?.type !== 'codeblock' || !Array.isArray(node.lines)) continue; + + for (const line_id of node.lines) { + const line_node = doc.nodes?.[line_id]; + if (line_node?.type !== 'text') continue; + + line_node.type = 'line'; + delete line_node.layout; + did_change = true; + } + } + + if (did_change) { + update_doc.run(JSON.stringify(doc), row.document_id); + } + } } -]; \ No newline at end of file +]; diff --git a/src/routes/commands.svelte.js b/src/routes/commands.svelte.js index 4d86bd3a..fb483002 100644 --- a/src/routes/commands.svelte.js +++ b/src/routes/commands.svelte.js @@ -113,6 +113,21 @@ export class CycleColorsetCommand extends Command { } } +export class BlockCodeblockNewLineCommand extends Command { + is_enabled() { + const { session, editable } = this.context; + if (!editable || session.selection?.type !== 'text') return false; + + const node = session.get(session.selection.path.slice(0, -1)); + return node?.type === 'line'; + } + + execute() { + // Intentionally empty. When this command is first in the Shift+Enter + // keymap, Svedit prevents the browser newline and stops command fallback. + } +} + export class ReplaceMediaCommand extends Command { is_enabled() { const session = this.context.session; diff --git a/src/routes/components/Codeblock.svelte b/src/routes/components/Codeblock.svelte new file mode 100644 index 00000000..54f512dc --- /dev/null +++ b/src/routes/components/Codeblock.svelte @@ -0,0 +1,25 @@ + + + +
+
+
+
+ +
+
+
+
+
\ No newline at end of file diff --git a/src/routes/components/Line.svelte b/src/routes/components/Line.svelte new file mode 100644 index 00000000..05573b2b --- /dev/null +++ b/src/routes/components/Line.svelte @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/src/routes/create_session.js b/src/routes/create_session.js index b6065fec..3a2c9a3a 100644 --- a/src/routes/create_session.js +++ b/src/routes/create_session.js @@ -18,7 +18,8 @@ import { ToggleLinkCommand, EditLinkCommand, ReplaceMediaCommand, - EditImageCommand + EditImageCommand, + BlockCodeblockNewLineCommand } from './commands.svelte.js'; // Command imported from 'svedit' above @@ -35,6 +36,7 @@ import FooterLink from './components/FooterLink.svelte'; import Prose from './components/Prose.svelte'; import Text from './components/Text.svelte'; +import Line from './components/Line.svelte'; import Gallery from './components/Gallery.svelte'; import GalleryItem from './components/GalleryItem.svelte'; import LinkCollection from './components/LinkCollection.svelte'; @@ -44,6 +46,7 @@ import Decoration from './components/Decoration.svelte'; import Feature from './components/Feature.svelte'; import Hero from './components/Hero.svelte'; import Button from './components/Button.svelte'; +import Codeblock from './components/Codeblock.svelte'; import Image from './components/Image.svelte'; import Video from './components/Video.svelte'; @@ -136,8 +139,10 @@ const session_config = { FooterLink, Hero, Button, + Codeblock, Prose, Text, + Line, Image, Video, Figure, @@ -250,7 +255,8 @@ const session_config = { gallery: 4, nav_item: 2, button: 2, - hero: 4 + hero: 4, + codeblock: 1 }, /** @@ -265,6 +271,7 @@ const session_config = { const commands = { select_all: new SelectAllCommand(context), insert_default_node: new InsertDefaultNodeCommand(context), + block_codeblock_new_line: new BlockCodeblockNewLineCommand(context), add_new_line: new AddNewLineCommand(context), break_text_node: new BreakTextNodeCommand(context), toggle_strong: new ToggleAnnotationCommand('strong', context), @@ -290,7 +297,12 @@ const session_config = { enter: [commands.replace_media, commands.break_text_node, commands.insert_default_node], // In case of a node cursor, fall back to inserting a default node. This is needed // because on iOS selecting a node cursor triggers auto capitalization (shift pressed) - 'shift+enter': [commands.replace_media, commands.add_new_line, commands.insert_default_node], + 'shift+enter': [ + commands.replace_media, + commands.block_codeblock_new_line, + commands.add_new_line, + commands.insert_default_node + ], 'alt+enter': [commands.edit_image], 'meta+b,ctrl+b': [commands.toggle_strong], 'meta+i,ctrl+i': [commands.toggle_emphasis], @@ -362,6 +374,41 @@ const session_config = { focus_offset: 0 }); }, + line: function (tr, content = { text: '', annotations: [] }) { + const new_line = { + id: nanoid(), + type: 'line', + content + }; + tr.create(new_line); + tr.insert_nodes([new_line.id]); + tr.set_selection({ + type: 'text', + path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], + anchor_offset: 0, + focus_offset: 0 + }); + }, + codeblock: function (tr) { + const codeblock_lines = [''].map((text) => { + const line = { + id: nanoid(), + type: 'line', + content: { text, annotations: [] } + }; + tr.create(line); + return line.id; + }); + + const new_codeblock = { + id: nanoid(), + type: 'codeblock', + colorset: 0, + lines: codeblock_lines + }; + tr.create(new_codeblock); + tr.insert_nodes([new_codeblock.id]); + }, feature: function (tr) { const new_feature_id = tr.build('new_feature', { feature_image: { From 3bf03148a0a4031ce7810e65ed74825e91a8d23c Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Wed, 29 Apr 2026 06:47:04 +0200 Subject: [PATCH 2/2] Implement basic syntax highlighting --- ARCHITECTURE.md | 8 + IMPLEMENTATION_PLAN.md | 6 + package-lock.json | 559 +++++++++++++++++++++++++ package.json | 2 +- src/lib/client/syntax_highlighting.js | 420 +++++++++++++++++++ src/lib/demo_doc.js | 1 + src/lib/document_schema.js | 1 + src/lib/server/migrations.js | 8 +- src/routes/components/Codeblock.svelte | 12 +- src/routes/create_session.js | 1 + 10 files changed, 1015 insertions(+), 3 deletions(-) create mode 100644 src/lib/client/syntax_highlighting.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2096a813..f4281fad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -417,6 +417,14 @@ This model is intentionally simple, but it still needs basic hardening: A `codeblock` has a `lines` property with type `node_array`. The allowed child node type is `line`, and the default child node type is `line`. Each line is represented as one `line` node of kind `text`, so codeblocks behave like lists structurally while preserving line-level editing. +Codeblocks support syntax highlighting for JavaScript and bash without replacing Svedit's editing surface with a code editor such as CodeMirror. Highlighting is visual-only and must not mutate document text or introduce annotation nodes. + +Syntax highlighting is implemented with the browser CSS Custom Highlight API. The rendered editable text remains normal DOM text owned by Svedit, while a client-side highlighter tokenizes the full codeblock text and maps token ranges back onto the editable line text nodes using `Range` objects. Those ranges are registered in `CSS.highlights` under stable highlight names. + +Tokenization should use existing TextMate grammars through Shiki. Only JavaScript and bash are required initially. The highlighter should load lazily on the client and should not run during SSR. If the CSS Highlight API or grammar loading is unavailable, codeblocks should remain editable and readable without highlighting. + +A `codeblock` has a language property. Supported language values are `javascript` and `bash`, with `javascript` as the default for inserted and seeded code samples. The language value controls which grammar is used for tokenization. + Codeblock rendering should use monospace text, preserve whitespace, prevent line wrapping, and allow horizontal overflow scrolling. This keeps long code lines on a single visual line instead of wrapping within the block. Codeblock lines should not allow embedded newline characters, so Shift+Enter must not insert newlines inside `line` nodes. ### Page ids, slugs, and routes diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 31c69a34..eb4438bb 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -17,6 +17,12 @@ Implementation steps: 7. Add the requested sample code as a seeded `codeblock` in `src/lib/demo_doc.js` using `line` nodes. 8. Add a migration that inserts the requested sample `codeblock` into existing seeded page documents using `line` nodes. 9. Add a migration that converts existing `codeblock.lines` children from `text` nodes to `line` nodes. +10. Add a `language` string property to `codeblock`, supporting `javascript` and `bash`, with `javascript` as the default for inserted and seeded samples. +11. Add Shiki as the TextMate grammar-backed tokenizer dependency for client-side syntax highlighting. +12. Add a client-only syntax highlighter helper that lazily loads Shiki with only the JavaScript and bash grammars and returns token ranges for a full codeblock string. +13. Add a CSS Custom Highlight API helper that maps token offsets back onto rendered editable `line` text nodes using `Range` objects and registers stable `CSS.highlights` entries per codeblock. +14. Wire the highlighting helper into `Codeblock.svelte` so highlighting updates after line text or language changes without mutating Svedit document data or replacing the editable DOM. +15. Add global `::highlight(...)` styles for syntax token categories and keep codeblocks readable/editable if the CSS Highlight API or grammar loading is unavailable. ## Next implementation draft — admin authentication diff --git a/package-lock.json b/package-lock.json index d91f0b4c..7ee68911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@jsquash/webp": "^1.5.0", "@sveltejs/adapter-node": "^5.5.4", "nanoid": "^5.1.6", + "shiki": "^4.0.2", "slugify": "^1.6.6", "svedit": "^0.9.0", "valibot": "^1.2.0" @@ -1022,6 +1023,106 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1436,6 +1537,15 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1443,6 +1553,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -1465,6 +1584,18 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1545,6 +1676,36 @@ "node": "18 || 20 || >=22" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1570,6 +1731,16 @@ "node": ">=6" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -1647,6 +1818,15 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1662,6 +1842,19 @@ "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -2099,6 +2292,52 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2540,6 +2779,116 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -2627,6 +2976,23 @@ ], "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2991,6 +3357,16 @@ } } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3015,6 +3391,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3168,6 +3568,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3200,6 +3619,30 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3406,6 +3849,16 @@ "node": ">=6" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3447,6 +3900,74 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3478,6 +3999,34 @@ } } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", @@ -3624,6 +4173,16 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index f8bb93ad..91df2dc6 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,10 @@ "vite": "^8.0.1" }, "dependencies": { - "@jsquash/webp": "^1.5.0", "@sveltejs/adapter-node": "^5.5.4", "nanoid": "^5.1.6", + "shiki": "^4.0.2", "slugify": "^1.6.6", "svedit": "^0.9.0", "valibot": "^1.2.0" diff --git a/src/lib/client/syntax_highlighting.js b/src/lib/client/syntax_highlighting.js new file mode 100644 index 00000000..f9fe2dd4 --- /dev/null +++ b/src/lib/client/syntax_highlighting.js @@ -0,0 +1,420 @@ +const SUPPORTED_LANGUAGES = new Set(['javascript', 'bash']); +const TOKEN_KIND_BY_COLOR = new Map(); +const ACTIVE_RANGES_BY_CODEBLOCK = new Map(); +const TOKEN_KIND_NAMES = [ + 'keyword', + 'string', + 'comment', + 'number', + 'function', + 'type', + 'variable', + 'operator', + 'punctuation', + 'constant', + 'property' +]; + +const SYNTAX_HIGHLIGHT_STYLES = ` +::highlight(svedit-syntax-keyword) { + color: oklch(50% 0.22 285); +} + +::highlight(svedit-syntax-string) { + color: oklch(48% 0.15 145); +} + +::highlight(svedit-syntax-comment) { + color: color-mix(in oklch, currentColor 45%, transparent); + font-style: italic; +} + +::highlight(svedit-syntax-number) { + color: oklch(52% 0.18 35); +} + +::highlight(svedit-syntax-function) { + color: oklch(48% 0.16 255); +} + +::highlight(svedit-syntax-type) { + color: oklch(46% 0.15 205); +} + +::highlight(svedit-syntax-variable) { + color: oklch(34% 0.04 260); +} + +::highlight(svedit-syntax-operator) { + color: oklch(45% 0.16 285); +} + +::highlight(svedit-syntax-punctuation) { + color: oklch(34% 0.04 260); +} + +::highlight(svedit-syntax-constant) { + color: oklch(50% 0.17 25); +} + +::highlight(svedit-syntax-property) { + color: oklch(42% 0.13 230); +} +`; + +let did_inject_syntax_highlight_styles = false; +let highlighter_promise = null; +let highlighter_failed = false; +let token_kind_index = 0; + +function normalize_language(language) { + if (language === 'js') return 'javascript'; + if (language === 'shell' || language === 'sh') return 'bash'; + if (SUPPORTED_LANGUAGES.has(language)) return language; + return 'javascript'; +} + +function inject_syntax_highlight_styles() { + if (did_inject_syntax_highlight_styles) return; + if (typeof document === 'undefined') return; + if (document.getElementById('svedit-syntax-highlight-styles')) { + did_inject_syntax_highlight_styles = true; + return; + } + + const style = document.createElement('style'); + style.id = 'svedit-syntax-highlight-styles'; + style.textContent = SYNTAX_HIGHLIGHT_STYLES; + document.head.appendChild(style); + did_inject_syntax_highlight_styles = true; +} + +function get_css_highlight_support() { + if (typeof CSS === 'undefined') return null; + if (!CSS.highlights) return null; + if (typeof Highlight === 'undefined') return null; + if (typeof Range === 'undefined') return null; + inject_syntax_highlight_styles(); + return CSS.highlights; +} + +async function get_highlighter() { + if (highlighter_failed) return null; + + if (!highlighter_promise) { + highlighter_promise = Promise.all([ + import('shiki/core'), + import('shiki/engine/oniguruma'), + import('shiki/wasm'), + import('@shikijs/langs/javascript'), + import('@shikijs/langs/bash'), + import('@shikijs/themes/github-light') + ]) + .then(([{ createHighlighterCore }, { createOnigurumaEngine }, wasm, javascript, bash, github_light]) => + createHighlighterCore({ + engine: createOnigurumaEngine(wasm.default), + langs: [javascript.default, bash.default], + themes: [github_light.default] + }) + ) + .catch((error) => { + console.warn('Syntax highlighting unavailable:', error); + highlighter_failed = true; + highlighter_promise = null; + return null; + }); + } + + return highlighter_promise; +} + +function get_token_kind(token) { + if (token.explanation?.length) { + const scopes = token.explanation + .flatMap((explanation) => explanation.scopes ?? []) + .map((scope) => scope.scopeName ?? scope.name ?? '') + .join(' '); + + if (/\bcomment\b/.test(scopes)) return 'comment'; + if (/\bstring\b/.test(scopes)) return 'string'; + if (/\bconstant\.numeric\b|\bnumber\b/.test(scopes)) return 'number'; + if (/\bkeyword\b|\bstorage\b/.test(scopes)) return 'keyword'; + if (/\bentity\.name\.function\b|\bsupport\.function\b/.test(scopes)) return 'function'; + if (/\bentity\.name\.type\b|\bsupport\.type\b|\bsupport\.class\b/.test(scopes)) return 'type'; + if (/\bvariable\b/.test(scopes)) return 'variable'; + if (/\bkeyword\.operator\b/.test(scopes)) return 'operator'; + if (/\bpunctuation\b/.test(scopes)) return 'punctuation'; + if (/\bconstant\b/.test(scopes)) return 'constant'; + if (/\bproperty\b/.test(scopes)) return 'property'; + } + + if (!token.color) return 'variable'; + + if (!TOKEN_KIND_BY_COLOR.has(token.color)) { + const kind = TOKEN_KIND_NAMES[token_kind_index % TOKEN_KIND_NAMES.length]; + TOKEN_KIND_BY_COLOR.set(token.color, kind); + token_kind_index += 1; + } + + return TOKEN_KIND_BY_COLOR.get(token.color); +} + +function get_codeblock_text(line_nodes) { + return line_nodes.map((line_node) => line_node?.content?.text ?? '').join('\n'); +} + +function escape_selector_value(value) { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value); + } + + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function get_line_text_element(codeblock_element, line_id) { + return codeblock_element.querySelector(`[data-node-id="${escape_selector_value(line_id)}"] [data-type="text"]`); +} + +function get_text_node(element) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + + while (walker.nextNode()) { + const text_node = walker.currentNode; + if (text_node.nodeValue !== '') return text_node; + } + + return null; +} + +function build_line_index(line_nodes, codeblock_element) { + const line_index = []; + let start_offset = 0; + + for (const line_node of line_nodes) { + const text = line_node?.content?.text ?? ''; + const text_element = get_line_text_element(codeblock_element, line_node.id); + const text_node = text_element ? get_text_node(text_element) : null; + + line_index.push({ + id: line_node.id, + start_offset, + end_offset: start_offset + text.length, + text, + text_node + }); + + start_offset += text.length + 1; + } + + return line_index; +} + +function get_line_for_offset(line_index, offset) { + return line_index.find((line) => offset >= line.start_offset && offset <= line.end_offset) ?? null; +} + +function create_range_for_line_slice(line, start_offset, end_offset) { + if (!line.text_node) return null; + if (start_offset >= end_offset) return null; + + const range = new Range(); + range.setStart(line.text_node, Math.max(0, start_offset - line.start_offset)); + range.setEnd(line.text_node, Math.min(line.text.length, end_offset - line.start_offset)); + return range; +} + +function create_ranges_for_token(line_index, start_offset, end_offset) { + const ranges = []; + let current_offset = start_offset; + + while (current_offset < end_offset) { + const line = get_line_for_offset(line_index, current_offset); + if (!line) break; + + const slice_end_offset = Math.min(end_offset, line.end_offset); + const range = create_range_for_line_slice(line, current_offset, slice_end_offset); + + if (range) { + ranges.push(range); + } + + current_offset = slice_end_offset; + + if (current_offset < end_offset) { + current_offset += 1; + } + } + + return ranges; +} + +function get_highlight_name(kind) { + return `svedit-syntax-${kind}`; +} + +function update_shared_highlights() { + const highlights = get_css_highlight_support(); + if (!highlights) return; + + for (const kind of TOKEN_KIND_NAMES) { + const ranges = []; + + for (const ranges_by_kind of ACTIVE_RANGES_BY_CODEBLOCK.values()) { + ranges.push(...(ranges_by_kind.get(kind) ?? [])); + } + + highlights.delete(get_highlight_name(kind)); + + if (ranges.length > 0) { + highlights.set(get_highlight_name(kind), new Highlight(...ranges)); + } + } +} + +function clear_codeblock_ranges(codeblock_id) { + ACTIVE_RANGES_BY_CODEBLOCK.delete(codeblock_id); + update_shared_highlights(); +} + +function set_codeblock_ranges(codeblock_id, ranges_by_kind) { + ACTIVE_RANGES_BY_CODEBLOCK.set(codeblock_id, ranges_by_kind); + update_shared_highlights(); +} + +function flatten_tokens(token_lines) { + const tokens = []; + + for (const line_tokens of token_lines) { + for (const token of line_tokens) { + tokens.push(token); + } + } + + return tokens; +} + +export async function highlight_codeblock({ + codeblock_id, + codeblock_element, + line_nodes, + language = 'javascript' +}) { + const highlights = get_css_highlight_support(); + + if (!codeblock_id) { + return () => {}; + } + + if (!highlights || !codeblock_element || !line_nodes?.length) { + clear_codeblock_ranges(codeblock_id); + return () => clear_codeblock_ranges(codeblock_id); + } + + const code = get_codeblock_text(line_nodes); + const normalized_language = normalize_language(language); + const line_index = build_line_index(line_nodes, codeblock_element); + const highlighter = await get_highlighter(); + if (!highlighter) { + clear_codeblock_ranges(codeblock_id); + return () => clear_codeblock_ranges(codeblock_id); + } + + let token_result; + try { + token_result = highlighter.codeToTokens(code, { + lang: normalized_language, + theme: 'github-light', + includeExplanation: true + }); + } catch (error) { + console.warn('Syntax highlighting failed:', error); + clear_codeblock_ranges(codeblock_id); + return () => clear_codeblock_ranges(codeblock_id); + } + + const ranges_by_kind = new Map(); + const tokens = flatten_tokens(token_result.tokens); + + for (const token of tokens) { + const kind = get_token_kind(token); + const ranges = create_ranges_for_token( + line_index, + token.offset, + token.offset + token.content.length + ); + + if (!ranges.length) continue; + + const kind_ranges = ranges_by_kind.get(kind) ?? []; + kind_ranges.push(...ranges); + ranges_by_kind.set(kind, kind_ranges); + } + + set_codeblock_ranges(codeblock_id, ranges_by_kind); + + return () => clear_codeblock_ranges(codeblock_id); +} + +export function syntax_highlighting(codeblock_element, options) { + let current_options = options; + let cleanup = () => {}; + let refresh_id = 0; + + async function refresh() { + const current_refresh_id = refresh_id + 1; + refresh_id = current_refresh_id; + + cleanup(); + cleanup = () => {}; + + let next_cleanup; + try { + next_cleanup = await highlight_codeblock({ + ...(current_options ?? {}), + codeblock_element + }); + } catch (error) { + console.warn('Syntax highlighting failed:', error); + + if (current_options?.codeblock_id) { + clear_codeblock_highlights(current_options.codeblock_id); + } + + return; + } + + if (current_refresh_id !== refresh_id) { + next_cleanup(); + return; + } + + cleanup = next_cleanup; + } + + refresh(); + + return { + update(next_options) { + current_options = next_options; + refresh(); + }, + destroy() { + refresh_id += 1; + cleanup(); + + if (current_options?.codeblock_id) { + clear_codeblock_highlights(current_options.codeblock_id); + } + } + }; +} + +export function get_codeblock_highlight_class() { + return TOKEN_KIND_NAMES.map((kind) => `::highlight(${get_highlight_name(kind)})`).join(', '); +} + +export function clear_codeblock_highlights(codeblock_id) { + clear_codeblock_ranges(codeblock_id); +} + +export { TOKEN_KIND_NAMES }; \ No newline at end of file diff --git a/src/lib/demo_doc.js b/src/lib/demo_doc.js index 6de43a04..0ccdb5ac 100644 --- a/src/lib/demo_doc.js +++ b/src/lib/demo_doc.js @@ -1320,6 +1320,7 @@ const FULL_DOC = { "id": "codeblock_sample_1", "type": "codeblock", "colorset": 0, + "language": "javascript", "lines": [ "codeblock_sample_line_1", "codeblock_sample_line_2", diff --git a/src/lib/document_schema.js b/src/lib/document_schema.js index 5ef19577..7505e3d6 100644 --- a/src/lib/document_schema.js +++ b/src/lib/document_schema.js @@ -167,6 +167,7 @@ export const document_schema = define_document_schema({ kind: 'block', properties: { colorset: { type: 'integer', default: 0 }, + language: { type: 'string', default: 'javascript' }, lines: { type: 'node_array', node_types: ['line'], diff --git a/src/lib/server/migrations.js b/src/lib/server/migrations.js index 3e37fee1..333fc0f0 100644 --- a/src/lib/server/migrations.js +++ b/src/lib/server/migrations.js @@ -219,6 +219,7 @@ export default [ id: 'codeblock_sample_1', type: 'codeblock', colorset: 0, + language: 'javascript', lines: [ 'codeblock_sample_line_1', 'codeblock_sample_line_2', @@ -239,7 +240,7 @@ export default [ row.document_id ); }, - function convert_codeblock_text_children_to_lines({ db }) { + function add_codeblock_defaults({ db }) { const rows = db.prepare('SELECT document_id, data FROM documents').all(); const update_doc = db.prepare('UPDATE documents SET data = ? WHERE document_id = ?'); @@ -250,6 +251,11 @@ export default [ for (const node of Object.values(doc.nodes ?? {})) { if (node?.type !== 'codeblock' || !Array.isArray(node.lines)) continue; + if (!node.language) { + node.language = 'javascript'; + did_change = true; + } + for (const line_id of node.lines) { const line_node = doc.nodes?.[line_id]; if (line_node?.type !== 'text') continue; diff --git a/src/routes/components/Codeblock.svelte b/src/routes/components/Codeblock.svelte index 54f512dc..6103aa77 100644 --- a/src/routes/components/Codeblock.svelte +++ b/src/routes/components/Codeblock.svelte @@ -1,11 +1,18 @@ @@ -13,7 +20,10 @@
-
+