Compare commits

...
16 Commits
Author SHA1 Message Date
github-actions[bot] b0ecc6b74d chore: bump version to 0.34.2 2026-09-07 17:37:47 +00:00
Frederic CabassutandClaude Fable 5.1 521acc718c docs: require all work to happen in a dedicated worktree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:53 +02:00
Frederic CabassutandClaude Fable 5.1 082b8632c6 fix: render empty task list items as checkboxes in note previews
The task-list markdown rule only matched a marker followed by a space
("[ ] text"). markdown-it trims trailing whitespace from inline content,
so an item with no text arrived as exactly "[ ]" and rendered as literal
brackets in the card preview, while the editor showed a checkbox.

Match the marker followed by a space or end-of-content instead, and strip
only what was matched so "[ ]x" glued to text stays literal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:53 +02:00
Frederic CabassutandClaude Fable 5.1 6bdcc3c214 fix(theme): map all prose colour variables to theme tokens for dark mode
The typography plugin gives each prose element its own --tw-prose-*
variable with a hardcoded grey default. Only body, headings, links and
code were remapped to parchment tokens, so bold text (and quotes, kbd,
bullets, counters, hr and table borders) kept their light-mode greys in
dark mode. Bold rendered near-black on the dark surface.

Map every variable to a theme token so [data-theme="dark"] flips them
all, and add an e2e scenario asserting bold text in the card preview and
editor computes to --text in dark mode.

Fixes #86

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:32 +02:00
Frederic Cabassut e8c30c518c revert: pin better-sqlite3 back to v11.10.0, breaks CI native builds
v13.0.3 has no prebuilt binary for the exact platform/Node combo used
by both this project's Docker build stage (node:22-slim) and the
GitHub Actions E2E runner, so it falls back to compiling from source
via node-gyp. Neither environment has python3/make available for that
fallback, so both the Docker Build and E2E Tests CI jobs failed with
"Could not find any Python installation" / "not found: make".

This wasn't fixing a known vulnerability (audit was already clean), so
revert rather than start patching the Docker image and CI workflow's
system packages for a version-currency bump.
2026-09-02 09:34:03 +02:00
Frederic Cabassut 84145de3b1 chore(deps): remove obsolete @types/uuid stub
uuid v14 ships its own type definitions; @types/uuid now publishes as
an empty stub explicitly saying it's no longer needed.
2026-09-02 09:34:03 +02:00
Frederic Cabassut ea66e23178 chore(deps): update better-sqlite3 to v13 (N-API rewrite)
v13 rewrote the native binding on top of N-API for better cross-version
binary compatibility; no application-facing API changes for the query
methods this app uses. Compiled and verified against the full DB test
suite (notes, versions, shares, collaborators, tags, search, api-keys).
Bumped @types/better-sqlite3 to match.
2026-09-02 09:34:03 +02:00
Frederic Cabassut feda983393 chore(deps): update argon2 to v0.45.1
No API changes (build system tweaks, dropped Node 18/20 support - this
project already targets Node 22+). Verified against the password
hash/verify test suite.
2026-09-02 09:34:03 +02:00
Frederic Cabassut bf5bfdd98e chore(deps): update uuid to v14
No code changes needed - the project is already full ESM ("type":
"module") and only uses the ESM v4 import, so v12's CommonJS removal
doesn't apply here.
2026-09-02 09:34:03 +02:00
Frederic Cabassut 12ff6d9c4d chore(deps): update markdown-it to v15 for quadratic-complexity fixes
v15.0.1 fixes quadratic-complexity bugs in the linkify inline rule and
fuzzy-link replacement - relevant here since this app renders notes
with linkify: true. Also drops @types/markdown-it, which v15 makes
redundant by bundling its own TypeScript declarations (conflicting
otherwise), and adapts two call sites to the now-more-precise
attrGet() return type (string | number | null vs the community types'
string | null).
2026-09-02 09:34:03 +02:00
Frederic Cabassut 44963bf684 chore(deps): fix nanoid vulnerability and update dependencies within semver range
- Add a scoped pnpm override forcing postcss's transitive nanoid to
  >=3.3.18 <4, patching GHSA-2v37-7h3g-55p8 (indefinite loop in custom
  generators with size=0). Scoped to postcss's own dependency rather
  than the top-level nanoid so the app's own nanoid@5 usage is
  untouched.
