Compare commits

...
11 Commits
Author SHA1 Message Date
github-actions[bot] 9cf0c0285b chore: bump version to 0.35.2 2026-10-11 07:49:40 +00:00
Claude faf926c081 fix(editor): open existing notes in the user's default note mode
Existing notes initialised raw markdown mode from note.checklistMode
instead of the defaultNoteMode preference, so users with Markdown as
default always got the rich text editor when reopening a note.

Fixes #96

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017neWQAAxi6nZYmwRkDThsy
2026-10-11 09:46:43 +02:00
github-actions[bot] 31732e8144 chore: pin docker-compose to v0.35.1 [skip ci] 2026-10-06 07:11:38 +00:00
github-actions[bot] d71dbe0f4a chore: bump version to 0.35.1 2026-10-06 07:05:30 +00:00
Claude effe7e3e72 fix(docker): make bind-mounted data folders writable on startup
The image switched to the node user (uid 1000) at build time, so a host
folder mounted at /data kept its host ownership and SQLite failed with
SQLITE_CANTOPEN in a restart loop. Named and anonymous volumes worked only
because Docker copies the image's /data ownership into them.

The container now starts as root, hands DATA_DIR and the database folder
to node (only entries not already owned by it), and execs the app via
setpriv as node. Containers started with an explicit `user:` skip this.

Adds a CI step that runs the image with a host-owned bind mount.

Fixes #93

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MHECwBhBpM248EFSBi4L5
2026-10-06 08:43:59 +02:00
github-actions[bot] 6f4938180a chore: pin docker-compose to v0.35.0 [skip ci] 2026-09-18 06:22:43 +00:00
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
Frederic CabassutandClaude Fable 5.1 7bf06edd97 perf(e2e): log in once and share the admin session via storage state
Every test in the `app` project submitted the login form through the
authenticatedPage fixture: a page load, an Argon2 verification on the
server, a redirect and a networkidle wait. Over 137 tests that was
roughly half of the median test's 1.8s.

A new `login-state` setup project (tests/e2e/auth.setup.ts) authenticates
once after auth.spec has created the account and saves the session cookie
to data/test-storage-state.json. The `app` project loads it into every
context, so setupAndLogin takes its "session still valid" path and the
fixture is reduced to a page load.

pwa.spec and mcp.spec describe anonymous visitors and opt out with an
empty storageState so their baseline is unchanged.

