Add the section container node to the document model (#75, part 1)
Some checks failed
CD / Build and push images (push) Successful in 3m15s
CD / Deploy to Test (push) Successful in 9s
CI / Import/export fidelity gate (push) Successful in 47s
CI / Auth e2e pack (push) Successful in 4m12s
CD / Smoke tests against Test (push) Successful in 1m4s
CD / Promote to Int (push) Successful in 10s
CI / Build container images (push) Has been skipped
CI / Warm action cache (push) Successful in 10s
CI / Lint, typecheck, test (push) Failing after 1m27s
Some checks failed
CD / Build and push images (push) Successful in 3m15s
CD / Deploy to Test (push) Successful in 9s
CI / Import/export fidelity gate (push) Successful in 47s
CI / Auth e2e pack (push) Successful in 4m12s
CD / Smoke tests against Test (push) Successful in 1m4s
CD / Promote to Int (push) Successful in 10s
CI / Build container images (push) Has been skipped
CI / Warm action cache (push) Successful in 10s
CI / Lint, typecheck, test (push) Failing after 1m27s
Document-model foundation for section-style plugins (ADR 0008 kind
section_style): a `section` block node carrying `pluginId` + `styleId`,
rendered as `<div class="dt-section dt-style-<pluginId>-<styleId>">` so a
plugin's sanitized, scoped CSS applies and — when the plugin is gone —
the class matches nothing and the content stays intact with neutral
styling. Round-trips to a Pandoc-style fenced div
(`::: {data-section-style="<pluginId>/<styleId>"}`), with a markdown-it
block rule that supports nesting.
This is the schema/markdown/HTML half of #75; the editor UI (wrap/style
picker/unwrap), the `section-styles-basic` reference plugin, per-pond CSS
injection, and PDF/office export styling follow in part 2. The node is
additive and backward-compatible (existing documents have none).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
48798d4247
commit
2e96173d44
@ -106,6 +106,14 @@ function renderBlock(node: Node): string {
|
||||
}
|
||||
case 'blockquote':
|
||||
return `<blockquote>${renderBlocks(node)}</blockquote>`;
|
||||
case 'section': {
|
||||
// Section-style container (issue #75): scoped class so the plugin's
|
||||
// sanitized CSS (`.dt-style-<pluginId>-<styleId>`) applies; when the
|
||||
// plugin is gone the class matches nothing and the content stays plain.
|
||||
const pluginId = escapeHtml(node.attrs.pluginId as string);
|
||||
const styleId = escapeHtml(node.attrs.styleId as string);
|
||||
return `<div class="dt-section dt-style-${pluginId}-${styleId}">${renderBlocks(node)}</div>`;
|
||||
}
|
||||
case 'code_block':
|
||||
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
|
||||
case 'horizontal_rule':
|
||||
|
||||
@ -83,4 +83,40 @@ describe('markdown round-trip (issue #24)', () => {
|
||||
});
|
||||
expect(hasWikilink).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips a section-style container as a fenced div (issue #75)', () => {
|
||||
const md = [
|
||||
'::: {data-section-style="section-styles-basic/callout"}',
|
||||
'Inside the callout.',
|
||||
'',
|
||||
'Second paragraph.',
|
||||
':::',
|
||||
].join('\n');
|
||||
const doc = markdownToDoc(md);
|
||||
const section = doc.firstChild;
|
||||
expect(section?.type.name).toBe('section');
|
||||
expect(section?.attrs).toMatchObject({
|
||||
pluginId: 'section-styles-basic',
|
||||
styleId: 'callout',
|
||||
});
|
||||
expect(section?.childCount).toBe(2); // two paragraphs preserved
|
||||
// Serialization is idempotent: a second pass reproduces the first exactly.
|
||||
const once = docToMarkdown(doc);
|
||||
expect(docToMarkdown(markdownToDoc(once))).toBe(once);
|
||||
expect(once).toContain('data-section-style="section-styles-basic/callout"');
|
||||
});
|
||||
|
||||
it('handles nested section containers (issue #75)', () => {
|
||||
const md = [
|
||||
'::: {data-section-style="p/outer"}',
|
||||
'::: {data-section-style="p/inner"}',
|
||||
'Deep.',
|
||||
':::',
|
||||
':::',
|
||||
].join('\n');
|
||||
const outer = markdownToDoc(md).firstChild;
|
||||
expect(outer?.attrs.styleId).toBe('outer');
|
||||
expect(outer?.firstChild?.type.name).toBe('section');
|
||||
expect(outer?.firstChild?.attrs.styleId).toBe('inner');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs';
|
||||
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs';
|
||||
import Token from 'markdown-it/lib/token.mjs';
|
||||
import { Mark, Node } from 'prosemirror-model';
|
||||
@ -163,10 +164,64 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Opening fence of a section-style container: `::: {data-section-style="p/s"}`. */
|
||||
const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/;
|
||||
const SECTION_CLOSE = /^:::+\s*$/;
|
||||
|
||||
/**
|
||||
* Block rule for section-style containers (issue #75). Recognises a Pandoc-style
|
||||
* fenced div opened by `::: {data-section-style="<pluginId>/<styleId>"}` and
|
||||
* closed by a bare `:::`, tokenising the body as normal block content in
|
||||
* between. Registered before `fence` so the `:::` marker is not mistaken for a
|
||||
* code fence.
|
||||
*/
|
||||
function sectionRule(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
silent: boolean,
|
||||
): boolean {
|
||||
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
|
||||
const max = state.eMarks[startLine]!;
|
||||
const open = SECTION_OPEN.exec(state.src.slice(start, max));
|
||||
if (!open) return false;
|
||||
if (silent) return true;
|
||||
|
||||
// Find the matching closing fence, honouring nested sections.
|
||||
let depth = 1;
|
||||
let nextLine = startLine;
|
||||
for (nextLine = startLine + 1; nextLine < endLine; nextLine += 1) {
|
||||
const lineStart = state.bMarks[nextLine]! + state.tShift[nextLine]!;
|
||||
const lineMax = state.eMarks[nextLine]!;
|
||||
const text = state.src.slice(lineStart, lineMax);
|
||||
if (SECTION_OPEN.test(text)) depth += 1;
|
||||
else if (SECTION_CLOSE.test(text)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
const openToken = state.push('section_open', 'div', 1);
|
||||
openToken.attrSet('pluginId', open[1]!);
|
||||
openToken.attrSet('styleId', open[2]!);
|
||||
openToken.map = [startLine, nextLine];
|
||||
|
||||
const oldLineMax = state.lineMax;
|
||||
state.lineMax = nextLine;
|
||||
state.md.block.tokenize(state, startLine + 1, nextLine);
|
||||
state.lineMax = oldLineMax;
|
||||
|
||||
state.push('section_close', 'div', -1);
|
||||
state.line = nextLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function createTokenizer(): MarkdownIt {
|
||||
const md = new MarkdownIt('default', { html: false });
|
||||
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
||||
// Run before `fence` so `:::` is not read as a code fence.
|
||||
md.block.ruler.before('fence', 'section', sectionRule);
|
||||
const rawParse = md.parse.bind(md);
|
||||
md.parse = (src, env) => transformTokens(rawParse(src, env));
|
||||
return md;
|
||||
@ -174,6 +229,13 @@ function createTokenizer(): MarkdownIt {
|
||||
|
||||
const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
||||
blockquote: { block: 'blockquote' },
|
||||
section: {
|
||||
block: 'section',
|
||||
getAttrs: (tok) => ({
|
||||
pluginId: tok.attrGet('pluginId') ?? '',
|
||||
styleId: tok.attrGet('styleId') ?? '',
|
||||
}),
|
||||
},
|
||||
paragraph: { block: 'paragraph' },
|
||||
list_item: { block: 'list_item' },
|
||||
task_item: {
|
||||
@ -261,6 +323,16 @@ const markdownSerializer = new MarkdownSerializer(
|
||||
blockquote(state, node) {
|
||||
state.wrapBlock('> ', null, node, () => state.renderContent(node));
|
||||
},
|
||||
section(state, node) {
|
||||
// Pandoc-style fenced div with an unambiguous data attribute (the `/`
|
||||
// separates the two slugs cleanly, unlike the hyphenated CSS class).
|
||||
const pluginId = node.attrs.pluginId as string;
|
||||
const styleId = node.attrs.styleId as string;
|
||||
state.write(`::: {data-section-style="${pluginId}/${styleId}"}\n`);
|
||||
state.renderContent(node);
|
||||
state.write(':::');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
code_block(state, node) {
|
||||
const backticks = node.textContent.match(/`{3,}/gm);
|
||||
const fence = backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```';
|
||||
|
||||
@ -37,6 +37,45 @@ export const editorSchema = new Schema({
|
||||
toDOM: () => ['blockquote', 0],
|
||||
},
|
||||
|
||||
// A styled container for section-style plugins (ADR 0008 kind
|
||||
// `section_style`, issue #75). It wraps block content and carries the
|
||||
// owning plugin + style ids; the applied CSS is the plugin's sanitized,
|
||||
// scoped stylesheet under `.dt-style-<pluginId>-<styleId>`. When the
|
||||
// plugin is disabled the class simply resolves to nothing, so the content
|
||||
// stays intact with neutral styling.
|
||||
section: {
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
attrs: {
|
||||
pluginId: { default: '', validate: 'string' },
|
||||
styleId: { default: '', validate: 'string' },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-section-style]',
|
||||
getAttrs: (dom) => {
|
||||
const [pluginId = '', styleId = ''] = (
|
||||
dom.getAttribute('data-section-style') ?? ''
|
||||
).split('/');
|
||||
return { pluginId, styleId };
|
||||
},
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const pluginId = node.attrs.pluginId as string;
|
||||
const styleId = node.attrs.styleId as string;
|
||||
return [
|
||||
'div',
|
||||
{
|
||||
'data-section-style': `${pluginId}/${styleId}`,
|
||||
class: `dt-section dt-style-${pluginId}-${styleId}`,
|
||||
},
|
||||
0,
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
code_block: {
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user