- Update all other dependencies within their existing package.json
  semver ranges (tiptap, markdown-it, nodemailer, svelte-dnd-action,
  zod, sveltejs/kit, svelte, svelte-check, tsx and their @types).

pnpm audit: 1 high -> 0 vulnerabilities.
2026-09-02 09:34:03 +02:00
github-actions[bot] dfaf1c474d chore: pin docker-compose to v0.34.1 [skip ci] 2026-09-01 10:08:28 +00:00
github-actions[bot] 881a85a09e chore: bump version to 0.34.1 2026-09-01 10:01:25 +00:00
Frederic Cabassut 27c3e7dccd fix(editor): render the table-with-list HTML fallback safely instead of escaping it
The table markdown fallback (previous commit) embeds raw HTML in saved
markdown when a cell holds a bullet list, since GFM can't express block
content in a table cell. But the read-only renderer (note-card preview,
public share page) ran with html:false, so that HTML rendered as
escaped literal text instead of a table - moving the bug rather than
fixing it.

Enable html mode and sanitize the rendered output with sanitize-html.
Sanitizing matters beyond this one fallback: note content isn't only
ever written through the editor - the MCP server and notes API accept
a raw markdown string directly, bypassing the editor's schema, so any
HTML this renders has to be treated as untrusted regardless of source.

Also make the table's HTML fallback respect the editor's own `html`
option (falls back to a `[table]` placeholder when disabled), matching
every other node type's markdown storage in tiptap-markdown.

Found via a 3-agent adversarial review of the previous fix.
2026-09-01 12:00:36 +02:00
Frederic Cabassut 64f96b21a3 fix(editor): preserve bullet lists inside table cells on save/reopen
tiptap-markdown's bundled table serializer only rejects cells with
multiple children or spans, not a single non-paragraph block child. A
cell holding just a bullet list slipped through and got fed to
renderInline(), which is only valid for textblocks, corrupting the row
on save. Now such cells fall back to serializing the whole table as
raw HTML, same as tiptap-markdown does for other unsupported layouts.

