forked from mirror/crumbs-original
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb83ee4cc5 | ||
|
|
a0f663d9d6 | ||
|
|
9cf0c0285b | ||
|
|
faf926c081 | ||
|
|
04088819cd | ||
|
|
31732e8144 | ||
|
|
d71dbe0f4a | ||
|
|
effe7e3e72 | ||
|
|
0eb2572e89 | ||
|
|
6f4938180a | ||
|
|
eb65425220 | ||
|
|
011113b7e1 | ||
|
|
58e5b5dd66 | ||
|
|
7bf06edd97 | ||
|
|
af7186675e | ||
|
|
b0ecc6b74d | ||
|
|
521acc718c | ||
|
|
082b8632c6 | ||
|
|
6bdcc3c214 | ||
|
|
e8c30c518c | ||
|
|
84145de3b1 | ||
|
|
ea66e23178 | ||
|
|
feda983393 | ||
|
|
bf5bfdd98e | ||
|
|
12ff6d9c4d | ||
|
|
44963bf684 | ||
|
|
dfaf1c474d |
@@ -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`.
|
||||
|
||||
@@ -171,7 +173,8 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
||||
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
||||
- **Main branch**: `main` — use as base for all PRs and feature branches
|
||||
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
||||
- **Always work in a worktree**: Never edit files in the main checkout. Before touching any file, create a dedicated worktree (`EnterWorktree` or `git worktree add .worktrees/<name> -b <branch>`) on a fresh branch from `origin/main`, do all work, commits, and PR creation from there. This keeps the main checkout clean and lets several tasks run side by side without clobbering each other's uncommitted changes.
|
||||
- **Feature branches**: Use `feat/<name>` (or `fix/<name>`) branches, one per worktree
|
||||
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
|
||||
|
||||
## CI/CD
|
||||
@@ -218,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
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
|
||||
</p>
|
||||
|
||||
# Crumbs by Bretzel
|
||||
# Crumbs by Bretzel (fork)
|
||||
|
||||
Original: https://github.com/bretzel-app/crumbs
|
||||
|
||||
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
|
||||
|
||||
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.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.35.2
|
||||
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)
|
||||
|
||||
+28
-27
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.34.1",
|
||||
"version": "0.35.2",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
@@ -36,57 +36,57 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-table": "^3.29.2",
|
||||
"@tiptap/extension-table-cell": "^3.29.2",
|
||||
"@tiptap/extension-table-header": "^3.29.2",
|
||||
"@tiptap/extension-table-row": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-underline": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"@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",
|
||||
"@tiptap/extension-table-cell": "^3.31.0",
|
||||
"@tiptap/extension-table-header": "^3.31.0",
|
||||
"@tiptap/extension-table-row": "^3.31.0",
|
||||
"@tiptap/extension-task-item": "^3.31.0",
|
||||
"@tiptap/extension-task-list": "^3.31.0",
|
||||
"@tiptap/extension-text-align": "^3.31.0",
|
||||
"@tiptap/extension-underline": "^3.31.0",
|
||||
"@tiptap/pm": "^3.31.0",
|
||||
"@tiptap/starter-kit": "^3.31.0",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"argon2": "^0.45.1",
|
||||
"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": "^14.3.0",
|
||||
"markdown-it": "^15.0.1",
|
||||
"nanoid": "^5.1.16",
|
||||
"node-diff3": "^3.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"nodemailer": "^9.1.0",
|
||||
"sanitize-html": "^2.17.7",
|
||||
"svelte-dnd-action": "^0.9.77",
|
||||
"svelte-dnd-action": "^0.9.79",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.5.4",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
@@ -108,7 +108,8 @@
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
"fast-uri": ">=3.1.2"
|
||||
"fast-uri": ">=3.1.2",
|
||||
"postcss>nanoid": ">=3.3.18 <4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
+896
-795
File diff suppressed because it is too large
Load Diff
+132
-3
@@ -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 {
|
||||
@@ -88,20 +108,129 @@ body::before {
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Prose overrides for parchment theme */
|
||||
/* Prose overrides for parchment theme.
|
||||
The typography plugin gives every element type its own --tw-prose-* variable,
|
||||
each defaulting to a fixed grey. Anything not remapped here stays that grey in
|
||||
dark mode (bold text was near-black on ink — see issue #86), so map them all. */
|
||||
.prose {
|
||||
--tw-prose-body: var(--text);
|
||||
--tw-prose-headings: var(--text);
|
||||
--tw-prose-lead: var(--text-muted);
|
||||
--tw-prose-links: var(--primary);
|
||||
--tw-prose-bold: var(--text);
|
||||
--tw-prose-counters: var(--text-muted);
|
||||
--tw-prose-bullets: var(--text-muted);
|
||||
--tw-prose-hr: var(--border-subtle);
|
||||
--tw-prose-quotes: var(--text);
|
||||
--tw-prose-quote-borders: var(--border-subtle);
|
||||
--tw-prose-captions: var(--text-muted);
|
||||
--tw-prose-kbd: var(--text);
|
||||
--tw-prose-kbd-shadows: color-mix(in oklab, var(--text) 10%, transparent);
|
||||
--tw-prose-code: var(--text);
|
||||
--tw-prose-pre-bg: var(--bg-surface);
|
||||
--tw-prose-pre-code: var(--text);
|
||||
--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>');
|
||||
@@ -95,6 +129,25 @@ describe('renderMarkdown', () => {
|
||||
expect(html).toContain('disabled');
|
||||
expect(html).toContain('class="task-checkbox"');
|
||||
});
|
||||
|
||||
it('should render an empty task item as a checkbox, not literal brackets', () => {
|
||||
const html = renderMarkdown('- [ ] Train tickets\n- [ ]');
|
||||
expect(html).not.toContain('[ ]');
|
||||
expect(html.match(/type="checkbox"/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render an empty checked task item as a checked checkbox', () => {
|
||||
const html = renderMarkdown('- [x]');
|
||||
expect(html).not.toContain('[x]');
|
||||
expect(html).toContain('<input type="checkbox" checked disabled class="task-checkbox">');
|
||||
expect(html).toContain('class="task-list"');
|
||||
});
|
||||
|
||||
it('should not treat brackets glued to text as a task marker', () => {
|
||||
const html = renderMarkdown('- [ ]x');
|
||||
expect(html).not.toContain('type="checkbox"');
|
||||
expect(html).toContain('[ ]x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMarkdown', () => {
|
||||
|
||||
@@ -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.
|
||||
@@ -33,12 +44,16 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].type !== 'inline') continue;
|
||||
const content = tokens[i].content;
|
||||
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
|
||||
const checked = content.startsWith('[x] ');
|
||||
// markdown-it trims trailing whitespace from inline content, so an item
|
||||
// with no text after the marker arrives as exactly "[ ]" — match the
|
||||
// marker followed by a space or end-of-content.
|
||||
const marker = /^\[( |x)\](?: |$)/.exec(content);
|
||||
if (marker) {
|
||||
const checked = marker[1] === 'x';
|
||||
const checkbox = checked
|
||||
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
|
||||
: '<input type="checkbox" disabled class="task-checkbox" /> ';
|
||||
tokens[i].content = content.slice(4);
|
||||
tokens[i].content = content.slice(marker[0].length);
|
||||
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
|
||||
// Prepend checkbox
|
||||
const token = new state.Token('html_inline', '', 0);
|
||||
@@ -48,7 +63,7 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
// Add task-list class to parent <ul> (once)
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (tokens[j].type === 'bullet_list_open') {
|
||||
const cls = tokens[j].attrGet('class') ?? '';
|
||||
const cls = String(tokens[j].attrGet('class') ?? '');
|
||||
if (!cls.includes('task-list')) {
|
||||
tokens[j].attrJoin('class', 'task-list');
|
||||
}
|
||||
@@ -66,7 +81,7 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
|
||||
const children = token.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type !== 'link_open') continue;
|
||||
const href = children[i].attrGet('href') ?? '';
|
||||
const href = String(children[i].attrGet('href') ?? '');
|
||||
if (!href.startsWith('crumb-note://')) continue;
|
||||
|
||||
let closeIndex = i + 1;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, createNote } from './helpers/fixtures.js';
|
||||
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe.serial('Dark mode', () => {
|
||||
test('Scenario: Theme toggle persists selection across page reload', async ({
|
||||
@@ -110,4 +110,38 @@ test.describe.serial('Dark mode', () => {
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Bold text in notes uses the theme text colour in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a note with bold content exists
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await createNote(page, 'Bold Contrast Note', 'Plain then **bold words** here');
|
||||
|
||||
// And dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When viewing the note on the notes page
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const preview = noteCard(page, 'Bold Contrast Note').getByTestId('note-content-preview');
|
||||
const boldInCard = preview.locator('strong');
|
||||
await expect(boldInCard).toHaveText('bold words');
|
||||
|
||||
// Then the bold text is rendered in the dark theme's text colour (--text = #ece3d3),
|
||||
// not the typography plugin's hardcoded near-black
|
||||
await expect(boldInCard).toHaveCSS('color', 'rgb(236, 227, 211)');
|
||||
|
||||
// And the same holds inside the editor
|
||||
await noteCard(page, 'Bold Contrast Note').click();
|
||||
const boldInEditor = page.getByTestId('tiptap-editor').locator('.tiptap strong');
|
||||
await expect(boldInEditor).toHaveText('bold words');
|
||||
await expect(boldInEditor).toHaveCSS('color', 'rgb(236, 227, 211)');
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.1</span>
|
||||
<span class="footer-version">v0.35.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user