Compare commits

...
Author SHA1 Message Date
github-actions[bot] eb65425220 chore: bump version to 0.35.0 2026-09-18 06:15:07 +00:00
Claude 011113b7e1 fix(e2e): keep preview server keep-alive sockets open longer than clients
CI on main (run 310, commit 58e5b5d) failed one public-sharing scenario
with "apiRequestContext.post: socket hang up" on its first request, with
no server-side error. The same tree had passed E2E on the PR run minutes
earlier, and the failing call is the first request of the test.

That is the keep-alive race: Playwright's request context reuses idle
sockets to `vite preview`, whose Node http server closes an idle socket
after 5s. A request that lands on a socket the server is closing at that
instant dies client-side. Raising the preview server's keepAliveTimeout
to 65s (headersTimeout 66s, which Node requires to be larger) makes the
client always the side that closes, so the race cannot occur. A tiny Vite
plugin does this only for `vite preview`; production runs `node build`
and is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-18 08:12:40 +02:00
Claude 58e5b5dd66 feat(editor): make code snippets readable and syntax highlighted
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-17 21:42:35 +02:00
16 changed files with 724 additions and 9 deletions
+2
View File
@@ -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`.
+3
View File
@@ -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
+1 -1
View File
@@ -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)
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.34.2",
"version": "0.35.0",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
@@ -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",
+63
View File
@@ -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: {}
+116 -2
View File
@@ -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 <pre> 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"] *)) {
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { CODE_LANGUAGES } from '$lib/utils/highlight.js';
import {
Undo2,
Redo2,
@@ -47,6 +48,18 @@
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
// The fence info string may carry an alias (```js) or a language the picker
// doesn't list; surface it as-is rather than silently showing "Plain text".
function codeBlockLanguage(): string {
const lang = getAttrs('codeBlock').language;
return typeof lang === 'string' ? lang : '';
}
function setCodeBlockLanguage(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
editor?.chain().focus().updateAttributes('codeBlock', { language: value || null }).run();
}
let openDropdown: string | null = $state(null);
let dropdownAnchorEl: HTMLElement | null = $state(null);
let linkUrl: string = $state('');
@@ -378,6 +391,25 @@
>
<CodeXml size={iconSize} />
</button>
{#if isActive('codeBlock')}
{@const currentLanguage = codeBlockLanguage()}
<select
value={currentLanguage}
onchange={setCodeBlockLanguage}
class="shrink-0 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-1 text-xs text-[var(--text)] outline-none focus:border-[var(--primary)]"
title="Code block language"
aria-label="Code block language"
data-testid="format-code-language"
>
<option value="">Plain text</option>
{#if currentLanguage && !CODE_LANGUAGES.some((l) => l.id === currentLanguage)}
<option value={currentLanguage}>{currentLanguage}</option>
{/if}
{#each CODE_LANGUAGES as lang (lang.id)}
<option value={lang.id}>{lang.label}</option>
{/each}
</select>
{/if}
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
+1 -1
View File
@@ -154,7 +154,7 @@
{/if}
</ul>
{:else if note.content}
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
<div class="card-preview prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
+4 -1
View File
@@ -13,7 +13,9 @@
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { NoteLink } from './tiptap/NoteLink.js';
import { editorLowlight } from '$lib/utils/highlight.js';
import { getAllNotes } from '$lib/sync/idb.js';
interface Props {
@@ -92,7 +94,8 @@
editorInstance = new Editor({
element: element!,
extensions: [
StarterKit.configure({ link: false, underline: false }),
StarterKit.configure({ link: false, underline: false, codeBlock: false }),
CodeBlockLowlight.configure({ lowlight: editorLowlight }),
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CODE_LANGUAGES, editorLowlight, highlightCode, isKnownLanguage, lowlight } from './highlight.js';
describe('highlightCode', () => {
it('wraps tokens in hljs-* spans for a known language', () => {
const html = highlightCode('const x = 1;', 'javascript');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('accepts highlight.js aliases the way a fence info string carries them', () => {
expect(highlightCode('let a = 1', 'js')).toContain('hljs-keyword');
expect(highlightCode('print(1)', 'py')).toContain('hljs-built_in');
expect(highlightCode('<div></div>', 'html')).toContain('hljs-tag');
});
it('returns null for an absent or unknown language so the caller falls back to plain text', () => {
expect(highlightCode('SELECT 1', null)).toBeNull();
expect(highlightCode('SELECT 1', '')).toBeNull();
expect(highlightCode('SELECT 1', 'not-a-language')).toBeNull();
});
it('escapes HTML in the source so a snippet cannot inject markup', () => {
const html = highlightCode('const s = "<img src=x onerror=alert(1)>";', 'javascript');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
});
it('keeps highlight.js sub-scope classes alongside the hljs-* class', () => {
const html = highlightCode('function foo() {}', 'javascript');
expect(html).toContain('<span class="hljs-title function_">foo</span>');
});
it('round-trips the source text exactly once tags are stripped', () => {
const source = 'if (a < b && c > d) {\n\treturn "x&y";\n}';
const html = highlightCode(source, 'typescript')!;
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(text).toBe(source);
});
});
describe('CODE_LANGUAGES', () => {
it('lists only languages the shared lowlight instance can actually highlight', () => {
for (const { id } of CODE_LANGUAGES) {
expect(isKnownLanguage(id), id).toBe(true);
}
});
it('has no duplicate ids', () => {
const ids = CODE_LANGUAGES.map((l) => l.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('editorLowlight', () => {
it('never auto-detects: an unlabelled block comes back as a single plain text node', () => {
const result = editorLowlight.highlightAuto('SELECT * FROM users;');
expect(result.children).toEqual([{ type: 'text', value: 'SELECT * FROM users;' }]);
});
it('highlights a labelled block with the same grammars as the renderer', () => {
const viaEditor = editorLowlight.highlight('python', 'def f(): pass');
const viaRenderer = lowlight.highlight('python', 'def f(): pass');
expect(viaEditor.children).toEqual(viaRenderer.children);
});
it('reports the languages the picker offers as registered', () => {
for (const { id } of CODE_LANGUAGES) {
expect(editorLowlight.registered(id), id).toBe(true);
}
expect(editorLowlight.listLanguages()).toContain('javascript');
});
});
// The palette lives in CSS, so the contrast guarantees are checked against the
// stylesheet itself rather than a TypeScript mirror that could drift.
describe('code palette in src/app.css', () => {
const css = readFileSync(resolve(__dirname, '../../app.css'), 'utf8');
const light = readVars(css.slice(css.indexOf(':root {'), css.indexOf('[data-theme="dark"] {')));
const dark = readVars(css.slice(css.indexOf('[data-theme="dark"] {')));
const tokenNames = ['--hl-keyword', '--hl-string', '--hl-number', '--hl-comment', '--hl-title', '--hl-tag'];
it('defines the code surface and every token colour in both themes', () => {
for (const name of ['--code-bg', ...tokenNames]) {
expect(light[name], `${name} light`).toMatch(/^#[0-9a-f]{6}$/i);
expect(dark[name], `${name} dark`).toMatch(/^#[0-9a-f]{6}$/i);
}
});
it('keeps AA contrast (4.5:1) between every token and the code surface', () => {
for (const [theme, vars] of [
['light', light],
['dark', dark]
] as const) {
for (const name of tokenNames) {
const ratio = contrastRatio(vars[name], vars['--code-bg']);
expect(ratio, `${theme} ${name} ${vars[name]} on ${vars['--code-bg']}`).toBeGreaterThanOrEqual(4.5);
}
}
});
it('keeps AA contrast between body text and the code surface', () => {
expect(contrastRatio(light['--text'], light['--code-bg'])).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(dark['--text'], dark['--code-bg'])).toBeGreaterThanOrEqual(4.5);
});
it('keeps token colours distinguishable from each other', () => {
for (const vars of [light, dark]) {
for (let i = 0; i < tokenNames.length; i++) {
for (let j = i + 1; j < tokenNames.length; j++) {
const distance = rgbDistance(vars[tokenNames[i]], vars[tokenNames[j]]);
expect(distance, `${tokenNames[i]} vs ${tokenNames[j]}`).toBeGreaterThanOrEqual(40);
}
}
}
});
});
function readVars(block: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const match of block.matchAll(/(--[\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) {
vars[match[1]] = match[2].toLowerCase();
}
return vars;
}
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
+108
View File
@@ -0,0 +1,108 @@
import { common, createLowlight } from 'lowlight';
/**
* One lowlight instance shared by the TipTap editor (live decorations) and the
* markdown-it renderer (card previews, share page), so a fence is coloured
* identically wherever it shows up.
*
* There is deliberately no language auto-detection. highlight.js guesses
* wrongly on note-sized snippets often enough (JavaScript read as CSS, SQL as
* VB.NET) that a wrong palette is worse than none. An unlabelled fence renders
* as plain text on the code surface; the language comes from the fence info
* string (```ts) or the toolbar picker.
*/
export const lowlight = createLowlight(common);
type HighlightRoot = ReturnType<typeof lowlight.highlight>;
type HighlightNode = HighlightRoot['children'][number];
/** Languages offered by the editor's picker. Aliases (js, py, sh…) still highlight. */
export const CODE_LANGUAGES: readonly { id: string; label: string }[] = [
{ id: 'bash', label: 'Bash' },
{ id: 'c', label: 'C' },
{ id: 'cpp', label: 'C++' },
{ id: 'csharp', label: 'C#' },
{ id: 'css', label: 'CSS' },
{ id: 'diff', label: 'Diff' },
{ id: 'go', label: 'Go' },
{ id: 'graphql', label: 'GraphQL' },
{ id: 'xml', label: 'HTML / XML' },
{ id: 'ini', label: 'INI / TOML' },
{ id: 'java', label: 'Java' },
{ id: 'javascript', label: 'JavaScript' },
{ id: 'json', label: 'JSON' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'less', label: 'Less' },
{ id: 'lua', label: 'Lua' },
{ id: 'makefile', label: 'Makefile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'objectivec', label: 'Objective-C' },
{ id: 'perl', label: 'Perl' },
{ id: 'php', label: 'PHP' },
{ id: 'python', label: 'Python' },
{ id: 'r', label: 'R' },
{ id: 'ruby', label: 'Ruby' },
{ id: 'rust', label: 'Rust' },
{ id: 'scss', label: 'SCSS' },
{ id: 'shell', label: 'Shell session' },
{ id: 'sql', label: 'SQL' },
{ id: 'swift', label: 'Swift' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'vbnet', label: 'VB.NET' },
{ id: 'wasm', label: 'WebAssembly' },
{ id: 'yaml', label: 'YAML' }
];
export function isKnownLanguage(lang: string | null | undefined): lang is string {
return typeof lang === 'string' && lang.length > 0 && lowlight.registered(lang);
}
/**
* Highlighted HTML (spans with `hljs-*` classes, text escaped) for a code
* block, or `null` when the language is absent or unknown so the caller can
* fall back to plain escaped text.
*/
export function highlightCode(code: string, lang: string | null | undefined): string | null {
if (!isKnownLanguage(lang)) return null;
return toHtml(lowlight.highlight(lang, code).children);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// lowlight emits only `element` (span with a className list) and `text`
// nodes, so a full hast serializer would be dead weight here.
function toHtml(nodes: HighlightNode[]): string {
let html = '';
for (const node of nodes) {
if (node.type === 'text') {
html += escapeHtml(node.value);
} else if (node.type === 'element') {
const classes = node.properties?.className;
const classAttr = Array.isArray(classes) && classes.length ? ` class="${classes.join(' ')}"` : '';
html += `<span${classAttr}>${toHtml(node.children as HighlightNode[])}</span>`;
}
}
return html;
}
/**
* What `@tiptap/extension-code-block-lowlight` needs from a lowlight instance,
* with auto-detection stubbed out so an unlabelled block stays plain (see the
* note at the top of this file).
*/
export const editorLowlight = {
highlight: (language: string, value: string) => lowlight.highlight(language, value),
highlightAuto: (value: string): HighlightRoot => ({
type: 'root',
children: [{ type: 'text', value }],
data: { language: undefined, relevance: 0 }
}),
listLanguages: () => lowlight.listLanguages(),
registered: (aliasOrLanguage: string) => lowlight.registered(aliasOrLanguage)
};
+34
View File
@@ -35,6 +35,40 @@ describe('renderMarkdown', () => {
expect(html).toContain('const x = 1;');
});
it('should syntax-highlight a fenced block that names its language', () => {
const html = renderMarkdown('```ts\nconst x: number = 1;\n```');
expect(html).toContain('<code class="language-ts">');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('should leave an unlabelled fence as plain text rather than guessing a language', () => {
const html = renderMarkdown('```\nSELECT * FROM users;\n```');
expect(html).toContain('<pre><code>SELECT * FROM users;');
expect(html).not.toContain('hljs-');
});
it('should leave a fence with an unknown language as plain text but keep its label', () => {
const html = renderMarkdown('```brainfuck\n+++\n```');
expect(html).toContain('<code class="language-brainfuck">+++');
expect(html).not.toContain('hljs-');
});
it('should keep code in a highlighted fence escaped', () => {
const html = renderMarkdown('```html\n<script>alert(1)</script>\n```');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;<span class="hljs-name">script</span>&gt;');
});
it('should strip classes other than highlighting classes from raw HTML spans and code', () => {
const html = renderMarkdown(
'<p><span class="hljs-keyword">kept</span> <span class="evil">dropped</span> <code class="language-js other">c</code></p>'
);
expect(html).toContain('<span class="hljs-keyword">kept</span>');
expect(html).toContain('<span>dropped</span>');
expect(html).toContain('<code class="language-js">c</code>');
});
it('should render inline code', () => {
const html = renderMarkdown('Use `npm install`');
expect(html).toContain('<code>npm install</code>');
+12 -1
View File
@@ -1,11 +1,15 @@
import MarkdownIt from 'markdown-it';
import sanitizeHtml from 'sanitize-html';
import { highlightCode } from './highlight.js';
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true
breaks: true,
// Returning '' makes markdown-it escape and wrap the block itself, so an
// unlabelled or unknown-language fence still renders as plain <pre><code>.
highlight: (code, lang) => highlightCode(code, lang) ?? ''
});
// Raw HTML has to render (not just be escaped as visible text): a table with
@@ -21,6 +25,13 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
input: ['type', 'checked', 'disabled', 'class'],
ul: ['class']
},
// Syntax highlighting is carried by classes: `language-*` on the <code>
// of a fence and `hljs-*` (plus highlight.js sub-scopes like `function_`)
// on the token spans. Nothing else may put a class on these tags.
allowedClasses: {
code: [/^language-[\w+#.-]+$/],
span: [/^hljs-[\w-]+$/, /^language-[\w+#.-]+$/, /^[a-z]+_{1,2}$/]
},
// Keep markdown-it's bare void-element style (<br>, not <br />) so
// sanitizing doesn't change output for content that had no raw HTML to
// begin with.
+164
View File
@@ -0,0 +1,164 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
async function waitForTiptapEditor(page: Page) {
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="tiptap-editor"]');
return el && (el as any).__tiptapEditor != null;
});
}
async function runTiptapCommand(page: Page, commandFn: string) {
await page.getByTestId('tiptap-editor').evaluate(
(el, fn) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
new Function('editor', fn)(editor);
},
commandFn
);
}
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
}
/** Write note content as markdown, then return to the rich editor. */
async function typeViaMarkdown(page: Page, content: string) {
await toggleMarkdownMode(page);
await page.getByTestId('note-content-input').fill(content);
await toggleMarkdownMode(page);
}
test.describe('Code snippets', () => {
test('Scenario: A fenced block with a language is syntax highlighted in the editor and on the card', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a JavaScript code block
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('JS Snippet');
await typeViaMarkdown(page, '```js\nconst answer = 42;\n```');
await waitForTiptapEditor(page);
// Then the editor colours the keyword and number as separate tokens
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre .hljs-keyword')).toHaveText('const');
await expect(editor.locator('pre .hljs-number')).toHaveText('42');
// When the note is closed
await page.getByTestId('close-editor-btn').click();
// Then the card preview shows the same highlighting
const preview = noteCard(page, 'JS Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre .hljs-keyword')).toHaveText('const');
await expect(preview.locator('pre .hljs-number')).toHaveText('42');
});
test('Scenario: An unlabelled code block stays plain instead of guessing a language', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a code block with no language
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Plain Snippet');
await typeViaMarkdown(page, '```\nSELECT * FROM users;\n```');
await waitForTiptapEditor(page);
// Then the editor shows the block without any token colouring
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(editor.locator('pre [class*="hljs-"]')).toHaveCount(0);
// And so does the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Plain Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(preview.locator('pre [class*="hljs-"]')).toHaveCount(0);
});
test('Scenario: Choosing a language for a code block is saved in the markdown fence', async ({
authenticatedPage: page
}) => {
// Given the user is editing a note with a plain code block
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, '```\ndef greet():\n return "hi"\n```');
await waitForTiptapEditor(page);
// And the language picker is offered only while the cursor is in the block
await runTiptapCommand(page, 'editor.chain().focus("start").run()');
const picker = page.getByTestId('format-code-language');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue('');
// When the user picks Python
await picker.selectOption('python');
// Then the block is highlighted as Python
await expect(page.getByTestId('tiptap-editor').locator('pre .hljs-keyword').first()).toHaveText('def');
await expect(picker).toHaveValue('python');
// And the markdown fence carries the language
await toggleMarkdownMode(page);
await expect(page.getByTestId('note-content-input')).toHaveValue('```python\ndef greet():\n return "hi"\n```');
});
test('Scenario: The language picker is hidden outside a code block', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with a paragraph
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'just prose');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus("end").run()');
// Then no language picker is shown
await expect(page.getByTestId('format-code-language')).toHaveCount(0);
});
test('Scenario: Code keeps the body text size instead of shrinking', async ({ authenticatedPage: page }) => {
// Given a note with a code block and a paragraph
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sized Snippet');
await typeViaMarkdown(page, 'Some prose\n\n```js\nconst a = 1;\n```');
await waitForTiptapEditor(page);
// Then the code block is set at the same size as the prose around it
const editor = page.getByTestId('tiptap-editor');
const fontSize = (locator: ReturnType<Page['locator']>) =>
locator.evaluate((el) => getComputedStyle(el).fontSize);
expect(await fontSize(editor.locator('pre code'))).toBe(await fontSize(editor.locator('p').first()));
// And the same holds on the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Sized Snippet').getByTestId('note-content-preview');
expect(await fontSize(preview.locator('pre code'))).toBe(await fontSize(preview.locator('p').first()));
});
test('Scenario: A long code line is clipped on the card instead of adding a scrollbar', async ({
authenticatedPage: page
}) => {
// Given a note with a code line far wider than a card
const longLine = 'const url = "https://example.com/' + 'segment/'.repeat(30) + '";';
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Wide Snippet');
await typeViaMarkdown(page, '```js\n' + longLine + '\n```');
await page.getByTestId('close-editor-btn').click();
// Then the card's code block stays inside the card and does not scroll horizontally
const preview = noteCard(page, 'Wide Snippet').getByTestId('note-content-preview');
const pre = preview.locator('pre');
await expect(pre).toBeVisible();
const overflow = await pre.evaluate((el) => {
const style = getComputedStyle(el);
const box = el.getBoundingClientRect();
const container = el.parentElement!.getBoundingClientRect();
return {
x: style.overflowX,
wider: el.scrollWidth > el.clientWidth,
spill: box.right - container.right
};
});
expect(overflow.wider, 'the line must actually be wider than the card for this test to mean anything').toBe(true);
expect(overflow.x).toBe('hidden');
expect(overflow.spill).toBeLessThanOrEqual(1);
});
});
+24 -1
View File
@@ -2,13 +2,36 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import istanbul from 'vite-plugin-istanbul';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
/**
* The e2e suite drives `vite preview` and its API helpers reuse keep-alive
* sockets. Node's http server drops an idle keep-alive socket after 5s by
* default, and a request that lands on a socket the server is closing at that
* instant fails client-side with "socket hang up" (seen on main CI in
* public-sharing.spec). Keeping the server's idle timeout well above any
* client's means the client always closes first, so the race cannot happen.
*/
function previewKeepAlive(): Plugin {
return {
name: 'crumbs:preview-keep-alive',
configurePreviewServer({ httpServer }) {
// HTTP/2 servers have no keep-alive timeout; preview is HTTP/1 here.
if ('keepAliveTimeout' in httpServer) {
httpServer.keepAliveTimeout = 65_000;
// Node requires headersTimeout to exceed keepAliveTimeout.
httpServer.headersTimeout = 66_000;
}
}
};
}
export default defineConfig({
build: {
chunkSizeWarningLimit: 850
},
plugins: [
previewKeepAlive(),
tailwindcss(),
sveltekit(),
...(process.env.VITE_COVERAGE === 'true'
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.34.2</span>
<span class="footer-version">v0.35.0</span>
</div>
</footer>