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
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:
parent
4dd452c0af
commit
b89aa6bed0
@ -25,9 +25,15 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^26.1.0",
|
||||
"tsup": "^8.3.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
48
packages/shared/src/editor-schema/html.test.ts
Normal file
48
packages/shared/src/editor-schema/html.test.ts
Normal 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('<script>');
|
||||
expect(html).toContain('"x"');
|
||||
});
|
||||
|
||||
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>');
|
||||
});
|
||||
});
|
||||
130
packages/shared/src/editor-schema/html.ts
Normal file
130
packages/shared/src/editor-schema/html.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
5
packages/shared/src/editor-schema/index.ts
Normal file
5
packages/shared/src/editor-schema/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './schema';
|
||||
export * from './markdown';
|
||||
export * from './html';
|
||||
export * from './plain-text';
|
||||
export * from './outline';
|
||||
62
packages/shared/src/editor-schema/markdown.test.ts
Normal file
62
packages/shared/src/editor-schema/markdown.test.ts
Normal 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: '',
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
315
packages/shared/src/editor-schema/markdown.ts
Normal file
315
packages/shared/src/editor-schema/markdown.ts
Normal 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(``);
|
||||
},
|
||||
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 });
|
||||
}
|
||||
23
packages/shared/src/editor-schema/outline.test.ts
Normal file
23
packages/shared/src/editor-schema/outline.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
31
packages/shared/src/editor-schema/outline.ts
Normal file
31
packages/shared/src/editor-schema/outline.ts
Normal 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;
|
||||
}
|
||||
16
packages/shared/src/editor-schema/plain-text.test.ts
Normal file
16
packages/shared/src/editor-schema/plain-text.test.ts
Normal 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('');
|
||||
expect(docToPlainText(doc)).toBe('A pond at dusk');
|
||||
});
|
||||
});
|
||||
9
packages/shared/src/editor-schema/plain-text.ts
Normal file
9
packages/shared/src/editor-schema/plain-text.ts
Normal 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();
|
||||
}
|
||||
21
packages/shared/src/editor-schema/schema.test.ts
Normal file
21
packages/shared/src/editor-schema/schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
168
packages/shared/src/editor-schema/schema.ts
Normal file
168
packages/shared/src/editor-schema/schema.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
export * from './api-error';
|
||||
export * from './auth';
|
||||
export * from './editor-schema';
|
||||
export * from './env';
|
||||
export * from './health';
|
||||
export * from './i18n-tools';
|
||||
|
||||
148
pnpm-lock.yaml
generated
148
pnpm-lock.yaml
generated
@ -181,10 +181,28 @@ importers:
|
||||
|
||||
packages/shared:
|
||||
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:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
'@types/node':
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
tsup:
|
||||
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)
|
||||
@ -1457,6 +1475,15 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
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':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
@ -2057,6 +2084,10 @@ packages:
|
||||
resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@ -2507,6 +2538,9 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
|
||||
|
||||
load-esm@1.0.3:
|
||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||
engines: {node: '>=13.2.0'}
|
||||
@ -2549,10 +2583,17 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
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:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@ -2720,6 +2761,9 @@ packages:
|
||||
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -2868,6 +2912,27 @@ packages:
|
||||
process-warning@5.0.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@ -2875,6 +2940,10 @@ packages:
|
||||
pump@3.0.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@ -3345,6 +3414,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
ufo@1.6.4:
|
||||
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
||||
|
||||
@ -3514,6 +3586,9 @@ packages:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
watchpack@2.5.2:
|
||||
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@ -4610,6 +4685,15 @@ snapshots:
|
||||
|
||||
'@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/node@26.1.0':
|
||||
@ -5254,6 +5338,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@ -5790,6 +5876,10 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
|
||||
load-tsconfig@0.2.5: {}
|
||||
@ -5825,8 +5915,19 @@ snapshots:
|
||||
dependencies:
|
||||
'@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: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
@ -5971,6 +6072,8 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wcwidth: 1.0.1
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
@ -6117,6 +6220,45 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@ -6127,6 +6269,8 @@ snapshots:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
@ -6599,6 +6743,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
ufo@1.6.4: {}
|
||||
|
||||
uid@2.0.2:
|
||||
@ -6741,6 +6887,8 @@ snapshots:
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
watchpack@2.5.2:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
Loading…
Reference in New Issue
Block a user