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"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
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 './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
148
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user