Add editor document schema in packages/shared (#24)
Some checks failed
CD / Build and push images (push) Successful in 1m50s
CI / Lint, typecheck, test (push) Failing after 52s
CI / Auth e2e pack (push) Successful in 1m47s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m5s
CD / Promote to Int (push) Successful in 10s

ProseMirror schema (headings 1-4, lists incl. task lists, blockquote,
code block, tables via prosemirror-tables, images, hard breaks; bold/
italic/code/strikethrough/link marks) plus docToMarkdown, markdownToDoc,
docToPlainText, docToHtml, and extractOutline built on it. Markdown
parsing extends markdown-it's default preset with a token-stream
transform for GFM task lists and table-cell paragraph wrapping.
docToHtml hand-rolls escaping and link-protocol allowlisting with zero
DOM dependencies, so it runs in the API/collab server as well as the
browser.

Node names `wikilink` and `plugin_block` are reserved for later stories.

Closes #24
This commit is contained in:
Claude Sonnet 5 2026-07-05 21:59:12 +02:00
parent 4dd452c0af
commit b89aa6bed0
14 changed files with 983 additions and 0 deletions

View File

@ -25,9 +25,15 @@
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"markdown-it": "^14.3.0",
"prosemirror-markdown": "^1.13.4",
"prosemirror-model": "^1.25.9",
"prosemirror-tables": "^1.8.5",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^26.1.0",
"tsup": "^8.3.0", "tsup": "^8.3.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
} }

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { docToHtml } from './html';
import { markdownToDoc } from './markdown';
import { editorSchema } from './schema';
describe('docToHtml (issue #24)', () => {
it('escapes text content, including angle brackets and quotes', () => {
const doc = markdownToDoc('Contains <script>alert("x")</script> literally.');
const html = docToHtml(doc);
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
expect(html).toContain('&quot;x&quot;');
});
it('renders inline marks and a table', () => {
const doc = markdownToDoc('**bold** and *italic* and `code`');
expect(docToHtml(doc)).toBe('<p><strong>bold</strong> and <em>italic</em> and <code>code</code></p>');
const table = markdownToDoc('| A | B |\n| --- | --- |\n| 1 | 2 |');
expect(docToHtml(table)).toBe('<table><tr><th><p>A</p></th><th><p>B</p></th></tr><tr><td><p>1</p></td><td><p>2</p></td></tr></table>');
});
it('allowlists link protocols, neutralizing javascript: hrefs', () => {
const safe = markdownToDoc('[go](https://example.org)');
expect(docToHtml(safe)).toContain('href="https://example.org"');
// markdown-it itself already refuses to tokenize `javascript:` links
// (falls back to plain text), so the schema is built directly here to
// exercise docToHtml's own allowlist (security.md) independently of
// that upstream defense.
const linkMark = editorSchema.marks.link.create({ href: 'javascript:evil' });
const doc = editorSchema.node('doc', null, [
editorSchema.node('paragraph', null, [editorSchema.text('click me', [linkMark])]),
]);
const html = docToHtml(doc);
expect(html).not.toContain('javascript:');
expect(html).toContain('href="#"');
});
it('renders task list checkboxes with their checked state', () => {
const doc = markdownToDoc('- [ ] Todo\n- [x] Done');
const html = docToHtml(doc);
expect(html).toContain('data-checked="false"');
expect(html).toContain('data-checked="true"');
expect(html).toContain('<input type="checkbox" disabled checked>');
});
});

View File