Local run, 4 workers: summed test time 276s -> 256s, median 1.8s -> 1.6s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:29:46 +02:00
github-actions[bot] af7186675e chore: pin docker-compose to v0.34.2 [skip ci] 2026-09-07 17:43:47 +00:00
28 changed files with 837 additions and 16 deletions
+16
View File
@@ -125,5 +125,21 @@ jobs:
push: false
provenance: false
tags: crumbs:ci
load: true
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
# Regression for #93: a host folder bind-mounted at /data is owned by the
# host user (the runner, uid 1001), not the container's node user (1000)
- name: Start with a host-owned bind mount
run: |
mkdir -p "$RUNNER_TEMP/crumbs-data"
docker run -d --name crumbs -p 3000:3000 -v "$RUNNER_TEMP/crumbs-data:/data" crumbs:ci
for i in $(seq 1 30); do
if curl -fsS -o /dev/null http://localhost:3000/login; then
test -f "$RUNNER_TEMP/crumbs-data/crumbs.db" && exit 0
fi
sleep 1
done
docker logs crumbs
exit 1
+3
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`.
@@ -219,6 +221,7 @@ Write tests first or alongside features. Tests serve as living documentation of
- Config: `playwright.config.ts` (Chromium only, builds + previews app on port 4173)
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
- **Shared login state**: `tests/e2e/auth.setup.ts` (project `login-state`) logs in once and saves the admin session cookie to `data/test-storage-state.json`; the `app` project loads it into every context so tests start authenticated instead of submitting the login form each time. A spec that needs an anonymous baseline opts out with `test.use({ storageState: { cookies: [], origins: [] } })` (see `pwa.spec.ts`, `mcp.spec.ts`). Never write a test that revokes or logs out the admin's own session — it would break every test in other workers
- Auth tests use `test.describe.serial` because they depend on sequential database state
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
+4 -2
View File
@@ -37,8 +37,10 @@ COPY --from=build /app/package.json ./
# Create data directory
RUN mkdir -p /data && chown -R node:node /data
# Use non-root user
USER node
# Runs as root only long enough to hand the data folder (often a host bind
# mount) to the node user, then drops privileges — see crumbs-entrypoint.sh
COPY crumbs-entrypoint.sh /usr/local/bin/crumbs-entrypoint.sh
ENTRYPOINT ["crumbs-entrypoint.sh"]
# Configuration
ENV NODE_ENV=production
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Starts as root so a bind-mounted data folder owned by the host user can be
# handed to the unprivileged `node` user, then drops privileges before running
# the app. Without this, a host folder mounted at /data is not writable by
# uid 1000 and SQLite fails with SQLITE_CANTOPEN.
set -e
if [ "$(id -u)" = "0" ]; then
for dir in "${DATA_DIR:-/data}" "$(dirname "${DATABASE_URL:-/data/crumbs.db}")"; do
mkdir -p "$dir"
# Only touch entries not already owned by node, so restarts on a large
# attachment folder stay fast.
find "$dir" \! -user node -exec chown node:node {} + ||
echo "warning: could not change ownership of $dir; it must be writable by uid 1000" >&2
done
exec setpriv --reuid=node --regid=node --init-groups "$@"
fi
# Started with an explicit non-root user (e.g. `user:` in compose): run as-is.
exec "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.34.1
image: ghcr.io/bretzel-app/crumbs:0.35.1
container_name: crumbs
ports:
- "3000:3000"
+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
+4
View File
@@ -26,6 +26,10 @@ services:
restart: unless-stopped
```
The app runs as the image's `node` user (uid 1000). On startup the container hands ownership of a
bind-mounted data folder to uid 1000, so a folder created by any host user works as-is. If you set
`user:` in compose, that step is skipped and the folder must already be writable by that user.
### Reverse Proxy (nginx)
```nginx
+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.2",
"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",
+13 -2
View File
@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE_FILE } from './tests/e2e/global-setup';
export default defineConfig({
testDir: './tests/e2e',
@@ -22,10 +23,20 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: 'auth.spec.ts',
// Logs in once and saves the session cookie for the `app` project.
name: 'login-state',
testMatch: 'auth.setup.ts',
dependencies: ['auth-setup'],
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: ['auth.spec.ts', 'auth.setup.ts'],
dependencies: ['login-state'],
// Every context starts with the admin already logged in. Specs that
// need an unauthenticated baseline opt out with
// `test.use({ storageState: { cookies: [], origins: [] } })`.
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_FILE }
}
],
webServer: {
+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}
+1 -1
View File
@@ -60,7 +60,7 @@
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let rawMarkdownMode = $state(prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
+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.
+18
View File
@@ -0,0 +1,18 @@
import { test as setup } from '@playwright/test';
import { setupAndLogin } from './helpers/fixtures';
import { STORAGE_STATE_FILE } from './global-setup';
/**
* Log in as the admin once and persist the session cookie. The `app` project
* loads this storage state into every browser context, so tests start already
* authenticated instead of each one submitting the login form (~0.8s per test,
* roughly half of a typical test's duration).
*
* The admin's own session is never revoked by any test: admin-users only
* resets/revokes victim accounts, and auth.spec's logout scenario runs in the
* auth-setup project with its own context before this setup executes.
*/
setup('authenticate as admin and save storage state', async ({ page }) => {
await setupAndLogin(page);
await page.context().storageState({ path: STORAGE_STATE_FILE });
});
+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);
});
});
+2
View File
@@ -2,6 +2,8 @@ import { writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
/** Admin session cookie captured by auth.setup.ts and shared by the `app` project. */
export const STORAGE_STATE_FILE = './data/test-storage-state.json';
export default function globalSetup() {
// The test database is wiped before the webServer starts (see the
+4
View File
@@ -9,6 +9,10 @@ import { setupAndLogin } from './helpers/fixtures.js';
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
*/
// Start unauthenticated: the "rejects requests without API key" scenario must
// not carry the shared admin session cookie, and the apiKey fixture logs in itself.
base.use({ storageState: { cookies: [], origins: [] } });
const test = base.extend<{ apiKey: string }>({
apiKey: async ({ page }, use) => {
await setupAndLogin(page);
+4
View File
@@ -1,5 +1,9 @@
import { test, expect } from '@playwright/test';
// These scenarios describe what an anonymous visitor receives, so drop the
// shared admin session the `app` project injects.
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('PWA', () => {
test('Scenario: Web manifest provides correct app metadata', async ({ page }) => {
// When the browser requests the web manifest
+25 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
const DEFAULT_PREFS = {
@@ -81,6 +81,30 @@ test.describe('Settings — Preferences', () => {
await expect(page.getByTestId('note-content-input')).toBeVisible();
});
test('Scenario: Default note mode applies when reopening an existing note', async ({
authenticatedPage: page
}) => {
await resetPreferences(page);
// Given a note titled "Existing Markdown Note" exists
const title = `Existing Markdown Note ${Date.now()}`;
await page.request.post('/api/notes', { data: { title, content: 'Some **bold** text' } });
// And the user's default note mode is Markdown
await gotoWithStablePrefs(page, '/settings/preferences');
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
await page.getByTestId('pref-mode-markdown').click();
await putResponse;
// When the user opens the note
await gotoWithStablePrefs(page, '/');
await noteCard(page, title).click();
// Then the note editor opens in markdown mode showing the raw source
await expect(page.getByTestId('note-content-input')).toBeVisible();
await expect(page.getByTestId('note-content-input')).toHaveValue('Some **bold** text');
});
test('Scenario: Footer toggle hides and shows footer', async ({
authenticatedPage: page
}) => {
+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.2</span>
</div>
</footer>