Fixes #83
2026-09-01 12:00:36 +02:00
github-actions[bot] ff24fb6d5b chore: pin docker-compose to v0.34.0 [skip ci] 2026-08-19 13:22:25 +00:00
12 changed files with 1455 additions and 837 deletions
+2 -1
View File
@@ -171,7 +171,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
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.33.0
image: ghcr.io/bretzel-app/crumbs:0.34.1
container_name: crumbs
ports:
- "3000:3000"
+28 -27
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.34.0",
"version": "0.34.2",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
@@ -36,55 +36,55 @@
"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-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",
"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",
"svelte-dnd-action": "^0.9.77",
"nodemailer": "^9.1.0",
"sanitize-html": "^2.17.7",
"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/uuid": "^10.0.0",
"@types/sanitize-html": "^2.16.1",
"@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",
@@ -106,7 +106,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"
}
}
}
+982 -796
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -88,14 +88,29 @@ 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(--bg-surface);
--tw-prose-th-borders: var(--border-subtle);
--tw-prose-td-borders: var(--border-subtle);
}
.prose :where(code):not(:where([class~="not-prose"] *)) {
+1 -1
View File
@@ -6,7 +6,7 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import { Table } from './tiptap/Table.js';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
+164
View File
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { Editor, type JSONContent } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import { Markdown } from 'tiptap-markdown';
import { Table } from './Table.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
function createEditor(content: string | object, markdownOptions?: { html?: boolean }) {
return new Editor({
extensions: [
StarterKit,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown.configure(markdownOptions ?? {})
],
content
});
}
function getMarkdown(editor: Editor): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (editor.storage as Record<string, any>).markdown.getMarkdown();
}
function docWithListInCell(): JSONContent {
return {
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }]
},
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }]
}
]
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
content: [
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] },
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] }
]
}
]
},
{
type: 'tableCell',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }]
}
]
}
]
}
]
};
}
// crumbs#83: a bulleted list inside a table cell survives in the live editor
// (never touches markdown) but breaks once the note is saved and reopened,
// because saving/reopening round-trips the doc through markdown text.
describe('Table markdown round-trip with a bullet list in a cell', () => {
it('preserves a bullet list inside a table cell after saving and reopening', () => {
const editor = createEditor(docWithListInCell());
// "Save": serialize the crumb to markdown, the way onUpdate() does.
const savedMarkdown = getMarkdown(editor);
// "Reopen": load that markdown into a fresh editor, the way opening the
// crumb again does (content is passed in as a markdown string).
const reopened = createEditor(savedMarkdown);
const json = reopened.getJSON() as JSONContent;
const table = json.content?.find((node) => node.type === 'table');
expect(table).toBeDefined();
const rows = table!.content!;
expect(rows).toHaveLength(2);
const [headerRow, bodyRow] = rows;
expect(headerRow.content!.map((cell) => cell.content?.[0]?.content?.[0]?.text)).toEqual(['Header A', 'Header B']);
const [listCell, plainCell] = bodyRow.content!;
expect(listCell.content?.[0]?.type).toBe('bulletList');
const items = listCell.content?.[0]?.content ?? [];
expect(items.map((item) => item.content?.[0]?.content?.[0]?.text)).toEqual(['First', 'Second']);
expect(plainCell.content?.[0]?.content?.[0]?.text).toBe('Plain cell');
});
it('still renders as a real table (not escaped HTML) on the read-only preview/share path', () => {
const editor = createEditor(docWithListInCell());
const savedMarkdown = getMarkdown(editor);
const html = renderMarkdown(savedMarkdown);
expect(html).toContain('<table>');
expect(html).toContain('<ul');
expect(html).toContain('First');
expect(html).toContain('Second');
expect(html).not.toContain('&lt;table&gt;');
});
it('still serializes a table with only plain-text cells as GFM markdown, not HTML', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] }
]
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] },
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2' }] }] }
]
}
]
}
]
});
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('| A | B |');
expect(savedMarkdown).toContain('| 1 | 2 |');
});
it('falls back to a placeholder instead of raw HTML when the editor has html mode disabled', () => {
const editor = createEditor(docWithListInCell(), { html: false });
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('[table]');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { elementFromString, getHTMLFromFragment } from '@tiptap/core';
import { Table as BaseTable } from '@tiptap/extension-table';
import { Fragment, type Node as ProseMirrorNode } from '@tiptap/pm/model';
interface MarkdownSerializerState {
write(text: string): void;
ensureNewLine(): void;
closeBlock(node: ProseMirrorNode): void;
renderInline(node: ProseMirrorNode): void;
inTable: boolean;
}
interface SerializeContext {
editor: { storage: { markdown: { options: { html: boolean } } } };
}
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
const nodes: ProseMirrorNode[] = [];
node.forEach((child) => nodes.push(child));
return nodes;
}
function hasSpan(cell: ProseMirrorNode): boolean {
return cell.attrs.colspan > 1 || cell.attrs.rowspan > 1;
}
/**
* A cell can only be squeezed onto a single `| cell |` markdown line via
* `state.renderInline()` if its content is itself a textblock (a paragraph,
* whose children are inline text runs). A block child instead - e.g. a
* bullet list - has to be rejected here, or renderInline() misreads its
* block-level children as inline ones and corrupts the row (crumbs#83).
*/
function hasOnlyInlineContent(cell: ProseMirrorNode): boolean {
const content = cell.firstChild;
return content === null || content.type.isTextblock;
}
function isMarkdownSerializable(node: ProseMirrorNode): boolean {
const rows = childNodes(node);
const firstRow = rows[0];
const bodyRows = rows.slice(1);
if (
childNodes(firstRow).some(
(cell) => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
) {
return false;
}
return !bodyRows.some((row) =>
childNodes(row).some(
(cell) => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
);
}
/** Mirrors tiptap-markdown's internal HTML-block fallback (its HTMLNode spec isn't exported). */
function formatBlock(html: string): string {
const element = elementFromString(html).firstElementChild!;
element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n';
return element.outerHTML;
}
/**
* Same as tiptap-markdown's HTMLNode fallback: when the editor's `html`
* option is off, other node types degrade to a `[nodename]` placeholder
* instead of emitting HTML. A table with non-inline cell content has to
* follow the same rule, or it'd be the only node type still leaking raw HTML
* with html mode disabled.
*/
function serializeAsHTML(
context: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!context.editor.storage.markdown.options.html) {
state.write(`[${node.type.name}]`);
state.closeBlock(node);
return;
}
const schema = node.type.schema;
const html = getHTMLFromFragment(Fragment.from(node), schema);
const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name;
state.write(isTopLevel ? formatBlock(html) : html);
state.closeBlock(node);
}
/**
* Overrides @tiptap/extension-table's markdown storage (tiptap-markdown ships
* a default for it, but only guards against multi-child/spanning cells, not
* a single block child - see hasOnlyInlineContent above).
*/
export const Table = BaseTable.extend({
addStorage() {
return {
markdown: {
serialize(
this: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!isMarkdownSerializable(node)) {
serializeAsHTML(this, state, node, parent);
return;
}
state.inTable = true;
node.forEach((row, _rowOffset, rowIndex) => {
state.write('| ');
row.forEach((cell, _cellOffset, cellIndex) => {
if (cellIndex) {
state.write(' | ');
}
const cellContent = cell.firstChild;
if (cellContent && cellContent.textContent.trim()) {
state.renderInline(cellContent);
}
});
state.write(' |');
state.ensureNewLine();
if (!rowIndex) {
const delimiterRow = Array.from({ length: row.childCount }, () => '---').join(' | ');
state.write(`| ${delimiterRow} |`);
state.ensureNewLine();
}
});
state.closeBlock(node);
state.inTable = false;
},
parse: {
// handled by markdown-it, same as tiptap-markdown's default table spec
}
}
};
}
});
+52
View File
@@ -62,6 +62,58 @@ describe('renderMarkdown', () => {
const html = renderMarkdown('line 1\nline 2');
expect(html).toContain('<br>');
});
it('should render a raw HTML table block (the table-with-a-list-in-a-cell fallback) instead of escaping it', () => {
// crumbs#83: a bulleted list inside a table cell can't be expressed in
// plain GFM, so the editor falls back to embedding the whole table as
// raw HTML in the saved markdown. The preview/share renderer must
// actually render that block, not show it as literal escaped text.
const html = renderMarkdown(
'<table><tbody><tr><td><ul><li>First</li><li>Second</li></ul></td><td>Plain cell</td></tr></tbody></table>'
);
expect(html).toContain('<table>');
expect(html).toContain('<li>First</li>');
expect(html).toContain('<li>Second</li>');
expect(html).not.toContain('&lt;table&gt;');
});
it('should strip a script tag from raw HTML content', () => {
// Note content isn't only ever written through the Tiptap editor - the
// MCP server and the notes API accept a raw markdown string directly,
// so this renderer can't trust that any HTML it's asked to render is
// safe just because rendering HTML at all is now allowed.
const html = renderMarkdown('<p>hello</p><script>alert(1)</script><img src=x onerror="alert(2)">');
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).toContain('hello');
});
it('should still render task-list checkboxes after sanitization', () => {
const html = renderMarkdown('[ ] todo\n\n[x] done');
expect(html).toContain('type="checkbox"');
expect(html).toContain('checked');
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', () => {
+31 -7
View File
@@ -1,24 +1,48 @@
import MarkdownIt from 'markdown-it';
import sanitizeHtml from 'sanitize-html';
const md = new MarkdownIt({
html: false,
html: true,
linkify: true,
typographer: true,
breaks: true
});
// Raw HTML has to render (not just be escaped as visible text): a table with
// a list in a cell can't be expressed in plain GFM, so the editor falls back
// to embedding it as an HTML block (see tiptap/Table.ts). Note content also
// isn't only ever written through that editor - the MCP server and the notes
// API accept a raw markdown string directly - so any HTML this renders has to
// be sanitized regardless of where it came from.
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'input'],
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
input: ['type', 'checked', 'disabled', 'class'],
ul: ['class']
},
// 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.
selfClosing: []
};
// Add task list support
md.core.ruler.after('inline', 'task-lists', (state) => {
const tokens = state.tokens;
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);
@@ -28,7 +52,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');
}
@@ -46,7 +70,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;
@@ -59,7 +83,7 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
});
export function renderMarkdown(content: string): string {
return md.render(content);
return sanitizeHtml(md.render(content), SANITIZE_OPTIONS);
}
export function stripMarkdown(content: string): string {
+35 -1
View File
@@ -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();
});
});
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.34.0</span>
<span class="footer-version">v0.34.2</span>
</div>
</footer>