@ -0,0 +1,130 @@
import { Mark, Node } from 'prosemirror-model';
import { isAllowedLinkHref } from './schema';
/**
* Hand-rolled ProseMirror HTML renderer (security.md §Content): every
* text node is escaped and link protocols are allowlisted. No DOM is
* touched this runs in the API/collab server as much as in the browser.
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const MARK_TAGS: Record<string, string> = {
bold: 'strong',
italic: 'em',
code: 'code',
strikethrough: 's',
};
function tagNameFor(mark: Mark): string {
return mark.type.name === 'link' ? 'a' : (MARK_TAGS[mark.type.name] ?? mark.type.name);
}
function openTagFor(mark: Mark): string {
if (mark.type.name === 'link') {
const href = mark.attrs.href as string;
const safeHref = isAllowedLinkHref(href) ? href : '#';
return `<a href="${escapeHtml(safeHref)}" rel="noopener noreferrer">`;
}
return `<${tagNameFor(mark)}>`;
}
function renderMarks(marks: readonly Mark[], inner: string): string {
const open = marks.map(openTagFor).join('');
const close = [...marks]
.reverse()
.map((mark) => `</${tagNameFor(mark)}>`)
.join('');
return open + inner + close;
}
function renderInline(node: Node): string {
let out = '';
node.forEach((child) => {
if (child.isText) {
out += renderMarks(child.marks, escapeHtml(child.text ?? ''));
} else if (child.type.name === 'hard_break') {
out += '<br>';
} else if (child.type.name === 'image') {
const alt = escapeHtml((child.attrs.alt as string) ?? '');
const width = child.attrs.width as number | null;
const widthAttr = width ? ` width="${width}"` : '';
out += `<img data-file-id="${escapeHtml(child.attrs.fileId as string)}" alt="${alt}"${widthAttr}>`;
}
});
return out;
}
function renderListItems(node: Node): string {
let out = '';
node.forEach((item) => {
if (item.type.name === 'task_item') {
const checked = item.attrs.checked === true;
out += `<li data-type="task_item" data-checked="${checked}"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
} else {
out += `<li>${renderBlocks(item)}</li>`;
}
});
return out;
}
function renderTable(node: Node): string {
let out = '<table>';
node.forEach((row) => {
out += '<tr>';
row.forEach((cell) => {
const tag = cell.type.name === 'table_header' ? 'th' : 'td';
out += `<${tag}>${renderBlocks(cell)}</${tag}>`;
});
out += '</tr>';
});
return `${out}</table>`;
}
function renderBlock(node: Node): string {
switch (node.type.name) {
case 'paragraph':
return `<p>${renderInline(node)}</p>`;
case 'heading': {
const level = node.attrs.level as number;
return `<h${level}>${renderInline(node)}</h${level}>`;
}
case 'blockquote':
return `<blockquote>${renderBlocks(node)}</blockquote>`;
case 'code_block':
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
case 'horizontal_rule':
return '<hr>';
case 'bullet_list':
return `<ul>${renderListItems(node)}</ul>`;
case 'ordered_list': {
const order = node.attrs.order as number;
const startAttr = order !== 1 ? ` start="${order}"` : '';
return `<ol${startAttr}>${renderListItems(node)}</ol>`;
}
case 'task_list':
return `<ul data-type="task_list">${renderListItems(node)}</ul>`;
case 'table':
return renderTable(node);
default:
return renderBlocks(node);
}
}
function renderBlocks(node: Node): string {
let out = '';
node.forEach((child) => {
out += renderBlock(child);
});
return out;
}
export function docToHtml(doc: Node): string {
return renderBlocks(doc);
}

View File

@ -0,0 +1,5 @@
export * from './schema';
export * from './markdown';
export * from './html';
export * from './plain-text';
export * from './outline';

View File

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { docToMarkdown, markdownToDoc } from './markdown';
/**
* Markdown doc Markdown must be stable for the node/mark set the
* schema supports (issue #24 acceptance criterion). Each fixture is
* expected to serialize back to itself byte-for-byte.
*/
const FIXTURES: Record<string, string> = {
headings: '# Level one\n\n## Level two\n\n### Level three\n\n#### Level four',
paragraphInlineMarks:
'Plain, **bold**, *italic*, ~~strikethrough~~, `code`, and [a link](https://example.org/page).',
bulletList: '- First item\n- Second item\n- Third item',
orderedList: '1. First\n2. Second\n3. Third',
taskList: '- [ ] Buy milk\n- [x] Walk the dog',
blockquote: '> Quoted paragraph one.\n>\n> Quoted paragraph two.',
codeBlock: '```\nconst x = 1;\nconsole.log(x);\n```',
horizontalRule: 'Above\n\n---\n\nBelow',
table: '| Name | Role |\n| --- | --- |\n| Uma | Editor |\n| Otto | Reader |',
image: '![A pond](file-123)',
nestedBlockquoteAndList: '> - Item inside a quote\n> - Second item',
};
describe('markdown round-trip (issue #24)', () => {
for (const [name, markdown] of Object.entries(FIXTURES)) {
it(`stabilizes: ${name}`, () => {
const doc = markdownToDoc(markdown);
expect(docToMarkdown(doc)).toBe(markdown);
});
}
it('produces the expected document shape for a mixed fixture', () => {
const doc = markdownToDoc(FIXTURES.paragraphInlineMarks ?? '');
expect(doc.toJSON()).toMatchObject({
type: 'doc',
content: [{ type: 'paragraph' }],
});
});
it('clamps markdown heading levels beyond 4 down to level 4', () => {
const doc = markdownToDoc('###### Deep heading');
const heading = doc.firstChild;
expect(heading?.type.name).toBe('heading');
expect(heading?.attrs.level).toBe(4);
});
it('does not turn a mixed checkbox/plain list into a task list', () => {
const doc = markdownToDoc('- [ ] Task\n- Plain item');
expect(doc.firstChild?.type.name).toBe('bullet_list');
});
});

