Skip to content

Commit

Permalink
Merge branch 'md-escape-curly-braces' into 'main'
Browse files Browse the repository at this point in the history
Improve vue template variable handling in MDE

See merge request reportcreator/reportcreator!484
  • Loading branch information
MWedl committed Mar 19, 2024
2 parents c532cf9 + a0ca71b commit fb58bf0
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 17 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* Remember "Encrypt PDF" setting in browser's local storage
* Fix force change design API request not sent
* Collaborative editing in notes
* Markdown editor: Improve vue template variable handling
* Markdown editor: Allow escaping curly braces


## v2024.19 - 2024-03-05
Expand Down
16 changes: 14 additions & 2 deletions frontend/test/markdownExtensions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ describe('Markdown extensions', () => {
'# Heading {#id .class style="color:red"}': '<h1 id="id" class="class" style="color:red">Heading </h1>',
'## Heading{#id}': '<h2 id="id">Heading</h2>',
'### Heading [link](https://example.com){.class}': '<h3>Heading <a href="https://example.com" class="class" target="_blank" rel="nofollow noopener noreferrer">link</a></h3>',
'#### Heading\n{#id}': { html: '<h4>Heading</h4>\n<p>{#id}</p>', formatted: '#### Heading\n\n{#id}' },
'#### Heading\n{#id}': { html: '<h4>Heading</h4>\n<p>&#x7B;#id&#x7D;</p>', formatted: '#### Heading\n\n{#id}' },
// Template varaibles
'{{ var }}': '<p>{{ var }}</p>',
'text **{{ var }}** text': '<p>text <strong>{{ var }}</strong> text</p>',
'text {{ (var1 < 0 && var2 > 0) ? \'a\' : "b" }} text': '<p>text {{ (var1 < 0 && var2 > 0) ? \'a\' : "b" }} text</p>',
'text {{ var **with** _code_ `code` }} text': '<p>text {{ var **with** _code_ `code` }} text</p>',
'text {{ var with curly braces {a: "abc", b: "def"}[var] }} text': '<p>text {{ var with curly braces {a: "abc", b: "def"}[var] }} text</p>',
'text {no var}} {{ no var } text': '<p>text {no var}} {{ no var } text</p>',
'text {no var}} {{ no var } text': '<p>text &#x7B;no var&#x7D;&#x7D; &#x7B;&#x7B; no var &#x7D; text</p>',
'text <template v-if="var">{{ var }} text</template> text': '<p>text <span v-if="var">{{ var }} text</span> text</p>',
'text <span v-for="v in var">{{ v }}</span> text': '<p>text <span v-for="v in var">{{ v }}</span> text</p>',
// TO-DOs
Expand Down Expand Up @@ -73,6 +74,17 @@ describe('Markdown extensions', () => {
// Self-closing tags
'text\n\n<pagebreak />\n\ntext': '<p>text</p>\n<pagebreak></pagebreak>\n<p>text</p>',
'text <ref to="ref" data-custom-attr="asf" /> text': '<p>text <ref to="ref" data-custom-attr="asf"></ref> text</p>',
// Line breaks
'text\ntext': '<p>text\ntext</p>',
'text \ntext': { html: '<p>text<br>\ntext</p>', formatted: 'text\\\ntext' },
'text \\\ntext': '<p>text <br>\ntext</p>',
// Escaping
'\\# text': '<p># text</p>',
'\\<h1>test\\</h1>': '<p>&#x3C;h1>test&#x3C;/h1></p>',
'\\{\\{ text \\}\\}': { html: '<p>&#x7B;&#x7B; text &#x7D;&#x7D;</p>', formatted: '{{ text }}' },
'`{{ text }}`': '<p><code class="code-inline">&#x7B;&#x7B; text &#x7D;&#x7D;</code></p>',
'```\n{{ text }}\n```': codeBlock('&#x7B;&#x7B; text &#x7D;&#x7D;'),
'```\n\\# \\{\\{ text \\}\\}\n```': codeBlock('\\# \\&#x7B;\\&#x7B; text \\&#x7D;\\&#x7D;'),
}).map(([md, expected]) => [md, typeof expected === 'string' ? { html: expected, formatted: md } : expected])) {
test(md, () => {
expect(renderMarkdownToHtml(md).trim()).toBe(expected.html);
Expand Down
27 changes: 16 additions & 11 deletions packages/markdown/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import rehypeRaw from 'rehype-raw';
import remarkStringify from 'remark-stringify';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { merge } from 'lodash';
import mermaid from 'mermaid';
import 'highlight.js/styles/default.css';

import { remarkFootnotes, remarkToRehypeHandlersFootnotes, remarkToRehypeHandersFootnotesPreview, rehypeFootnoteSeparator, rehypeFootnoteSeparatorPreview } from './mdext/footnotes.js';
import { remarkStrikethrough, remarkTaskListItem } from './mdext/gfm.js';
import { rehypeConvertAttrsToStyle, rehypeLinkTargetBlank, rehypeRewriteImageSources, rehypeRewriteFileLinks, rehypeTemplates, rehypeRawFixSelfClosingTags } from './mdext/rehypePlugins.js';
import { rehypeConvertAttrsToStyle, rehypeLinkTargetBlank, rehypeRewriteImageSources, rehypeRewriteFileLinks, rehypeTemplates, rehypeRawFixSelfClosingTags, rehypeRawFixPassthroughStitches } from './mdext/rehypePlugins.js';
import { remarkAttrs, remarkToRehypeAttrs } from './mdext/attrs.js';
import { remarkFigure, remarkToRehypeHandlersFigure } from './mdext/image.js';
import { remarkTables, remarkTableCaptions, remarkToRehypeHandlersTableCaptions, rehypeTableCaptions } from './mdext/tables.js';
import { rehypeReferenceLink, rehypeReferenceLinkPreview } from './mdext/reference.js';
import { annotatedTextParse } from './editor/annotatedtext';
import { remarkTemplateVariables } from './mdext/templates.js';
import { remarkTemplateVariables, remarkToRehypeTemplateVariables, rehypeTemplateVariables } from './mdext/templates.js';
import { remarkTodoMarker } from './mdext/todo.js';
import { rehypeHighlightCode } from './mdext/codeHighlight.js';
import { modifiedCommonmarkFeatures } from './mdext/modified-commonmark.js';
import { rehypeStringify } from './mdext/stringify.js';

const allClasses = ['className', /^.*$/];
const rehypeSanitizeSchema = merge({}, defaultSchema, {
Expand All @@ -35,7 +35,7 @@ const rehypeSanitizeSchema = merge({}, defaultSchema, {
'abbr', 'bdo', 'cite', 'dfn', 'time', 'var', 'wbr',
].concat(defaultSchema.tagNames),
attributes: {
'*': ['className', 'style', 'data*', 'v-if', 'v-for', 'v-bind', 'v-on'].concat(defaultSchema.attributes['*']),
'*': ['className', 'style', 'data*', 'v-if', 'v-else-if', 'v-else', 'v-for', 'v-bind', 'v-on', 'v-show', 'v-pre', 'v-text'].concat(defaultSchema.attributes['*']),
'a': ['download', 'target', 'rel', allClasses].concat(defaultSchema.attributes['a']),
'img': ['loading'].concat(defaultSchema.attributes['img']),
'code': [allClasses].concat(defaultSchema.attributes['code']),
Expand Down Expand Up @@ -85,7 +85,7 @@ export function formatMarkdown(text) {
* @returns {string}
*/
export function renderMarkdownToHtml(text, {preview = false, rewriteFileSource = null, rewriteReferenceLink = null} = {}) {
const md = markdownParser()
let md = markdownParser()
.use(remarkParse)
.use(remarkRehype, {
allowDangerousHtml: true,
Expand All @@ -95,21 +95,26 @@ export function renderMarkdownToHtml(text, {preview = false, rewriteFileSource =
...remarkToRehypeHandlersFigure,
...remarkToRehypeHandlersTableCaptions,
...remarkToRehypeAttrs,
...remarkToRehypeTemplateVariables,
}
})
.use(rehypeTableCaptions)
.use(rehypeHighlightCode, { preview })
.use(rehypeRawFixSelfClosingTags)
.use(rehypeRaw)
.use(rehypeConvertAttrsToStyle)
.use(rehypeRaw, { passThrough: ['templateVariable']})
.use(rehypeTemplates)
.use(rehypeRawFixPassthroughStitches)
.use(rehypeTemplateVariables, { preview })
.use(rehypeConvertAttrsToStyle)
.use(preview ? rehypeFootnoteSeparatorPreview : rehypeFootnoteSeparator)
.use(preview ? rehypeReferenceLinkPreview : rehypeReferenceLink, { rewriteReferenceLink })
.use(rehypeRewriteImageSources, {rewriteImageSource: rewriteFileSource})
.use(rehypeRewriteFileLinks, {rewriteFileUrl: rewriteFileSource})
.use(rehypeLinkTargetBlank)
.use(rehypeSanitize, rehypeSanitizeSchema)
.use(rehypeStringify);
.use(rehypeLinkTargetBlank);
if (preview) {
md = md.use(rehypeSanitize, rehypeSanitizeSchema);
}
md = md.use(rehypeStringify);

// Normalize linebreaks
text = text.replace(/\r\n/g, '\n');
Expand Down
11 changes: 10 additions & 1 deletion packages/markdown/mdext/rehypePlugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ export function rehypeRewriteFileLinks({ rewriteFileUrl }) {
export function rehypeTemplates() {
return tree => visit(tree, 'element', node => {
if (node.tagName === 'template') {
node.children = node.content?.children || [];
node.tagName = 'span';
node.children = node.content?.children || [];
}
})
}
Expand All @@ -115,3 +115,12 @@ export function rehypeRawFixSelfClosingTags() {
node.value = node.value.replaceAll(/<(?<tag>[a-zA-Z0-9-]+)(?<attrs>[^>]*)\/>/g, "<$<tag>$<attrs>></$<tag>>");
});
}


export function rehypeRawFixPassthroughStitches() {
return tree => visit(tree, 'comment', (node, index, parent) => {
if (node.value?.stitch) {
parent.children.splice(index, 1, node.value.stitch);
}
})
}
58 changes: 58 additions & 0 deletions packages/markdown/mdext/stringify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { htmlVoidElements } from 'html-void-elements'
import { html } from 'property-information'
import { stringifyEntities } from 'stringify-entities'
import { handle } from '../node_modules/hast-util-to-html/lib/handle/index'
import { all } from '../node_modules/hast-util-to-html/lib/index'


export function rehypeStringify() {
this.compiler = toHtml;
}

export function toHtml(tree) {
const state = {
one,
all,
settings: {
// Required to pass-through vue template variables.
// In preview mode, rehype-sanitize already removed dangerous HTML at this point.
allowDangerousHtml: true,
voids: htmlVoidElements,
characterReferences: {},

},
schema: html,
quote: '"',
alternative: "'",
}
return state.one(
Array.isArray(tree) ? {type: 'root', children: tree} : tree,
undefined,
undefined
)
}

function one(node, index, parent) {
return handleModified(node, index, parent, this);
}

function handleModified(node, index, parent, state) {
if (node.type === 'text') {
return text(node, index, parent, state);
}
return handle(node, index, parent, state);
}

export function text(node, _, parent, state) {
// Check if content of `node` should be escaped.
return parent &&
parent.type === 'element' &&
(parent.tagName === 'script' || parent.tagName === 'style')
? node.value
: stringifyEntities(
node.value,
Object.assign({}, state.settings.characterReferences, {
subset: ['<', '&', '{', '}']
})
)
}
29 changes: 26 additions & 3 deletions packages/markdown/mdext/templates.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { codes } from 'micromark-util-symbol';
import { visit } from 'unist-util-visit';
import { addRemarkExtension } from './helpers';


Expand Down Expand Up @@ -71,11 +72,11 @@ function templateVariableFromMarkdown() {
};

function enterTemplateVariable(token) {
this.enter({type: 'templateVariable', value: ''}, token);
this.enter({ type: 'templateVariable', value: '' }, token);
}
function exitTemplateVariable(token) {
const node = this.stack[this.stack.length - 1];
node.value = this.sliceSerialize(token);
node.value = this.sliceSerialize(token);
this.exit(token);
}
}
Expand All @@ -84,7 +85,7 @@ function templateVariableToMarkdown() {
return {
handlers: {
templateVariable,
}
}
};

function templateVariable(node, _, state) {
Expand All @@ -99,3 +100,25 @@ function templateVariableToMarkdown() {
export function remarkTemplateVariables() {
addRemarkExtension(this, templateVariableSyntax(), templateVariableFromMarkdown(), templateVariableToMarkdown());
}


export const remarkToRehypeTemplateVariables = {
templateVariable(state, node) {
return state.applyData(node, {
type: 'templateVariable',
value: node.value,
})
}
}


export function rehypeTemplateVariables({ preview = false }) {
return tree =>
visit(tree, 'templateVariable', (node) => {
if (preview) {
node.type = 'text';
} else {
node.type = 'raw';
}
});
}

0 comments on commit fb58bf0

Please sign in to comment.