diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts
index 00d244f..73bfd4d 100644
--- a/packages/shared/src/editor-schema/html.ts
+++ b/packages/shared/src/editor-schema/html.ts
@@ -106,6 +106,14 @@ function renderBlock(node: Node): string {
}
case 'blockquote':
return `
${renderBlocks(node)}
`;
+ case 'section': {
+ // Section-style container (issue #75): scoped class so the plugin's
+ // sanitized CSS (`.dt-style--`) 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 `${renderBlocks(node)}
`;
+ }
case 'code_block':
return `${escapeHtml(node.textContent)}
`;
case 'horizontal_rule':
diff --git a/packages/shared/src/editor-schema/markdown.test.ts b/packages/shared/src/editor-schema/markdown.test.ts
index 6de5977..9e53936 100644
--- a/packages/shared/src/editor-schema/markdown.test.ts
+++ b/packages/shared/src/editor-schema/markdown.test.ts
@@ -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');
+ });
});
diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts
index 71bca8f..381cfc3 100644
--- a/packages/shared/src/editor-schema/markdown.ts
+++ b/packages/shared/src/editor-schema/markdown.ts
@@ -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="/"}` 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]}\`` : '```';
diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts
index 77d6c16..e815eb1 100644
--- a/packages/shared/src/editor-schema/schema.ts
+++ b/packages/shared/src/editor-schema/schema.ts
@@ -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--`. 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*',