View File

@ -0,0 +1,315 @@
import MarkdownIt from 'markdown-it';
import Token from 'markdown-it/lib/token.mjs';
import { Mark, Node } from 'prosemirror-model';
import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown';
import { editorSchema } from './schema';
/**
* markdown-it's default preset already tokenizes GFM tables and
* strikethrough; task lists are GFM-only and not tokenized by markdown-it
* itself, so {@link transformTokens} rewrites the raw token stream before
* `prosemirror-markdown` turns it into a document (ADR 0004).
*/
function findMatchingClose(tokens: Token[], openIndex: number): number {
let depth = 1;
for (let i = openIndex + 1; i < tokens.length; i += 1) {
const type = tokens[i]?.type ?? '';
if (type.endsWith('_open')) depth += 1;
else if (type.endsWith('_close')) depth -= 1;
if (depth === 0) return i;
}
throw new Error(`Unbalanced markdown-it token stream at index ${openIndex}`);
}
/** Indices of `type` tokens directly inside `[start, end)`, not in a nested container. */
function directChildOpens(tokens: Token[], start: number, end: number, type: string): number[] {
const result: number[] = [];
let depth = 0;
for (let i = start; i < end; i += 1) {
const tok = tokens[i];
if (!tok) continue;
if (depth === 0 && tok.type === type) result.push(i);
if (tok.type.endsWith('_open')) depth += 1;
else if (tok.type.endsWith('_close')) depth -= 1;
}
return result;
}
const TASK_MARKER = /^\[([ xX])\]\s+/;
/**
* Reads (and strips) the `[ ] `/`[x] ` prefix from a list item's first
* paragraph. Returns `null` when the item has no checkbox marker, which
* also signals "this list is not a task list" to the caller.
*/
function taskCheckedFor(tokens: Token[], itemOpen: number): boolean | null {
const paragraphOpen = tokens[itemOpen + 1];
if (paragraphOpen?.type !== 'paragraph_open') return null;
const inline = tokens[itemOpen + 2];
if (inline?.type !== 'inline') return null;
const firstChild = inline.children?.[0];
if (!firstChild || firstChild.type !== 'text') return null;
const match = TASK_MARKER.exec(firstChild.content);
if (!match) return null;
firstChild.content = firstChild.content.slice(match[0].length);
return (match[1] ?? '').toLowerCase() === 'x';
}
function retype(token: Token, type: string): Token {
const clone = new Token(type, token.tag, token.nesting);
Object.assign(clone, token, { type });
return clone;
}
/**
* Rewrites the markdown-it token stream so it matches the editor schema:
* - a bullet list where every item carries a `[ ]`/`[x]` marker becomes a
* `task_list` of `task_item`s (mixed lists are left as plain bullet
* lists GFM does not define their meaning either);
* - table cell content (a bare `inline` token in markdown-it) is wrapped
* in a synthetic paragraph, matching `table_cell`/`table_header`'s
* `block+` content.
*/
function transformTokens(tokens: Token[]): Token[] {
const out: Token[] = [];
let i = 0;
while (i < tokens.length) {
const tok = tokens[i];
if (!tok) {
i += 1;
continue;
}
if (tok.type === 'bullet_list_open') {
const close = findMatchingClose(tokens, i);
const itemOpens = directChildOpens(tokens, i + 1, close, 'list_item_open');
const checks = itemOpens.map((idx) => taskCheckedFor(tokens, idx));
const isTaskList = checks.length > 0 && checks.every((c) => c !== null);
if (isTaskList) {
out.push(retype(tok, 'task_list_open'));
let itemPos = 0;
for (let j = i + 1; j < close; j += 1) {
const t = tokens[j];
if (!t) continue;
if (t.type === 'list_item_open') {
const retyped = retype(t, 'task_item_open');
retyped.attrSet('checked', String(checks[itemPos] === true));
itemPos += 1;
out.push(retyped);
} else if (t.type === 'list_item_close') {
out.push(retype(t, 'task_item_close'));
} else {
out.push(t);
}
}
const closeTok = tokens[close];
if (closeTok) out.push(retype(closeTok, 'task_list_close'));
i = close + 1;
continue;
}
}
if (tok.type === 'th_open' || tok.type === 'td_open') {
const close = findMatchingClose(tokens, i);
out.push(tok);
out.push(Object.assign(new Token('paragraph_open', 'p', 1), { hidden: true }));
for (let j = i + 1; j < close; j += 1) {
const t = tokens[j];
if (t) out.push(t);
}
out.push(Object.assign(new Token('paragraph_close', 'p', -1), { hidden: true }));
const closeTok = tokens[close];
if (closeTok) out.push(closeTok);
i = close + 1;
continue;
}
out.push(tok);
i += 1;
}
return out;
}
function createTokenizer(): MarkdownIt {
const md = new MarkdownIt('default', { html: false });
const rawParse = md.parse.bind(md);
md.parse = (src, env) => transformTokens(rawParse(src, env));
return md;
}
const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
blockquote: { block: 'blockquote' },
paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' },
task_item: {
block: 'task_item',
getAttrs: (tok) => ({ checked: tok.attrGet('checked') === 'true' }),
},
bullet_list: { block: 'bullet_list' },
task_list: { block: 'task_list' },
ordered_list: {
block: 'ordered_list',
getAttrs: (tok) => ({ order: Number(tok.attrGet('start')) || 1 }),
},
heading: {
block: 'heading',
// The schema only defines levels 1-4; deeper markdown headings clamp
// down rather than fail the parse.
getAttrs: (tok) => ({ level: Math.min(4, Number(tok.tag.slice(1)) || 1) }),
},
code_block: { block: 'code_block', noCloseToken: true },
fence: { block: 'code_block', noCloseToken: true },
hr: { node: 'horizontal_rule' },
image: {
node: 'image',
// The "src" slot carries the opaque fileId; the API's markdown import/
// export endpoint (#30) is responsible for resolving it to a servable
// URL and back — that boundary is not this package's concern.
getAttrs: (tok) => ({
fileId: tok.attrGet('src') ?? '',
alt: tok.children?.[0]?.content ?? '',
width: null,
}),
},
hardbreak: { node: 'hard_break' },
em: { mark: 'italic' },
strong: { mark: 'bold' },
s: { mark: 'strikethrough' },
link: { mark: 'link', getAttrs: (tok) => ({ href: tok.attrGet('href') ?? '' }) },
code_inline: { mark: 'code', noCloseToken: true },
table: { block: 'table' },
thead: { ignore: true },
tbody: { ignore: true },
tr: { block: 'table_row' },
th: { block: 'table_header' },
td: { block: 'table_cell' },
});
/** Parses Markdown into a document of {@link editorSchema}. */
export function markdownToDoc(markdown: string): Node {
return markdownParser.parse(markdown);
}
function renderTable(state: MarkdownSerializerState, node: Node): void {
const rows: string[][] = [];
node.forEach((row) => {
const cells: string[] = [];
row.forEach((cell) => {
cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim());
});
rows.push(cells);
});
const headerRow = rows[0];
if (!headerRow) {
state.closeBlock(node);
return;
}
const lines = [
`| ${headerRow.join(' | ')} |`,
`| ${headerRow.map(() => '---').join(' | ')} |`,
...rows.slice(1).map((row) => `| ${row.join(' | ')} |`),
];
state.write(lines.join('\n'));
state.closeBlock(node);
}
/** Serializes a document of {@link editorSchema} back to Markdown. */
const markdownSerializer = new MarkdownSerializer(
{
blockquote(state, node) {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
},
code_block(state, node) {
const backticks = node.textContent.match(/`{3,}/gm);
const fence = backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```';
state.write(`${fence}\n`);
state.text(node.textContent, false);
state.write('\n');
state.write(fence);
state.closeBlock(node);
},
heading(state, node) {
state.write(`${state.repeat('#', node.attrs.level as number)} `);
state.renderInline(node, false);
state.closeBlock(node);
},
horizontal_rule(state, node) {
state.write('---');
state.closeBlock(node);
},
bullet_list(state, node) {
state.renderList(node, ' ', () => '- ');
},
task_list(state, node) {
state.renderList(node, ' ', (i) => `- [${node.child(i).attrs.checked ? 'x' : ' '}] `);
},
ordered_list(state, node) {
const start = (node.attrs.order as number) || 1;
const maxWidth = String(start + node.childCount - 1).length;
const space = state.repeat(' ', maxWidth + 2);
state.renderList(node, space, (i) => {
const numeral = String(start + i);
return state.repeat(' ', maxWidth - numeral.length) + numeral + '. ';
});
},
list_item(state, node) {
state.renderContent(node);
},
task_item(state, node) {
state.renderContent(node);
},
paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
image(state, node) {
const alt = state.esc((node.attrs.alt as string) || '');
const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&');
state.write(`![${alt}](${fileId})`);
},
hard_break(state, node, parent, index) {
for (let i = index + 1; i < parent.childCount; i += 1) {
if (parent.child(i).type !== node.type) {
state.write('\\\n');
return;
}
}
},
text(state, node) {
state.text(node.text ?? '');
},
table: renderTable,
},
{
italic: { open: '*', close: '*', mixable: true, expelEnclosingWhitespace: true },
bold: { open: '**', close: '**', mixable: true, expelEnclosingWhitespace: true },
strikethrough: { open: '~~', close: '~~', mixable: true, expelEnclosingWhitespace: true },
link: {
open: '[',
close: (_state, mark: Mark) => `](${(mark.attrs.href as string).replace(/[()]/g, '\\$&')})`,
mixable: true,
},
code: {
open: (_state, _mark, parent, index) => backticksFor(parent.child(index), -1),
close: (_state, _mark, parent, index) => backticksFor(parent.child(index - 1), 1),
escape: false,
},
},
);
function backticksFor(node: Node, side: -1 | 1): string {
const text = node.isText ? (node.text ?? '') : '';
const matches = text.match(/`+/g);
const len = matches ? Math.max(...matches.map((m) => m.length)) : 0;
let result = len > 0 && side > 0 ? ' `' : '`';
result += '`'.repeat(len);
if (len > 0 && side < 0) result += ' ';
return result;
}
export function docToMarkdown(doc: Node): string {
// The schema has no `tight`/`loose` list attribute (out of scope for v1)
// — every list renders without blank lines between items.
return markdownSerializer.serialize(doc, { tightLists: true });
}

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { markdownToDoc } from './markdown';
import { extractOutline } from './outline';
describe('extractOutline (issue #24)', () => {
it('returns the heading tree with levels and text', () => {
const doc = markdownToDoc(
'# Handbook\n\nIntro paragraph.\n\n## Onboarding\n\n### Day one\n\n## Offboarding',
);
expect(extractOutline(doc)).toEqual([
{ id: 'handbook', level: 1, text: 'Handbook' },
{ id: 'onboarding', level: 2, text: 'Onboarding' },
{ id: 'day-one', level: 3, text: 'Day one' },
{ id: 'offboarding', level: 2, text: 'Offboarding' },
]);
});
it('deduplicates repeated heading text with stable numeric suffixes', () => {
const doc = markdownToDoc('# Notes\n\n## Notes\n\n## Notes');
expect(extractOutline(doc).map((entry) => entry.id)).toEqual(['notes', 'notes-2', 'notes-3']);
});
});

View File

@ -0,0 +1,31 @@
import { Node } from 'prosemirror-model';
import { slugify } from '../ponds';
export interface OutlineEntry {
id: string;
level: number;
text: string;
}
/**
* Heading tree for TOC plugins (data-model.md `page_content_cache`). Ids
* are stable across re-derivation: same heading text + position in the
* duplicate sequence always yields the same id (deterministic suffixes,
* mirroring the pond-slug convention in `../ponds`).
*/
export function extractOutline(doc: Node): OutlineEntry[] {
const entries: OutlineEntry[] = [];
const seen = new Map<string, number>();
doc.descendants((node) => {
if (node.type.name !== 'heading') return true;
const text = node.textContent.trim();
const base = slugify(text) || 'section';
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
const id = count === 0 ? base : `${base}-${count + 1}`;
entries.push({ id, level: node.attrs.level as number, text });
return false;
});
return entries;
}

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { markdownToDoc } from './markdown';
import { docToPlainText } from './plain-text';
describe('docToPlainText (issue #24)', () => {
it('strips formatting and joins blocks with blank lines', () => {
const doc = markdownToDoc('# Title\n\nA **bold** paragraph.\n\n- One\n- Two');
expect(docToPlainText(doc)).toBe('Title\n\nA bold paragraph.\n\nOne\n\nTwo');
});
it('falls back to alt text for images', () => {
const doc = markdownToDoc('![A pond at dusk](file-1)');
expect(docToPlainText(doc)).toBe('A pond at dusk');
});
});

View File

@ -0,0 +1,9 @@
import { Node } from 'prosemirror-model';
/** Search/preview representation (data-model.md `page_content_cache`). */
export function docToPlainText(doc: Node): string {
const text = doc.textBetween(0, doc.content.size, '\n\n', (leaf) =>
leaf.type.name === 'image' ? ((leaf.attrs.alt as string) ?? '') : '',
);
return text.replace(/\n{3,}/g, '\n\n').trim();
}

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { isAllowedLinkHref } from './schema';
describe('isAllowedLinkHref (issue #24, security.md)', () => {
it('allows http(s) and mailto', () => {
expect(isAllowedLinkHref('https://example.org')).toBe(true);
expect(isAllowedLinkHref('http://example.org')).toBe(true);
expect(isAllowedLinkHref('mailto:person@example.org')).toBe(true);
});
it('allows relative links', () => {
expect(isAllowedLinkHref('/some/page')).toBe(true);
});
it('rejects executable schemes', () => {
expect(isAllowedLinkHref('javascript:alert(1)')).toBe(false);
expect(isAllowedLinkHref('data:text/html,evil')).toBe(false);
expect(isAllowedLinkHref('vbscript:evil')).toBe(false);
});
});

View File

@ -0,0 +1,168 @@
import { Schema } from 'prosemirror-model';
import { tableNodes } from 'prosemirror-tables';
/**
* The one ProseMirror schema Dorfteich documents are written in (ADR 0004).
* Editor (TipTap, #25), server-side derivation (this package), and plugin
* validation (ADR 0008) all import this schema instead of defining their
* own, so "valid document" means the same thing everywhere.
*
* Node names `wikilink` and `plugin_block` are reserved for later stories
* (wikilinks, plugin-defined block types) do not repurpose them.
*/
export const editorSchema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: {
group: 'block',
content: 'inline*',
parseDOM: [{ tag: 'p' }],
toDOM: () => ['p', 0],
},
heading: {
group: 'block',
content: 'inline*',
defining: true,
attrs: { level: { default: 1, validate: 'number' } },
parseDOM: [1, 2, 3, 4].map((level) => ({ tag: `h${level}`, attrs: { level } })),
toDOM: (node) => [`h${node.attrs.level as number}`, 0],
},
blockquote: {
group: 'block',
content: 'block+',
parseDOM: [{ tag: 'blockquote' }],
toDOM: () => ['blockquote', 0],
},
code_block: {
group: 'block',
content: 'text*',
marks: '',
code: true,
defining: true,
whitespace: 'pre',
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
toDOM: () => ['pre', ['code', 0]],
},
horizontal_rule: {
group: 'block',
parseDOM: [{ tag: 'hr' }],
toDOM: () => ['hr'],
},
bullet_list: {
group: 'block',
content: 'list_item+',
parseDOM: [{ tag: 'ul' }],
toDOM: () => ['ul', 0],
},
ordered_list: {
group: 'block',
content: 'list_item+',
attrs: { order: { default: 1, validate: 'number' } },
parseDOM: [{ tag: 'ol' }],
toDOM: (node) => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]),
},
list_item: {
content: 'paragraph block*',
parseDOM: [{ tag: 'li' }],
toDOM: () => ['li', 0],
},
task_list: {
group: 'block',
content: 'task_item+',
parseDOM: [{ tag: 'ul[data-type="task_list"]' }],
toDOM: () => ['ul', { 'data-type': 'task_list' }, 0],
},
task_item: {
content: 'paragraph block*',
attrs: { checked: { default: false, validate: 'boolean' } },
parseDOM: [{ tag: 'li[data-type="task_item"]' }],
toDOM: (node) => ['li', { 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) }, 0],
},
text: { group: 'inline' },
hard_break: {
group: 'inline',
inline: true,
selectable: false,
parseDOM: [{ tag: 'br' }],
toDOM: () => ['br'],
},
image: {
group: 'inline',
inline: true,
atom: true,
attrs: {
fileId: { validate: 'string' },
alt: { default: '', validate: 'string' },
width: { default: null },
},
parseDOM: [{ tag: 'img[data-file-id]' }],
toDOM: (node) => [
'img',
{
'data-file-id': node.attrs.fileId as string,
alt: node.attrs.alt as string,
width: node.attrs.width as number | null,
},
],
},
...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }),
},
marks: {
bold: {
parseDOM: [{ tag: 'strong' }, { tag: 'b' }],
toDOM: () => ['strong', 0],
},
italic: {
parseDOM: [{ tag: 'em' }, { tag: 'i' }],
toDOM: () => ['em', 0],
},
code: {
parseDOM: [{ tag: 'code' }],
toDOM: () => ['code', 0],
},
strikethrough: {
parseDOM: [{ tag: 's' }, { tag: 'del' }],
toDOM: () => ['s', 0],
},
link: {
inclusive: false,
attrs: { href: { validate: 'string' } },
parseDOM: [{ tag: 'a[href]' }],
toDOM: (mark) => ['a', { href: mark.attrs.href as string }, 0],
},
},
});
/** Link protocols allowed in editor content (security.md §Content). */
export const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:'] as const;
/** Rejects `javascript:`/`data:`/etc. hrefs; also rejects unparseable input. */
export function isAllowedLinkHref(href: string): boolean {
try {
// A base is only needed to resolve protocol-relative/relative inputs;
// those never carry an executable scheme, so any base works here.
const url = new URL(href, 'https://dorfteich.invalid');
return (ALLOWED_LINK_PROTOCOLS as readonly string[]).includes(url.protocol);
} catch {
return false;
}
}

