From 58e5b5dd6646204cf84427ed9e97b121c11dd90e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:04:03 +0000 Subject: [PATCH] feat(editor): make code snippets readable and syntax highlighted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #90. Code blocks and inline code inherited the typography plugin's defaults, which shrank them to ~86% of an already-monospace body and painted them --bg-surface — the same colour as the default note card — so a snippet was only recognisable by being smaller. Long lines also grew a horizontal scrollbar inside card previews. - New --code-bg surface (one step below the page in light mode, page-level ink in dark) with a subtle border, and code keeps the surrounding text size. Decorative backtick pseudo-elements dropped now that the chip carries the signal. - Card previews clip wide code instead of scrolling; editor and share page keep horizontal scroll. - Syntax highlighting via one shared lowlight instance feeding both the TipTap editor (extension-code-block-lowlight) and the markdown-it renderer, so a fence looks identical in the editor, on cards and on the share page. Only highlight.js "common" grammars are bundled. - No auto-detection: highlight.js misreads note-sized snippets too often (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the language comes from the fence info string or a toolbar picker shown while the cursor is in a code block. - Six --hl-* token colours per theme; a unit test parses app.css and asserts AA contrast against --code-bg and mutual distinguishability. - Sanitizer now allows only language-* / hljs-* classes on code and span. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx --- CLAUDE.md | 2 + docs/ARCHITECTURE.md | 3 + docs/FEATURES.md | 2 +- package.json | 2 + pnpm-lock.yaml | 63 ++++++++ src/app.css | 118 +++++++++++++- src/lib/components/FormattingToolbar.svelte | 32 ++++ src/lib/components/NoteCard.svelte | 2 +- src/lib/components/TiptapEditor.svelte | 5 +- src/lib/utils/highlight.test.ts | 156 +++++++++++++++++++ src/lib/utils/highlight.ts | 108 +++++++++++++ src/lib/utils/markdown.test.ts | 34 ++++ src/lib/utils/markdown.ts | 13 +- tests/e2e/code-blocks.spec.ts | 164 ++++++++++++++++++++ 14 files changed, 698 insertions(+), 6 deletions(-) create mode 100644 src/lib/utils/highlight.test.ts create mode 100644 src/lib/utils/highlight.ts create mode 100644 tests/e2e/code-blocks.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1f09a8f..085aeb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,8 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo | `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold | | `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout | | `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity | +| `--code-bg` | `#dfd5c1` (light) / `#141312` (dark) | Code block and inline code surface — one step below the page so it stands out on every note colour, including the default card which is `--bg-surface` itself | +| `--hl-keyword` … `--hl-tag` | six token colours per theme | Syntax highlighting roles (keyword, string, number, comment, title, tag). Must keep AA contrast against `--code-bg`; asserted in `src/lib/utils/highlight.test.ts` | Note colors for cards are defined in `src/lib/utils/colors.ts`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 244d53a..1e8d1d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,6 +82,9 @@ For a single-user app with multiple devices: - Built-in dark mode support (`dark:` variants) - New v4 uses Vite plugin (faster builds) +### lowlight (highlight.js) +Code blocks are highlighted by one shared lowlight instance (`src/lib/utils/highlight.ts`) that feeds both the TipTap editor (`@tiptap/extension-code-block-lowlight`, live decorations) and the markdown-it renderer used for card previews and the share page, so a fence looks the same everywhere. Only the highlight.js "common" grammar set is bundled. Language auto-detection is deliberately disabled: it misreads note-sized snippets too often, so an unlabelled fence renders as plain text and the language comes from the fence info string or the toolbar picker. Token colours are the six `--hl-*` variables in `src/app.css`; a unit test asserts AA contrast against `--code-bg` in both themes. + ### Argon2 - Memory-hard password hashing (resists GPU attacks) - See [AUTH.md](AUTH.md) for authentication details diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 90eb342..8bec960 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -23,7 +23,7 @@ - Blockquotes (`> quote`) - Unordered lists (`- item`) - Ordered lists (`1. item`) -- Code blocks (triple backtick) +- Code blocks (triple backtick) with syntax highlighting for 30+ languages — set the language in the fence (```ts) or from the toolbar picker while the cursor is inside the block; unlabelled blocks stay plain rather than guessing - Inline code (single backtick) - Links — insert, edit, and remove with URL input - Tables (insert, add/delete rows and columns) diff --git a/package.json b/package.json index 0cac5fb..ec3a8bb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@sveltejs/adapter-node": "^5.5.7", "@tiptap/core": "^3.31.0", + "@tiptap/extension-code-block-lowlight": "^3.31.0", "@tiptap/extension-link": "^3.31.0", "@tiptap/extension-placeholder": "^3.31.0", "@tiptap/extension-table": "^3.31.0", @@ -54,6 +55,7 @@ "better-sqlite3": "^11.10.0", "drizzle-orm": "^0.45.2", "idb": "^8.0.3", + "lowlight": "^3.3.0", "lucide-svelte": "^0.577.0", "markdown-it": "^15.0.1", "nanoid": "^5.1.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21104a2..999e95e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@tiptap/core': specifier: ^3.31.0 version: 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/extension-code-block-lowlight': + specifier: ^3.31.0 + version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(highlight.js@11.11.2)(lowlight@3.3.0) '@tiptap/extension-link': specifier: ^3.31.0 version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) @@ -78,6 +81,9 @@ importers: idb: specifier: ^8.0.3 version: 8.0.3 + lowlight: + specifier: ^3.3.0 + version: 3.3.0 lucide-svelte: specifier: ^0.577.0 version: 0.577.0(svelte@5.57.0) @@ -1563,6 +1569,15 @@ packages: peerDependencies: '@tiptap/extension-list': 3.31.0 + '@tiptap/extension-code-block-lowlight@3.31.0': + resolution: {integrity: sha512-fFwXqwh0CwJkQzdqgv4ObcS70m0Muq1RbAZUTQNZFqCDcu1krDJW0PWNmua7+nZdmXwOyA2PDUTAtWYFQ+srpw==} + peerDependencies: + '@tiptap/core': 3.31.0 + '@tiptap/extension-code-block': 3.31.0 + '@tiptap/pm': 3.31.0 + highlight.js: ^11 + lowlight: ^2 || ^3 + '@tiptap/extension-code-block@3.31.0': resolution: {integrity: sha512-y/A6N96gBPjEHqVYZ3u/yO/Anyo92iDWgYqShMXAvHprn2Ipmn32wIkX05Kv983fa6j6ySXF7A1nJdHcGCl5aQ==} peerDependencies: @@ -1731,6 +1746,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/linkify-it@3.0.5': resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} @@ -1764,6 +1782,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/zxcvbn@4.4.5': resolution: {integrity: sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==} @@ -2183,6 +2204,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2190,6 +2215,9 @@ packages: devalue@5.9.2: resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2664,6 +2692,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + highlight.js@11.11.2: + resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==} + engines: {node: '>=12.0.0'} + hono@4.13.5: resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} @@ -3073,6 +3105,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -5560,6 +5595,14 @@ snapshots: dependencies: '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-code-block-lowlight@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(highlight.js@11.11.2)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/extension-code-block': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/pm': 3.31.0 + highlight.js: 11.11.2 + lowlight: 3.3.0 + '@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': dependencies: '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) @@ -5739,6 +5782,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/linkify-it@3.0.5': {} '@types/linkify-it@5.0.0': {} @@ -5773,6 +5820,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/unist@3.0.3': {} + '@types/zxcvbn@4.4.5': {} '@vite-pwa/sveltekit@1.1.0(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.57.0)(vite@7.3.6(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)))(svelte@5.57.0)(typescript@5.9.3)(vite@7.3.6(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@7.3.6(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))(workbox-build@7.4.0)(workbox-window@7.4.1)': @@ -6213,10 +6262,16 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} devalue@5.9.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -6726,6 +6781,8 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@11.11.2: {} + hono@4.13.5: {} html-encoding-sniffer@6.0.0: @@ -7129,6 +7186,12 @@ snapshots: loupe@3.2.1: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + highlight.js: 11.11.2 + lru-cache@10.4.3: {} lru-cache@11.5.2: {} diff --git a/src/app.css b/src/app.css index 034c861..b4f4fbb 100644 --- a/src/app.css +++ b/src/app.css @@ -26,6 +26,17 @@ light styling is byte-identical; dark mode keeps the cream that --border used to supply, because a mid-grey wash is invisible on a dark surface. */ --hover-wash: var(--border); + /* Code surface: an inset one step below the page so it reads as a panel on + every note colour (the default card is --bg-surface itself, so neither + surface nor base would stand out on it). Token colours must keep AA + contrast against it — see src/lib/utils/highlight.test.ts. */ + --code-bg: #dfd5c1; + --hl-keyword: #7d5306; + --hl-string: #325d28; + --hl-number: #1f5c7a; + --hl-comment: #5d5462; + --hl-title: #6a2c7a; + --hl-tag: #8b2e1f; } [data-theme="dark"] { @@ -49,6 +60,15 @@ /* Dimmed so a long done-list of gold checkboxes doesn't shout. */ --accent-check: #c8a44a; --hover-wash: #ece3d3; + /* Dark code sinks to page level (the ink behind the cards) rather than + lifting, so a snippet on a dark card reads as a cut-out, not a slab. */ + --code-bg: #141312; + --hl-keyword: #e0a030; + --hl-string: #8fc97a; + --hl-number: #7fb8e0; + --hl-comment: #a89b8c; + --hl-title: #cfa3e6; + --hl-tag: #e2705c; } html, body { @@ -108,15 +128,109 @@ body::before { --tw-prose-kbd-shadows: color-mix(in oklab, var(--text) 10%, transparent); --tw-prose-code: var(--text); --tw-prose-pre-code: var(--text); - --tw-prose-pre-bg: var(--bg-surface); + --tw-prose-pre-bg: var(--code-bg); --tw-prose-th-borders: var(--border-subtle); --tw-prose-td-borders: var(--border-subtle); } +/* Code — the body font is already monospace, so the typography plugin's + "shrink code to 86%" default only makes snippets harder to read. Code keeps + the surrounding text size and gets a distinct surface instead. */ .prose :where(code):not(:where([class~="not-prose"] *)) { - background-color: var(--bg-surface); + background-color: var(--code-bg); border-radius: 2px; padding: 0.125rem 0.25rem; + font-size: inherit; +} + +/* The chip is the signal now; the plugin's decorative backticks doubled it. */ +.prose :where(code):not(:where([class~="not-prose"] *))::before, +.prose :where(code):not(:where([class~="not-prose"] *))::after { + content: none; +} + +.prose :where(pre):not(:where([class~="not-prose"] *)) { + border: 1px solid var(--border-subtle); + border-radius: 2px; + font-size: inherit; + line-height: 1.6; + padding: 0.625rem 0.75rem; + tab-size: 4; +} + +.prose :where(pre code):not(:where([class~="not-prose"] *)) { + background-color: transparent; + border-radius: 0; + padding: 0; +} + +/* Card previews are a glance, not a workspace: clip long lines instead of + growing a scrollbar inside the card. The editor and share page keep the + typography default of scrolling horizontally. */ +.card-preview :where(pre) { + overflow: hidden; + /* line-clamp is a legacy -webkit-box; without this a wide
 takes its
