mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf0c0285b | ||
|
|
faf926c081 | ||
|
|
31732e8144 | ||
|
|
d71dbe0f4a | ||
|
|
effe7e3e72 | ||
|
|
6f4938180a | ||
|
|
eb65425220 | ||
|
|
011113b7e1 | ||
|
|
58e5b5dd66 | ||
|
|
7bf06edd97 | ||
|
|
af7186675e |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+20
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Generated
+63
@@ -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
@@ -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())}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'] }),
|
||||
|
||||
@@ -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('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/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);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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)
|
||||
};
|
||||
@@ -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('<<span class="hljs-name">script</span>>');
|
||||
});
|
||||
|
||||
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>');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user