diff --git a/src/rich-text/schema.ts b/src/rich-text/schema.ts index 7a8d2409..a639be09 100644 --- a/src/rich-text/schema.ts +++ b/src/rich-text/schema.ts @@ -223,6 +223,30 @@ const nodes: { pre: genHtmlBlockNodeSpec("pre"), + /** + * Defines an uneditable html_comment node; Only appears when a user has written an html comment block + * i.e. `` or `` but not ` other text` + */ + html_comment: { + content: "text*", + attrs: { content: { default: "" } }, + group: "block", + atom: true, + inline: false, + selectable: false, + parseDOM: [{ tag: "div.html_comment" }], + toDOM(node) { + return [ + "div", + { + class: "html_comment", + hidden: true, + }, + node.attrs.content, + ]; + }, + }, + /** * Defines an uneditable html_block node; Only appears when a user has written a "complicated" html_block * i.e. anything not resembling `content` or `` diff --git a/src/shared/markdown-it/html-comment.ts b/src/shared/markdown-it/html-comment.ts new file mode 100644 index 00000000..429d7303 --- /dev/null +++ b/src/shared/markdown-it/html-comment.ts @@ -0,0 +1,64 @@ +import MarkdownIt from "markdown-it/lib"; +import StateBlock from "markdown-it/lib/rules_block/state_block"; + +const HTML_COMMENT_OPEN_TAG = //; + +function getLineText(state: StateBlock, line: number): string { + const pos = state.bMarks[line] + state.tShift[line]; + const max = state.eMarks[line]; + return state.src.slice(pos, max).trim(); +} + +function html_comment( + state: StateBlock, + startLine: number, + endLine: number, + silent: boolean +) { + if (!state.md.options.html) { + return false; + } + + let lineText = getLineText(state, startLine); + + // check if the open tag "" occurence is the last element in the line + if (HTML_COMMENT_CLOSE_TAG.exec(lineText).index + 3 !== lineText.length) { + return false; + } + + if (silent) { + return true; + } + + state.line = nextLine; + + const token = state.push("html_comment", "", 0); + token.map = [startLine, nextLine]; + token.content = state.getLines(startLine, nextLine, state.blkIndent, true); + + return true; +} + +/** + * Parses out HTML comments blocks + * (HTML comments inlined with other text/elements are not parsed by this plugin) + * @param md + */ +export function htmlComment(md: MarkdownIt): void { + md.block.ruler.before("html_block", "html_comment", html_comment); +} diff --git a/src/shared/markdown-parser.ts b/src/shared/markdown-parser.ts index 928c3c5e..a182dade 100644 --- a/src/shared/markdown-parser.ts +++ b/src/shared/markdown-parser.ts @@ -11,6 +11,7 @@ import { spoiler } from "./markdown-it/spoiler"; import { stackLanguageComments } from "./markdown-it/stack-language-comments"; import { tagLinks } from "./markdown-it/tag-link"; import { tight_list } from "./markdown-it/tight-list"; +import { htmlComment } from "./markdown-it/html-comment"; import type { CommonmarkParserFeatures } from "./view"; // extend the default markdown parser's tokens and add our own @@ -27,7 +28,12 @@ const customMarkdownParserTokens: MarkdownParser["tokens"] = { content: token.content, }), }, - + html_comment: { + node: "html_comment", + getAttrs: (token: Token) => ({ + content: token.content, + }), + }, html_block: { node: "html_block", getAttrs: (token: Token) => ({ @@ -311,6 +317,9 @@ export function createDefaultMarkdownItInstance( // ensure we can tell the difference between the different types of hardbreaks defaultMarkdownItInstance.use(hardbreak_markup); + // parse html comments + defaultMarkdownItInstance.use(htmlComment); + // TODO should always exist, so remove the check once the param is made non-optional externalPluginProvider?.alterMarkdownIt(defaultMarkdownItInstance); diff --git a/src/shared/markdown-serializer.ts b/src/shared/markdown-serializer.ts index d1214914..8c91a175 100644 --- a/src/shared/markdown-serializer.ts +++ b/src/shared/markdown-serializer.ts @@ -332,6 +332,11 @@ const customMarkdownSerializerNodes: MarkdownSerializerNodes = { state.write(node.attrs.content as string); }, + html_comment(state, node) { + state.write(node.attrs.content as string); + state.closeBlock(node); + }, + html_block(state, node) { state.write(node.attrs.content as string); state.closeBlock(node); diff --git a/test/shared/markdown-it/html-comment.test.ts b/test/shared/markdown-it/html-comment.test.ts new file mode 100644 index 00000000..59617ea9 --- /dev/null +++ b/test/shared/markdown-it/html-comment.test.ts @@ -0,0 +1,65 @@ +import MarkdownIt from "markdown-it/lib"; +import { htmlComment } from "../../../src/shared/markdown-it/html-comment"; + +function createParser() { + const instance = new MarkdownIt("default", { html: true }); + instance.use(htmlComment); + return instance; +} + +describe("html-comment markdown-it plugin", () => { + it("should add the html comment block rule to the instance", () => { + const instance = createParser(); + const blockRulesNames = instance.block.ruler + .getRules("") + .map((r) => r.name); + expect(blockRulesNames).toContain("html_comment"); + }); + + it("should detect single line html comment blocks", () => { + const singleLineComment = ""; + const instance = createParser(); + const tokens = instance.parse(singleLineComment, {}); + + expect(tokens).toHaveLength(1); + expect(tokens[0].type).toBe("html_comment"); + expect(tokens[0].content).toBe(singleLineComment); + expect(tokens[0].map).toEqual([0, 1]); + }); + + it("should detect multiline html comment blocks", () => { + const multilineComment = ``; + const instance = createParser(); + const tokens = instance.parse(multilineComment, {}); + + expect(tokens).toHaveLength(1); + expect(tokens[0].type).toBe("html_comment"); + expect(tokens[0].content).toBe(multilineComment); + expect(tokens[0].map).toEqual([0, 2]); + }); + + it("should detect indented html comment blocks", () => { + const indentedComment = ` `; + const instance = createParser(); + const tokens = instance.parse(indentedComment, {}); + + expect(tokens).toHaveLength(1); + expect(tokens[0].type).toBe("html_comment"); + expect(tokens[0].content).toBe(indentedComment); + expect(tokens[0].map).toEqual([0, 4]); + }); + + it.each([ + "other text ", + " other text", + "
other element
", + ])( + "should ignore html comments inlined with other element/text (test #%#)", + (inlinedHtmlComment) => { + const instance = createParser(); + const tokens = instance.parse(inlinedHtmlComment, {}); + + expect(tokens.map((t) => t.type)).not.toContain("html_comment"); + } + ); +}); diff --git a/test/shared/markdown-parser.test.ts b/test/shared/markdown-parser.test.ts index 75aae185..bc207404 100644 --- a/test/shared/markdown-parser.test.ts +++ b/test/shared/markdown-parser.test.ts @@ -52,6 +52,19 @@ describe("SOMarkdownParser", () => { }); }); + it("should support html comments", () => { + const doc = markdownParser.parse(``); + expect(doc).toMatchNodeTree({ + childCount: 1, + content: [ + { + "type.name": "html_comment", + "attrs.content": "", + }, + ], + }); + }); + it.skip("should support single block html without nesting", () => { const doc = markdownParser.parse("

test

"); diff --git a/test/shared/markdown-serializer.test.ts b/test/shared/markdown-serializer.test.ts index 0d837473..94377925 100644 --- a/test/shared/markdown-serializer.test.ts +++ b/test/shared/markdown-serializer.test.ts @@ -160,6 +160,9 @@ describe("markdown-serializer", () => { /* Tables */ `| foo | bar |\n| --- | --- |\n| baz | bim |`, `| abc | def | ghi |\n|:---:|:--- | ---:|\n| foo | bar | baz |`, + /* Comments */ + ``, + ``, /* Marks */ `*test*`, `_test_`,