View File

@ -1,5 +1,6 @@
export * from './api-error'; export * from './api-error';
export * from './auth'; export * from './auth';
export * from './editor-schema';
export * from './env'; export * from './env';
export * from './health'; export * from './health';
export * from './i18n-tools'; export * from './i18n-tools';

148
pnpm-lock.yaml generated
View File

@ -181,10 +181,28 @@ importers:
packages/shared: packages/shared:
dependencies: dependencies:
markdown-it:
specifier: ^14.3.0
version: 14.3.0
prosemirror-markdown:
specifier: ^1.13.4
version: 1.13.4
prosemirror-model:
specifier: ^1.25.9
version: 1.25.9
prosemirror-tables:
specifier: ^1.8.5
version: 1.8.5
zod: zod:
specifier: ^3.24.0 specifier: ^3.24.0
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/node':
specifier: ^26.1.0
version: 26.1.0
tsup: tsup:
specifier: ^8.3.0 specifier: ^8.3.0
version: 8.5.1(@swc/core@1.15.43)(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3) version: 8.5.1(@swc/core@1.15.43)(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)
@ -1457,6 +1475,15 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/methods@1.1.4': '@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@ -2057,6 +2084,10 @@ packages:
resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
error-ex@1.3.4: error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -2507,6 +2538,9 @@ packages:
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
load-esm@1.0.3: load-esm@1.0.3:
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
engines: {node: '>=13.2.0'} engines: {node: '>=13.2.0'}
@ -2549,10 +2583,17 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
markdown-it@14.3.0:
resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
media-typer@0.3.0: media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -2720,6 +2761,9 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
p-limit@3.1.0: p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2868,6 +2912,27 @@ packages:
process-warning@5.0.0: process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.4:
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
prosemirror-model@1.25.9:
resolution: {integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-transform@1.12.0:
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
prosemirror-view@1.42.0:
resolution: {integrity: sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -2875,6 +2940,10 @@ packages:
pump@3.0.4: pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -3345,6 +3414,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.6.4: ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
@ -3514,6 +3586,9 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
watchpack@2.5.2: watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -4610,6 +4685,15 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
'@types/node@26.1.0': '@types/node@26.1.0':
@ -5254,6 +5338,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.3 tapable: 2.3.3
entities@4.5.0: {}
error-ex@1.3.4: error-ex@1.3.4:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@ -5790,6 +5876,10 @@ snapshots:
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
linkify-it@5.0.2:
dependencies:
uc.micro: 2.1.0
load-esm@1.0.3: {} load-esm@1.0.3: {}
load-tsconfig@0.2.5: {} load-tsconfig@0.2.5: {}
@ -5825,8 +5915,19 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
markdown-it@14.3.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.2
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mdurl@2.0.0: {}
media-typer@0.3.0: {} media-typer@0.3.0: {}
media-typer@1.1.0: {} media-typer@1.1.0: {}
@ -5971,6 +6072,8 @@ snapshots:
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wcwidth: 1.0.1 wcwidth: 1.0.1
orderedmap@2.1.1: {}
p-limit@3.1.0: p-limit@3.1.0:
dependencies: dependencies:
yocto-queue: 0.1.0 yocto-queue: 0.1.0
@ -6117,6 +6220,45 @@ snapshots:
process-warning@5.0.0: {} process-warning@5.0.0: {}
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.4:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.3.0
prosemirror-model: 1.25.9
prosemirror-model@1.25.9:
dependencies:
orderedmap: 2.1.1
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.9
prosemirror-transform: 1.12.0
prosemirror-view: 1.42.0
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.42.0
prosemirror-transform@1.12.0:
dependencies:
prosemirror-model: 1.25.9
prosemirror-view@1.42.0:
dependencies:
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@ -6127,6 +6269,8 @@ snapshots:
end-of-stream: 1.4.5 end-of-stream: 1.4.5
once: 1.4.0 once: 1.4.0
punycode.js@2.3.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
pure-rand@6.1.0: {} pure-rand@6.1.0: {}
@ -6599,6 +6743,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
uc.micro@2.1.0: {}
ufo@1.6.4: {} ufo@1.6.4: {}
uid@2.0.2: uid@2.0.2:
@ -6741,6 +6887,8 @@ snapshots:
void-elements@3.1.0: {} void-elements@3.1.0: {}
w3c-keyname@2.2.8: {}
watchpack@2.5.2: watchpack@2.5.2:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11