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