forked from mirror/crumbs-original
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ecc6b74d | ||
|
|
521acc718c | ||
|
|
082b8632c6 | ||
|
|
6bdcc3c214 | ||
|
|
e8c30c518c | ||
|
|
84145de3b1 | ||
|
|
ea66e23178 | ||
|
|
feda983393 | ||
|
|
bf5bfdd98e | ||
|
|
12ff6d9c4d | ||
|
|
44963bf684 | ||
|
|
dfaf1c474d | ||
|
|
881a85a09e | ||
|
|
27c3e7dccd | ||
|
|
64f96b21a3 | ||
|
|
ff24fb6d5b |
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+982
-796
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -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"] *)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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('<table>');
|
||||
});
|
||||
|
||||
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]');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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('<table>');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user