Files
crumbs-original/src/lib/components/tiptap/Table.ts
T
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

142 lines
4.3 KiB
TypeScript

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
}
}
};
}
});