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

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:
Claude Fable 5 2026-07-11 10:00:39 +02:00
parent 48798d4247
commit 2e96173d44
4 changed files with 155 additions and 0 deletions

View File

@ -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':

View File

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

View File

@ -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]}\`` : '```';

View File

@ -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*',