From da8fef765e3eb35e4e19d92491d089c583099a6f Mon Sep 17 00:00:00 2001 From: d3m1d0v Date: Thu, 21 Nov 2024 12:46:57 +0300 Subject: [PATCH] feat(YfmCut): support directiveSyntax experiment --- demo/defaults/md-plugins.ts | 9 +- package-lock.json | 33 +- package.json | 5 +- src/extensions/yfm/YfmCut/YfmCut.test.ts | 361 +++++++++++++++--- .../yfm/YfmCut/YfmCutSpecs/const.ts | 5 + .../yfm/YfmCut/YfmCutSpecs/index.ts | 24 +- .../yfm/YfmCut/YfmCutSpecs/parser.ts | 8 +- .../yfm/YfmCut/YfmCutSpecs/schema.ts | 12 +- .../yfm/YfmCut/YfmCutSpecs/serializer.ts | 72 ++-- src/markup/codemirror/yfm.ts | 34 +- src/markup/commands/yfm.ts | 15 +- tests/sameMarkup.ts | 8 +- tests/toMatchNode.ts | 10 + 13 files changed, 498 insertions(+), 98 deletions(-) diff --git a/demo/defaults/md-plugins.ts b/demo/defaults/md-plugins.ts index 5d2a1bf4..dceedc7b 100644 --- a/demo/defaults/md-plugins.ts +++ b/demo/defaults/md-plugins.ts @@ -35,11 +35,16 @@ type GetPluginsOptions = { directiveSyntax?: RenderPreviewParams['directiveSyntax']; }; -export function getPlugins(_opts: GetPluginsOptions = {}): markdownit.PluginWithParams[] { +export function getPlugins({ + directiveSyntax, +}: GetPluginsOptions = {}): markdownit.PluginWithParams[] { const defaultPlugins: PluginWithParams[] = [ anchors, code, - yfmCut({bundle: false}), + yfmCut({ + bundle: false, + directiveSyntax: directiveSyntax?.mdPluginValueFor('yfmCut'), + }), deflist, file, (md) => md.use(imsize, {enableInlineStyling: true}), diff --git a/package-lock.json b/package-lock.json index d7776f49..00ee6a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ "tslib": "^2.3.1" }, "devDependencies": { - "@diplodoc/cut-extension": "^0.3.1", + "@diplodoc/cut-extension": "^0.4.0", "@diplodoc/folding-headings-extension": "0.1.0", "@diplodoc/html-extension": "2.3.2", "@diplodoc/latex-extension": "1.0.3", @@ -110,11 +110,12 @@ "sass": "^1.64.1", "sass-loader": "^13.3.2", "stylelint": "15.11.0", + "ts-dedent": "2.2.0", "ts-jest": "^27.0.7", "typescript": "^4.5.2" }, "peerDependencies": { - "@diplodoc/cut-extension": "^0.3.1", + "@diplodoc/cut-extension": "^0.3.1 || ^0.4.0", "@diplodoc/folding-headings-extension": "^0.1.0", "@diplodoc/html-extension": "2.3.2", "@diplodoc/latex-extension": "^1.0.3", @@ -2517,15 +2518,27 @@ } }, "node_modules/@diplodoc/cut-extension": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@diplodoc/cut-extension/-/cut-extension-0.3.1.tgz", - "integrity": "sha512-Em17/XxXm7V8xgaayaMqf0Yek8Rgr3lko6YbX2Z80eDLHjr7wD+C1latPo/HwZ5OS3NUjEuaM+NgIfbU0uUQgA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@diplodoc/cut-extension/-/cut-extension-0.4.0.tgz", + "integrity": "sha512-0H8bNRNkdXBej/gMuitHO+WyQd+vP3mSM4Ixpt2HMta+JzmQYjacFV+uxMa4Tkbzspeo6C6HAwbkvNTmedMRUw==", "dev": true, + "dependencies": { + "@diplodoc/directive": "^0.1.0" + }, "peerDependencies": { "@diplodoc/utils": "1.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@diplodoc/directive": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@diplodoc/directive/-/directive-0.1.0.tgz", + "integrity": "sha512-wQ4RoCoCjbWD9r2YdVPlgTW8+a0JNmsh4OmRLq4EA5PJ1SLjUKqSP9HkHb4LjRINLFtac6SsbXtQ8Y/zUiYazA==", + "dev": true, + "dependencies": { + "markdown-it-directive": "2.0.2" + } + }, "node_modules/@diplodoc/folding-headings-extension": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@diplodoc/folding-headings-extension/-/folding-headings-extension-0.1.0.tgz", @@ -2640,6 +2653,16 @@ } } }, + "node_modules/@diplodoc/transform/node_modules/@diplodoc/cut-extension": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@diplodoc/cut-extension/-/cut-extension-0.3.2.tgz", + "integrity": "sha512-55AgVEIiy3GHorZRht3dm1AcFWdgCMAn8yGV/8qp8sPQ4LssPPuCrVzCVFMn7o8d3KZbEDt5dDj6kI1KtVaWQg==", + "dev": true, + "peerDependencies": { + "@diplodoc/utils": "1.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@diplodoc/transform/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", diff --git a/package.json b/package.json index f32f31dc..806ee467 100644 --- a/package.json +++ b/package.json @@ -200,7 +200,7 @@ "tslib": "^2.3.1" }, "devDependencies": { - "@diplodoc/cut-extension": "^0.3.1", + "@diplodoc/cut-extension": "^0.4.0", "@diplodoc/folding-headings-extension": "0.1.0", "@diplodoc/html-extension": "2.3.2", "@diplodoc/latex-extension": "1.0.3", @@ -258,6 +258,7 @@ "sass": "^1.64.1", "sass-loader": "^13.3.2", "stylelint": "15.11.0", + "ts-dedent": "2.2.0", "ts-jest": "^27.0.7", "typescript": "^4.5.2" }, @@ -282,7 +283,7 @@ } }, "peerDependencies": { - "@diplodoc/cut-extension": "^0.3.1", + "@diplodoc/cut-extension": "^0.3.1 || ^0.4.0", "@diplodoc/folding-headings-extension": "^0.1.0", "@diplodoc/html-extension": "2.3.2", "@diplodoc/latex-extension": "^1.0.3", diff --git a/src/extensions/yfm/YfmCut/YfmCut.test.ts b/src/extensions/yfm/YfmCut/YfmCut.test.ts index be949698..ad711d4e 100644 --- a/src/extensions/yfm/YfmCut/YfmCut.test.ts +++ b/src/extensions/yfm/YfmCut/YfmCut.test.ts @@ -1,8 +1,10 @@ import {builders} from 'prosemirror-test-builder'; +import dd from 'ts-dedent'; import {parseDOM} from '../../../../tests/parse-dom'; import {createMarkupChecker} from '../../../../tests/sameMarkup'; import {ExtensionsManager} from '../../../core'; +import {DirectiveSyntaxContext, type DirectiveSyntaxOption} from '../../../utils/directive'; import {BaseNode, BaseSchemaSpecs} from '../../base/specs'; import { BlockquoteSpecs, @@ -14,21 +16,34 @@ import { italicMarkName, } from '../../markdown/specs'; -import {CutNode, YfmCutSpecs} from './YfmCutSpecs'; - -const { - schema, - markupParser: parser, - serializer, -} = new ExtensionsManager({ - extensions: (builder) => - builder - .use(BaseSchemaSpecs, {}) - .use(ItalicSpecs) - .use(BlockquoteSpecs) - .use(YfmCutSpecs, {}) - .use(ImageSpecs), -}).buildDeps(); +import {CutAttr, CutNode, YfmCutSpecs} from './YfmCutSpecs'; + +class DirectiveContext extends DirectiveSyntaxContext { + setOption(option: DirectiveSyntaxOption | undefined) { + this.option = option; + } +} +const directiveContext = new DirectiveContext(undefined); + +function buildDeps() { + return new ExtensionsManager({ + extensions: (builder) => { + builder.context.set('directiveSyntax', directiveContext); + builder + .use(BaseSchemaSpecs, {}) + .use(ItalicSpecs) + .use(BlockquoteSpecs) + .use(YfmCutSpecs, {}) + .use(ImageSpecs); + }, + }).buildDeps(); +} +function buildCheckers() { + const {markupParser, serializer} = buildDeps(); + return createMarkupChecker({parser: markupParser, serializer}); +} + +const {schema, markupParser: parser, serializer} = buildDeps(); const {doc, p, i, bq, img, cut, cutTitle, cutContent} = builders< 'doc' | 'p' | 'bq' | 'img' | 'cut' | 'cutTitle' | 'cutContent', @@ -48,83 +63,117 @@ const {same} = createMarkupChecker({parser, serializer}); describe('YfmCut extension', () => { it('should parse yfm-cut', () => { - const markup = ` -{% cut "cut title" %} + const markup = dd` + {% cut "cut title" %} -cut content + cut content -cut content 2 + cut content 2 -{% endcut %} -`.trim(); + {% endcut %} + `.trim(); same( markup, - doc(cut(cutTitle('cut title'), cutContent(p('cut content'), p('cut content 2')))), + doc( + cut( + {[CutAttr.Markup]: '{%'}, + cutTitle('cut title'), + cutContent(p('cut content'), p('cut content 2')), + ), + ), ); }); it('should parse nested yfm-cuts', () => { - const markup = ` -{% cut "cut title" %} + const markup = dd` + {% cut "cut title" %} -{% cut "cut title 2" %} + {% cut "cut title 2" %} -cut content + cut content -{% endcut %} + {% endcut %} -{% endcut %} -`.trim(); + {% endcut %} + `.trim(); same( markup, doc( cut( + {[CutAttr.Markup]: '{%'}, cutTitle('cut title'), - cutContent(cut(cutTitle('cut title 2'), cutContent(p('cut content')))), + cutContent( + cut( + {[CutAttr.Markup]: '{%'}, + cutTitle('cut title 2'), + cutContent(p('cut content')), + ), + ), ), ), ); }); it('should parse yfm-cut under blockquote', () => { - const markup = ` -> {% cut "cut title" %} -> -> cut content -> -> {% endcut %} -`.trim(); - - same(markup, doc(bq(cut(cutTitle('cut title'), cutContent(p('cut content')))))); + const markup = dd` + > {% cut "cut title" %} + > + > cut content + > + > {% endcut %} + `.trim(); + + same( + markup, + doc( + bq( + cut( + {[CutAttr.Markup]: '{%'}, + cutTitle('cut title'), + cutContent(p('cut content')), + ), + ), + ), + ); }); it('should parse yfm-cut with inline markup in cut title', () => { - const markup = ` -{% cut "*cut italic title*" %} + const markup = dd` + {% cut "*cut italic title*" %} -cut content + cut content -{% endcut %} - `.trim(); + {% endcut %} + `.trim(); - same(markup, doc(cut(cutTitle(i('cut italic title')), cutContent(p('cut content'))))); + same( + markup, + doc( + cut( + {[CutAttr.Markup]: '{%'}, + cutTitle(i('cut italic title')), + cutContent(p('cut content')), + ), + ), + ); }); it('should parse yfm-cut with inline node in cut title', () => { - const markup = ` -{% cut "![img](path/to/img)" %} + const markup = dd` + {% cut "![img](path/to/img)" %} -cut content + cut content -{% endcut %} - `.trim(); + {% endcut %} + `.trim(); same( markup, doc( cut( + {[CutAttr.Markup]: '{%'}, cutTitle( img({ [ImageAttr.Src]: 'path/to/img', @@ -137,7 +186,7 @@ cut content ); }); - it('should parse yfm-note from html', () => { + it('should parse yfm-cut from html', () => { parseDOM( schema, '
' + @@ -147,4 +196,214 @@ cut content doc(cut(cutTitle('YfmCut title'), cutContent(p('YfmCut content')))), ); }); + + it('should parse yfm-cut with markup attr from html', () => { + parseDOM( + schema, + '
' + + '
YfmCut title
' + + '

YfmCut content

', + doc( + cut( + {[CutAttr.Markup]: '{%'}, + cutTitle('YfmCut title'), + cutContent(p('YfmCut content')), + ), + ), + ); + }); + + describe('Directive syntax', () => { + afterAll(() => { + directiveContext.setOption(undefined); + }); + + const PM_DOC = { + CurlyCut: doc( + cut({[CutAttr.Markup]: '{%'}, cutTitle('title'), cutContent(p('content'))), + ), + UnknownCut: doc(cut(cutTitle('title'), cutContent(p('content')))), + DirectiveCut: doc( + cut({[CutAttr.Markup]: ':::cut'}, cutTitle('title'), cutContent(p('content'))), + ), + }; + const MARKUP = { + CurlySyntax: dd` + {% cut "title" %} + + content + + {% endcut %} + `, + + DirectiveSyntax: dd` + :::cut [title] + content + + ::: + `, + }; + + describe('directiveSyntax:disabled', () => { + beforeAll(() => { + directiveContext.setOption('disabled'); + }); + + it('should parse curly cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.CurlySyntax, PM_DOC.CurlyCut, {json: true}); + }); + + it('should not parse directive cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.DirectiveSyntax, doc(p(':::cut [title]\ncontent'), p(':::')), { + json: true, + }); + }); + + it('should preserve curly cut syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.CurlyCut, MARKUP.CurlySyntax); + }); + + it('should serialize cut with unknown markup to curly syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.UnknownCut, MARKUP.CurlySyntax); + }); + + it('should preserve directive cut to curly syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.DirectiveCut, MARKUP.DirectiveSyntax); + }); + }); + + describe('directiveSyntax:enabled', () => { + beforeAll(() => { + directiveContext.setOption('enabled'); + }); + + it('should parse curly cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.CurlySyntax, PM_DOC.CurlyCut, {json: true}); + }); + + it('should parse directive cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.DirectiveSyntax, PM_DOC.DirectiveCut, {json: true}); + }); + + it('should preserve curly cut syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.CurlyCut, MARKUP.CurlySyntax); + }); + + it('should serialize cut with unknown markup to curly syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.UnknownCut, MARKUP.CurlySyntax); + }); + + it('should preserve directive cut syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.DirectiveCut, MARKUP.DirectiveSyntax); + }); + }); + + describe('directiveSyntax:preserve', () => { + beforeAll(() => { + directiveContext.setOption('preserve'); + }); + + it('should parse curly cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.CurlySyntax, PM_DOC.CurlyCut, {json: true}); + }); + + it('should parse directive cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.DirectiveSyntax, PM_DOC.DirectiveCut, {json: true}); + }); + + it('should preserve curly cut syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.CurlyCut, MARKUP.CurlySyntax); + }); + + it('should serialize cut with unknown markup to directive syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.UnknownCut, MARKUP.DirectiveSyntax); + }); + + it('should preserve directive cut syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.DirectiveCut, MARKUP.DirectiveSyntax); + }); + }); + + describe('directiveSyntax:overwrite', () => { + beforeAll(() => { + directiveContext.setOption('overwrite'); + }); + + it('should parse curly cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.CurlySyntax, PM_DOC.CurlyCut, {json: true}); + }); + + it('should parse directive cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.DirectiveSyntax, PM_DOC.DirectiveCut, {json: true}); + }); + + it('should overwrite curly cut to directive syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.CurlyCut, MARKUP.DirectiveSyntax); + }); + + it('should serialize cut with unknown markup to directive syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.UnknownCut, MARKUP.DirectiveSyntax); + }); + + it('should preserve directive cut syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.DirectiveCut, MARKUP.DirectiveSyntax); + }); + }); + + describe('directiveSyntax:only', () => { + beforeAll(() => { + directiveContext.setOption('only'); + }); + + it('should not parse curly cut syntax', () => { + const {parse} = buildCheckers(); + parse( + MARKUP.CurlySyntax, + doc(p('{% cut "title" %}'), p('content'), p('{% endcut %}')), + {json: true}, + ); + }); + + it('should parse directive cut syntax', () => { + const {parse} = buildCheckers(); + parse(MARKUP.DirectiveSyntax, PM_DOC.DirectiveCut, {json: true}); + }); + + it('should overwrite curly cut to directive syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.CurlyCut, MARKUP.DirectiveSyntax); + }); + + it('should serialize cut with unknown markup to directive syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.UnknownCut, MARKUP.DirectiveSyntax); + }); + + it('should preserve directive cut syntax', () => { + const {serialize} = buildCheckers(); + serialize(PM_DOC.DirectiveCut, MARKUP.DirectiveSyntax); + }); + }); + }); }); diff --git a/src/extensions/yfm/YfmCut/YfmCutSpecs/const.ts b/src/extensions/yfm/YfmCut/YfmCutSpecs/const.ts index cbe301d4..2099408e 100644 --- a/src/extensions/yfm/YfmCut/YfmCutSpecs/const.ts +++ b/src/extensions/yfm/YfmCut/YfmCutSpecs/const.ts @@ -6,6 +6,11 @@ export enum CutNode { CutContent = 'yfm_cut_content', } +export enum CutAttr { + Class = 'class', + Markup = 'data-markup', +} + export const cutType = nodeTypeFactory(CutNode.Cut); export const cutTitleType = nodeTypeFactory(CutNode.CutTitle); export const cutContentType = nodeTypeFactory(CutNode.CutContent); diff --git a/src/extensions/yfm/YfmCut/YfmCutSpecs/index.ts b/src/extensions/yfm/YfmCut/YfmCutSpecs/index.ts index 80886df9..8367d1b4 100644 --- a/src/extensions/yfm/YfmCut/YfmCutSpecs/index.ts +++ b/src/extensions/yfm/YfmCut/YfmCutSpecs/index.ts @@ -6,9 +6,18 @@ import type {ExtensionAuto, ExtensionNodeSpec} from '../../../../core'; import {CutNode} from './const'; import {parserTokens} from './parser'; import {getSchemaSpecs} from './schema'; -import {serializerTokens} from './serializer'; +import {getSerializerTokens} from './serializer'; -export {CutNode, cutType, cutTitleType, cutContentType} from './const'; +export {CutAttr, CutNode, cutType, cutTitleType, cutContentType} from './const'; + +declare global { + namespace MarkdownEditor { + interface DirectiveSyntaxAdditionalSupportedExtensions { + // Mark in global types that YfmCut has support for directive syntax + yfmCut: true; + } + } +} export type YfmCutSpecsOptions = { cutView?: ExtensionNodeSpec['view']; @@ -26,9 +35,18 @@ export type YfmCutSpecsOptions = { export const YfmCutSpecs: ExtensionAuto = (builder, opts) => { const schemaSpecs = getSchemaSpecs(opts, builder.context.get('placeholder')); + const directiveSyntax = builder.context.get('directiveSyntax'); + const serializerTokens = getSerializerTokens({directiveSyntax}); builder - .configureMd((md) => md.use(yfmCut({bundle: false}))) + .configureMd((md) => + md.use( + yfmCut({ + bundle: false, + directiveSyntax: directiveSyntax?.mdPluginValueFor('yfmCut'), + }), + ), + ) .addNode(CutNode.Cut, () => ({ spec: schemaSpecs[CutNode.Cut], toMd: serializerTokens[CutNode.Cut], diff --git a/src/extensions/yfm/YfmCut/YfmCutSpecs/parser.ts b/src/extensions/yfm/YfmCut/YfmCutSpecs/parser.ts index 883cac3f..fc4ab683 100644 --- a/src/extensions/yfm/YfmCut/YfmCutSpecs/parser.ts +++ b/src/extensions/yfm/YfmCut/YfmCutSpecs/parser.ts @@ -1,8 +1,12 @@ import type {ParserToken} from '../../../../core'; -import {CutNode} from './const'; +import {CutAttr, CutNode} from './const'; -const getAttrs: ParserToken['getAttrs'] = (tok) => (tok.attrs ? Object.fromEntries(tok.attrs) : {}); +const getAttrs: ParserToken['getAttrs'] = (tok) => { + const nodeAttrs = tok.attrs ? Object.fromEntries(tok.attrs) : {}; + nodeAttrs[CutAttr.Markup] = tok.markup; + return nodeAttrs; +}; export const parserTokens: Record = { [CutNode.Cut]: {name: CutNode.Cut, type: 'block', getAttrs}, diff --git a/src/extensions/yfm/YfmCut/YfmCutSpecs/schema.ts b/src/extensions/yfm/YfmCut/YfmCutSpecs/schema.ts index 3789f1ac..018bff13 100644 --- a/src/extensions/yfm/YfmCut/YfmCutSpecs/schema.ts +++ b/src/extensions/yfm/YfmCut/YfmCutSpecs/schema.ts @@ -1,7 +1,8 @@ import type {NodeSpec} from 'prosemirror-model'; import type {PlaceholderOptions} from '../../../../utils/placeholder'; -import {CutNode} from '../const'; + +import {CutAttr, CutNode} from './const'; import type {YfmCutSpecsOptions} from './index'; @@ -15,10 +16,15 @@ export const getSchemaSpecs = ( placeholder?: PlaceholderOptions, ): Record => ({ [CutNode.Cut]: { - attrs: {class: {default: 'yfm-cut'}}, + attrs: {class: {default: 'yfm-cut'}, [CutAttr.Markup]: {default: null}}, content: `${CutNode.CutTitle} ${CutNode.CutContent}`, group: 'block yfm-cut', - parseDOM: [{tag: '.yfm-cut'}], + parseDOM: [ + { + tag: '.yfm-cut', + getAttrs: (node) => ({[CutAttr.Markup]: node.getAttribute(CutAttr.Markup)}), + }, + ], toDOM(node) { return ['div', node.attrs, 0]; }, diff --git a/src/extensions/yfm/YfmCut/YfmCutSpecs/serializer.ts b/src/extensions/yfm/YfmCut/YfmCutSpecs/serializer.ts index 54185024..b8071c18 100644 --- a/src/extensions/yfm/YfmCut/YfmCutSpecs/serializer.ts +++ b/src/extensions/yfm/YfmCut/YfmCutSpecs/serializer.ts @@ -1,27 +1,53 @@ +import type {Node} from 'prosemirror-model'; + import type {SerializerNodeToken} from '../../../../core'; import {isNodeEmpty} from '../../../../utils/nodes'; import {getPlaceholderContent} from '../../../../utils/placeholder'; -import {CutNode} from './const'; - -export const serializerTokens: Record = { - [CutNode.Cut]: (state, node) => { - state.renderContent(node); - state.write('{% endcut %}'); - state.closeBlock(node); - }, - - [CutNode.CutTitle]: (state, node) => { - state.write('{% cut "'); - if (node.nodeSize > 2) state.renderInline(node); - else state.write(getPlaceholderContent(node)); - state.write('" %}\n'); - state.write('\n'); - state.closeBlock(); - }, - - [CutNode.CutContent]: (state, node) => { - if (!isNodeEmpty(node)) state.renderInline(node); - else state.write(getPlaceholderContent(node) + '\n\n'); - }, -}; +import {CutAttr, CutNode} from './const'; + +export function getSerializerTokens({ + directiveSyntax, +}: { + directiveSyntax?: WysiwygEditor.Context['directiveSyntax']; +}): Record { + const isDirectiveCut = (node: Node): boolean | undefined => { + return directiveSyntax?.shouldSerializeToDirective('yfmCut', node.attrs[CutAttr.Markup]); + }; + + return { + [CutNode.Cut]: (state, node) => { + state.renderContent(node); + state.write(isDirectiveCut(node) ? ':::' : '{% endcut %}'); + state.closeBlock(node); + }, + + [CutNode.CutTitle]: (state, node, parent) => { + if (isDirectiveCut(parent)) { + state.write(':::cut ['); + state.renderInline(node); + state.write(']'); + state.ensureNewLine(); + state.closeBlock(); + return; + } + + state.write('{% cut "'); + if (node.nodeSize > 2) state.renderInline(node); + else state.write(getPlaceholderContent(node)); + state.write('" %}\n'); + state.write('\n'); + state.closeBlock(); + }, + + [CutNode.CutContent]: (state, node, parent) => { + if (isDirectiveCut(parent)) { + state.renderContent(node); + return; + } + + if (!isNodeEmpty(node)) state.renderInline(node); + else state.write(getPlaceholderContent(node) + '\n\n'); + }, + }; +} diff --git a/src/markup/codemirror/yfm.ts b/src/markup/codemirror/yfm.ts index 6e2aa1a0..d6259cbf 100644 --- a/src/markup/codemirror/yfm.ts +++ b/src/markup/codemirror/yfm.ts @@ -6,6 +6,8 @@ import type {DelimiterType, MarkdownConfig} from '@lezer/markdown'; import {capitalize} from '../../lodash'; +import {DirectiveSyntaxFacet} from './directive-facet'; + export const customTags = { underline: Tag.define(), monospace: Tag.define(), @@ -76,6 +78,9 @@ export const yfmNoteSnippets: Record> = export const yfmCutSnippetTemplate = '{% cut "#{title}" %}\n\n#{}\n\n{% endcut %}\n\n'; export const yfmCutSnippet = snippet(yfmCutSnippetTemplate); +export const yfmCutDirectiveSnippetTemplate = ':::cut [#{title}]\n#{}\n:::\n\n'; +export const yfmCutDirectiveSnippet = snippet(yfmCutDirectiveSnippetTemplate); + export interface LanguageData { autocomplete: CompletionSource; [key: string]: any; @@ -87,6 +92,8 @@ export interface YfmLangOptions { const mdAutocomplete: LanguageData = { autocomplete: (context) => { + const directiveContext = context.state.facet(DirectiveSyntaxFacet); + // TODO: add more actions and re-enable // let word = context.matchBefore(/\/.*/); // if (word) { @@ -104,13 +111,16 @@ const mdAutocomplete: LanguageData = { // label: '/yfm cut', // displayLabel: 'YFM Cut', // type: 'text', - // apply: yfmCutSnippet, + // apply: directiveFacet.shouldInsertDirectiveMarkup('yfmCut') + // ? yfmCutDirectiveSnippet + // : yfmCutSnippet, // }, // ], // }; // } + const word = context.matchBefore(/^.*/); - if (word?.text.startsWith('{%')) { + if (directiveContext.option !== 'only' && word?.text.startsWith('{%')) { return { from: word.from, options: [ @@ -126,11 +136,29 @@ const mdAutocomplete: LanguageData = { label: '{% cut', displayLabel: 'YFM Cut', type: 'text', - apply: yfmCutSnippet, + apply: directiveContext.shouldInsertDirectiveMarkup('yfmCut') + ? yfmCutDirectiveSnippet + : yfmCutSnippet, }, ], }; } + if (directiveContext.option !== 'disabled' && word?.text.startsWith(':')) { + const options: Completion[] = []; + + if (directiveContext.valueFor('yfmCut') !== 'disabled') { + options.push({ + label: ':::cut', + displayLabel: 'YFM Cut', + type: 'text', + apply: yfmCutDirectiveSnippet, + }); + } + + if (options.length) { + return {from: word.from, options}; + } + } return null; }, }; diff --git a/src/markup/commands/yfm.ts b/src/markup/commands/yfm.ts index b8a7e890..a1902955 100644 --- a/src/markup/commands/yfm.ts +++ b/src/markup/commands/yfm.ts @@ -1,11 +1,24 @@ import type {StateCommand} from '@codemirror/state'; +import {DirectiveSyntaxFacet} from '../codemirror/directive-facet'; + import {wrapToBlock} from './helpers'; -export const wrapToYfmCut: StateCommand = wrapToBlock( +const wrapToYfmCutCurly: StateCommand = wrapToBlock( ({lineBreak}) => '{% cut "title" %}' + lineBreak.repeat(2), ({lineBreak}) => lineBreak.repeat(2) + '{% endcut %}', ); +const wrapToYfmCutDirective: StateCommand = wrapToBlock( + ({lineBreak}) => ':::cut [title]' + lineBreak, + ({lineBreak}) => lineBreak + ':::', +); + +export const wrapToYfmCut: StateCommand = (target) => { + const cmd = target.state.facet(DirectiveSyntaxFacet).shouldInsertDirectiveMarkup('yfmCut') + ? wrapToYfmCutDirective + : wrapToYfmCutCurly; + return cmd(target); +}; export const wrapToYfmNote = wrapToBlock( ({lineBreak}) => '{% note info %}' + lineBreak.repeat(2), diff --git a/tests/sameMarkup.ts b/tests/sameMarkup.ts index fb6b1c6e..02c67d6f 100644 --- a/tests/sameMarkup.ts +++ b/tests/sameMarkup.ts @@ -1,7 +1,8 @@ /* eslint-disable no-implicit-globals */ import type {Node} from 'prosemirror-model'; -import {Parser, Serializer} from '../src/core'; + +import type {Parser, Serializer} from '../src/core'; export function createMarkupChecker({ parser, @@ -10,8 +11,9 @@ export function createMarkupChecker({ parser: Parser; serializer: Serializer; }) { - function parse(text: string, doc: Node) { - expect(parser.parse(text)).toMatchNode(doc); + function parse(text: string, doc: Node, {json}: {json?: boolean} = {}) { + if (json) expect(parser.parse(text)).toMatchNodeJson(doc); + else expect(parser.parse(text)).toMatchNode(doc); } function serialize(doc: Node, text: string) { diff --git a/tests/toMatchNode.ts b/tests/toMatchNode.ts index 1a4bceeb..e4d5a457 100644 --- a/tests/toMatchNode.ts +++ b/tests/toMatchNode.ts @@ -15,12 +15,22 @@ expect.extend({ pass: eq(received, expect), }; }, + toMatchNodeJson: (received: Node, expect: Node) => { + return { + message: () => + `nodes do not match.\n\nreceived: ${received}\n\tjson: ${toJson( + received, + )}\n\nexpect: ${expect}\n\tjson: ${toJson(expect)}`, + pass: toJson(received) === toJson(expect), + }; + }, }); declare global { namespace jest { interface Matchers { toMatchNode(expect: Node): R; + toMatchNodeJson(expect: Node): R; } } }