+	   content width and runs out past the card edge instead of being clipped. */
+	max-width: 100%;
+}
+
+/* Syntax highlighting (highlight.js classes via lowlight). Six roles are
+   enough to tell structure apart without turning a snippet into confetti;
+   the colours live in --hl-* above so the dark theme can swap them. */
+.hljs-keyword,
+.hljs-selector-tag,
+.hljs-built_in,
+.hljs-type,
+.hljs-literal {
+	color: var(--hl-keyword);
+}
+
+.hljs-string,
+.hljs-regexp,
+.hljs-quote,
+.hljs-addition {
+	color: var(--hl-string);
+}
+
+.hljs-number,
+.hljs-symbol,
+.hljs-bullet,
+.hljs-link,
+.hljs-attr,
+.hljs-attribute,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-selector-attr,
+.hljs-selector-pseudo {
+	color: var(--hl-number);
+}
+
+.hljs-comment,
+.hljs-meta,
+.hljs-doctag {
+	color: var(--hl-comment);
+	font-style: italic;
+}
+
+.hljs-title,
+.hljs-section,
+.hljs-selector-id,
+.hljs-selector-class {
+	color: var(--hl-title);
+}
+
+.hljs-tag,
+.hljs-name,
+.hljs-deletion {
+	color: var(--hl-tag);
+}
+
+.hljs-emphasis {
+	font-style: italic;
+}
+
+.hljs-strong {
+	font-weight: 600;
 }
 
 .prose :where(a):not(:where([class~="not-prose"] *)) {
diff --git a/src/lib/components/FormattingToolbar.svelte b/src/lib/components/FormattingToolbar.svelte
index 60a2bcf..c35bfca 100644
--- a/src/lib/components/FormattingToolbar.svelte
+++ b/src/lib/components/FormattingToolbar.svelte
@@ -1,5 +1,6 @@
 \n```');
+		expect(html).not.toContain('