diff --git a/docs/userGuide/syntax/code.mbdf b/docs/userGuide/syntax/code.mbdf index a9143fe972..dd8a3c38e6 100644 --- a/docs/userGuide/syntax/code.mbdf +++ b/docs/userGuide/syntax/code.mbdf @@ -58,47 +58,68 @@ function add(a, b) { ##### Line highlighting -To highlight lines, add the attribute `highlight-lines` as shown below. -You can specify highlighting in many different ways, depending on how you want it to be. There are two main variants: - -**Text-only highlighting** - -To highlight only the text portion of the line, you can just use the line numbers as is. - -For ranges of lines, join the two line numbers with a dash sign (`-`). - -**Whole-line highlighting** - -If you wish to highlight a full line (including whitespaces) or ranges of it, you can leverage MarkBind's own _line-slice_ syntax. Line-slices are in the form of `lineNumber[:]`, e.g. `2[:]`. - -This variant's format is very similar to the previous, but instead use line-slices rather than line numbers. - -For ranges, you only need to use line-slices on either ends. +You can add the `highlight-lines` attribute to add highlighting to your code block. Refer to the example code block +below for a visual demonstration of all the possible ways of highlighting a code block. -```java {highlight-lines="1,3[:],6-8,10[:]-12"} +```java {highlight-lines="1[:],3['Inventory'],4['It\'s designed'],5,6[8:18],8[0::2],12[:]-14,16-18,20[12:]-22,24[1::]-26"} import java.util.List; +// Inventory is a class that stores inventory items in a list. +// It's designed as a thin wrapper on the List interface. public class Inventory { private List items; public int getItemCount(){ return items.size(); } - + public bool isEmpty() { return items.isEmpty(); } - //... + public Item getItem(idx: int) { + return items.get(idx); + } + + public void addItem(item: Item) { + return items.add(item); + } + + public void removeItem(item: Item) { + return items.remove(item); + } } ``` +The value of `highlight-lines` is composed of *highlight rules*, separated by commas. +These rules dictate where and how MarkBind should highlight your code block. + +You can specify the highlight rules in many different ways, each is detailed as follows: + +Type | Format | Example +-----|--------|-------- +**Full text highlight**
Highlights the entirety of the text portion of the line | The line numbers as-is (subject to the starting line number set in `start-from`). | `3`, `5` +**Substring highlight**
Highlights _all_ occurrences of a substring in the line | `lineNumber[part]`

_Limitations_: `part` must be wrapped in quotes. If `part` contains a quote, escape it with a backslash (`\`). | `3['Inventory']`,`4['It\'s designed']` +**Character-bounded highlight**
Highlights a specific range of characters in the line | `lineNumber[start:end]`, highlights from character position `start` up to (but not including) `end`.

Character positions start from `0` as the first non-whitespace character, upwards.

Omit either `start`/`end` to highlight from the start / up to the end, respectively. | `19[1:5]`,`30[10:]`,`35[:20]` +**Word-bounded highlight**
Highlights a specific range of words in the line | `lineNumber[start::end]`, highlights from word position `start` up to (but not including) `end`.

Word positions start from `0` as the first word (sequence of non-whitespace characters), upwards.

Omit either `start`/`end` to highlight from the start / up to the end, respectively. | `5[2::4]`,`9[1::]`,`11[::5]` +**Full line highlight**
Highlights the entirety of the line | `lineNumber[:]` | `7[:]` + +Not only a single line, MarkBind is also capable of highlighting ranges of lines in various ways. In general, the syntax +for range highlighting consists of two single line highlight rules as listed above joined by a dash (`-`). + +Type | Format | Example +-----|--------|-------- +**Ranged full text highlight**
Highlights from the first non-whitespace character to the last non-whitespace character | `lineStart-lineEnd` | `2-4` +**Ranged full line highlight**
Like ranged full text highlight, but highlights the entirety of the lines | `lineStart[:]-lineEnd` or `lineStart-lineEnd[:]` | `1[:]-5`,`10-12[:]` +**Ranged character-bounded highlight**
Highlights the text portion of the lines within the range, but starts/ends at an arbitrary character | `lineStart[start:]-lineEnd` or `lineStart-lineEnd[:end]` | `3[2:]-7`, `4-9[:17]` +**Ranged word-bounded highlight**
Like ranged character-bounded highlight, but starts/ends at an arbitrary word | `lineStart[start::]-lineEnd` or `lineStart-lineEnd[::end]` | `16[1::]-20`,`22-24[::3]` + ##### Heading To add a heading, add the attribute `heading` with the heading text as the value, as shown below. diff --git a/packages/cli/test/functional/test_site/expected/testCodeBlocks.html b/packages/cli/test/functional/test_site/expected/testCodeBlocks.html index 38ac04cb40..c556323d41 100644 --- a/packages/cli/test/functional/test_site/expected/testCodeBlocks.html +++ b/packages/cli/test/functional/test_site/expected/testCodeBlocks.html @@ -223,13 +223,53 @@

Test 19 20 -

**highlight-lines attr with line-slice syntax of empty indices should highlight leading/trailing spaces

+

highlight-lines attr with empty (any variant) line-slice syntax should highlight leading/trailing spaces

<foo>
   <bar type="name">goo</bar>
-  <baz type="name">goo</baz>
+  <baz type="name">goo</baz>
   <qux type="name">goo</qux>
   <quux type="name">goo</quux>
 </foo>
+
+

highlight-lines attr with full character-variant line-slice syntax should highlight only at specified range

+
<foo>
+  <bar type="name">goo</bar>
+  <baz type="name">goo</baz>
+  <qux type="name">goo</qux>
+  <quux type="name">goo</quux>
+</foo>
+
+

highlight-lines attr with partial character-variant line-slice syntax should default highlight to start/end of line

+
<foo>
+  <bar type="name">goo</bar>
+  <baz type="name">goo</baz>
+  <qux type="name">goo</qux>
+  <quux type="name">goo</quux>
+</foo>
+
+

highlight-lines attr with line-part syntax should highlight only at specified substring

+
<foo>
+  <bar type="name">goo</bar>
+  <baz type="name">goo</baz>
+  <qux type="name">go,o</qux>
+  <quux type="name">go'o</quux>
+</foo>
+
+

highlight-lines attr with full word-variant line-slice syntax should highlight only at specified word ranges

+
<foo>
+  <bar type="name"> goo </bar>
+  <baz type="name"> goo </baz>
+  <qux type="name"> goo </qux>
+  <quux type="name"> goo </quux>
+</foo>
+
+

highlight-lines attr with partial word-variant line-slice syntax should default highlight to start/end of line

+
<foo>
+  <bar type="name"> goo </bar>
+  <baz type="name"> goo </baz>
+  <qux type="name"> goo </qux>
+  <quux type="name"> goo </quux>
+</foo>
 

Should render correctly with heading

diff --git a/packages/cli/test/functional/test_site/testCodeBlocks.md b/packages/cli/test/functional/test_site/testCodeBlocks.md index 4efb30b03b..43675c14be 100644 --- a/packages/cli/test/functional/test_site/testCodeBlocks.md +++ b/packages/cli/test/functional/test_site/testCodeBlocks.md @@ -56,8 +56,8 @@ Content in a fenced code block 20 ``` -**`highlight-lines` attr with line-slice syntax of empty indices should highlight leading/trailing spaces -```xml {highlight-lines="2[:],4[:]-5[:]"} +**`highlight-lines` attr with empty (any variant) line-slice syntax should highlight leading/trailing spaces** +```xml {highlight-lines="2[:],3[::],4[:]-5[:]"} goo goo @@ -66,6 +66,56 @@ Content in a fenced code block ``` +**`highlight-lines` attr with full character-variant line-slice syntax should highlight only at specified range** +```xml {highlight-lines="1[1:4],2[5:13],3[2:10]-4,5-6[1:4]"} + + goo + goo + goo + goo + +``` + +**`highlight-lines` attr with partial character-variant line-slice syntax should default highlight to start/end of line** +```xml {highlight-lines="1[1:],2[:13],3[2:]-4,5-6[:2]"} + + goo + goo + goo + goo + +``` + +**`highlight-lines` attr with line-part syntax should highlight only at specified substring** +```xml {highlight-lines="1[''],2['type'],3['baz'],4['go,o'],5['go\'o']"} + + goo + goo + go,o + go'o + +``` + +**`highlight-lines` attr with full word-variant line-slice syntax should highlight only at specified word ranges** +```xml {highlight-lines="1[0::1],2[3::4],3[0::2],4[2::4],5[1::3]"} + + goo + goo + goo + goo + +``` + +**`highlight-lines` attr with partial word-variant line-slice syntax should default highlight to start/end of line** +```xml {highlight-lines="1[0::],2[3::],3[::2],4[2::],5[::3]"} + + goo + goo + goo + goo + +``` + **Should render correctly with heading** ```{heading="A heading"} diff --git a/packages/core/src/html/NodeProcessor.js b/packages/core/src/html/NodeProcessor.js index 18dc60117e..202859b9ec 100644 --- a/packages/core/src/html/NodeProcessor.js +++ b/packages/core/src/html/NodeProcessor.js @@ -15,6 +15,7 @@ const { processInclude, processPanelSrc } = require('./includePanelProcessor'); const { Context } = require('./Context'); const linkProcessor = require('./linkProcessor'); const { insertTemporaryStyles } = require('./tempStyleProcessor'); +const { highlightCodeBlock } = require('./codeblockProcessor'); const md = require('../lib/markdown-it'); const utils = require('../utils'); @@ -746,6 +747,9 @@ class NodeProcessor { postProcessNode(node) { try { switch (node.name) { + case 'pre': + highlightCodeBlock(node); + break; case 'panel': NodeProcessor._assignPanelId(node); break; diff --git a/packages/core/src/html/codeblockProcessor.js b/packages/core/src/html/codeblockProcessor.js new file mode 100644 index 0000000000..40c1a63069 --- /dev/null +++ b/packages/core/src/html/codeblockProcessor.js @@ -0,0 +1,134 @@ +const cheerio = require('cheerio'); +const lodashHas = require('lodash/has'); +const md = require('../lib/markdown-it'); +const utils = require('../lib/markdown-it/utils'); + +/** + * Traverses a line part and applies highlighting if necessary. + * @param node The node of the line part to be traversed + * @param hlStart The highlight start position, relative to the start of the line part + * @param hlEnd The highlight end position, relative to the start of the line part + * @returns {object} An object that contains data to be used by the node's parent. + */ +function traverseLinePart(node, hlStart, hlEnd) { + const resData = { + numCharsTraversed: 0, + shouldParentHighlight: false, + highlightRange: undefined, + }; + + if (hlEnd <= 0) { + // Highlight end has passed, no need to traverse further + return resData; + } + + if (node.type === 'text') { + /* + * Node is a text node. It is not an inherent HTML element of its own, + * so to actually highlight this text, we have to ask to apply at its parent. + */ + + const cleanedText = utils.unescapeHtml(node.data); + const textLength = cleanedText.length; + resData.numCharsTraversed = textLength; + + if (hlStart >= textLength) { + // Highlight start is not in this text + resData.shouldParentHighlight = false; + return resData; + } + + if (hlStart <= 0 && hlEnd >= textLength) { + // Highlight spans across the entirety of text + resData.shouldParentHighlight = true; + return resData; + } + + // Partial text highlighting + resData.shouldParentHighlight = true; + resData.highlightRange = [hlStart, hlEnd]; + return resData; + } + + /* + * The remaining possibility is that node is a tag node. + * It has at least one child (to contain the text content). + * It may have more children, such as inner tag nodes. + */ + + const highlightData = node.children.map((child) => { + const [relativeHlStart, relativeHlEnd] = [hlStart, hlEnd].map(x => x - resData.numCharsTraversed); + const data = traverseLinePart(child, relativeHlStart, relativeHlEnd); + resData.numCharsTraversed += data.numCharsTraversed; + return data; + }); + + if (highlightData.every(data => data.shouldParentHighlight && !data.highlightRange)) { + /* + * Every child wants highlight to be applied to the whole content at node level. + * For conciseness, ask for the node's parent to highlight, if possible + */ + resData.shouldParentHighlight = true; + return resData; + } + + /* + * If node level highlighting is not possible, highlight the individual children as needed. + * For text nodes, it is trickier, as we have to wrap the text inside a first. + * Essentially, we have to change the text node to become a tag node. + */ + + node.children.forEach((child, idx) => { + const data = highlightData[idx]; + if (!data.shouldParentHighlight) { + return; + } + + if (child.type === 'tag') { + child.attribs.class = child.attribs.class ? `${child.attribs.class} highlighted` : 'highlighted'; + return; + } + + if (!data.highlightRange) { + cheerio(child).wrap(''); + } else { + const [start, end] = data.highlightRange; + const cleaned = utils.unescapeHtml(child.data); + const split = [cleaned.substring(0, start), cleaned.substring(start, end), cleaned.substring(end)]; + const [pre, highlighted, post] = split.map(md.utils.escapeHtml); + const newElement = cheerio(`${pre}${highlighted}${post}`); + cheerio(child).replaceWith(newElement); + } + }); + + resData.shouldParentHighlight = false; + return resData; +} + +/** + * Applies pending highlighting to the code block. + * This looks into each line for highlighting data, and if found, + * traverses over the line and applies the highlight. + * @param node Root of the code block element, which is the 'pre' node + */ +function highlightCodeBlock(node) { + const codeNode = node.children.find(c => c.name === 'code'); + if (!codeNode) { + return; + } + + codeNode.children.forEach((lineNode) => { + if (!lodashHas(lineNode.attribs, 'hl-data')) { + return; + } + + const bounds = lineNode.attribs['hl-data'].split(',').map(boundStr => boundStr.split('-').map(Number)); + bounds.forEach(([start, end]) => traverseLinePart(lineNode, start, end)); + + delete lineNode.attribs['hl-data']; + }); +} + +module.exports = { + highlightCodeBlock, +}; diff --git a/packages/core/src/lib/markdown-it/highlight/HighlightRule.js b/packages/core/src/lib/markdown-it/highlight/HighlightRule.js index 6abe98fb98..1761dfe225 100644 --- a/packages/core/src/lib/markdown-it/highlight/HighlightRule.js +++ b/packages/core/src/lib/markdown-it/highlight/HighlightRule.js @@ -1,4 +1,5 @@ const { HighlightRuleComponent } = require('./HighlightRuleComponent.js'); +const { splitCodeAndIndentation } = require('./helper'); class HighlightRule { constructor(ruleComponents) { @@ -8,13 +9,17 @@ class HighlightRule { this.ruleComponents = ruleComponents; } - static parseRule(ruleString) { - const components = ruleString.split('-').map(HighlightRuleComponent.parseRuleComponent); - return new HighlightRule(components); - } + static parseRule(ruleString, lineOffset, lines) { + const components = ruleString.split('-') + .map(compString => HighlightRuleComponent.parseRuleComponent(compString, lineOffset, lines)); + + if (components.some(c => !c)) { + // Not all components are properly parsed, which means + // the rule itself is not proper + return null; + } - offsetLines(offset) { - this.ruleComponents.forEach(comp => comp.offsetLineNumber(offset)); + return new HighlightRule(components); } shouldApplyHighlight(lineNumber) { @@ -29,38 +34,56 @@ class HighlightRule { return atLineNumber; } - applyHighlight(line) { - const isLineSlice = this.ruleComponents.length === 1 && this.ruleComponents[0].isSlice; + applyHighlight(line, lineNumber) { + // Applied rule is the first component until deduced otherwise + let [appliedRule] = this.ruleComponents; if (this.isLineRange()) { + // For cases like 2[:]-3 (or 2-3[:]), the highlight would be line highlight + // across all the ranges const shouldWholeLine = this.ruleComponents.some(comp => comp.isUnboundedSlice()); - return shouldWholeLine - ? HighlightRule._highlightWholeLine(line) - : HighlightRule._highlightTextOnly(line); + if (shouldWholeLine) { + return HighlightRule._highlightWholeLine(line); + } + + const [startCompare, endCompare] = this.ruleComponents.map(comp => comp.compareLine(lineNumber)); + if (startCompare < 0 && endCompare > 0) { + // In-between range + return HighlightRule._highlightWholeText(line); + } + + // At the range boundaries + const [startRule, endRule] = this.ruleComponents; + appliedRule = startCompare === 0 ? startRule : endRule; } - if (isLineSlice) { - // TODO: Implement slice-index based highlighting - return HighlightRule._highlightWholeLine(line); + if (appliedRule.isSlice) { + return appliedRule.isUnboundedSlice() + ? HighlightRule._highlightWholeLine(line) + : HighlightRule._highlightPartOfText(line, appliedRule.bounds); } - return HighlightRule._highlightTextOnly(line); + // Line number only + return HighlightRule._highlightWholeText(line); } static _highlightWholeLine(codeStr) { return `${codeStr}\n`; } - static _splitCodeAndIndentation(codeStr) { - const codeStartIdx = codeStr.search(/\S|$/); - const indents = codeStr.substr(0, codeStartIdx); - const content = codeStr.substr(codeStartIdx); - return [indents, content]; + static _highlightWholeText(codeStr) { + const [indents, content] = splitCodeAndIndentation(codeStr); + return `${indents}${content}\n`; } - static _highlightTextOnly(codeStr) { - const [indents, content] = HighlightRule._splitCodeAndIndentation(codeStr); - return `${indents}${content}\n`; + static _highlightPartOfText(codeStr, bounds) { + /* + * Note: As part-of-text highlighting requires walking over the node of the generated + * html by highlight.js, highlighting will be applied in NodeProcessor instead. + * hl-data is used to pass over the bounds. + */ + const dataStr = bounds.map(bound => bound.join('-')).join(','); + return `${codeStr}\n`; } isLineRange() { diff --git a/packages/core/src/lib/markdown-it/highlight/HighlightRuleComponent.js b/packages/core/src/lib/markdown-it/highlight/HighlightRuleComponent.js index 36dd0341c6..4438f743e9 100644 --- a/packages/core/src/lib/markdown-it/highlight/HighlightRuleComponent.js +++ b/packages/core/src/lib/markdown-it/highlight/HighlightRuleComponent.js @@ -1,35 +1,82 @@ -const LINESLICE_REGEX = new RegExp('(\\d+)\\[(\\d*):(\\d*)]'); +const { splitCodeAndIndentation } = require('./helper'); + +const LINESLICE_CHAR_REGEX = new RegExp('(\\d+)\\[(\\d*):(\\d*)]'); +const LINESLICE_WORD_REGEX = new RegExp('(\\d+)\\[(\\d*)::(\\d*)]'); +const LINEPART_REGEX = new RegExp('(\\d+)\\[(["\'])((?:\\\\.|[^\\\\])*?)\\2]'); +const UNBOUNDED = -1; class HighlightRuleComponent { - constructor(lineNumber, isSlice, bounds) { + constructor(lineNumber, isSlice = false, bounds = []) { + /** + * @type {number} + */ this.lineNumber = lineNumber; - this.isSlice = isSlice || false; - this.bounds = bounds || []; + /** + * @type {boolean} + */ + this.isSlice = isSlice; + /** + * @type {Array<[number, number]>} + */ + this.bounds = bounds; } - static parseRuleComponent(compString) { - // tries to match with the line slice pattern - const matches = compString.match(LINESLICE_REGEX); - if (matches) { - const groups = matches.slice(1); // keep the capturing group matches only - const lineNumber = parseInt(groups.shift(), 10); + static parseRuleComponent(compString, lineNumberOffset, lines) { + // Match line-slice (character and word variant) syntax + const linesliceCharMatch = compString.match(LINESLICE_CHAR_REGEX); + const linesliceWordMatch = compString.match(LINESLICE_WORD_REGEX); + const sliceMatch = linesliceCharMatch || linesliceWordMatch; + if (sliceMatch) { + // There are four capturing groups: [full match, line number, start bound, end bound] + const groups = sliceMatch.slice(1); // discard full match + + let lineNumber = parseInt(groups.shift(), 10); + if (Number.isNaN(lineNumber)) { + return null; + } + lineNumber += lineNumberOffset; const isUnbounded = groups.every(x => x === ''); if (isUnbounded) { - return new HighlightRuleComponent(lineNumber, true); + return new HighlightRuleComponent(lineNumber, true, []); + } + + let bound = groups.map(x => (x !== '' ? parseInt(x, 10) : UNBOUNDED)); + const isCharSlice = sliceMatch === linesliceCharMatch; + bound = isCharSlice + ? HighlightRuleComponent.computeCharBounds(bound, lines[lineNumber - 1]) + : HighlightRuleComponent.computeWordBounds(bound, lines[lineNumber - 1]); + + return new HighlightRuleComponent(lineNumber, true, [bound]); + } + + // Match line-part syntax + const linepartMatch = compString.match(LINEPART_REGEX); + if (linepartMatch) { + // There are four capturing groups: [full match, line number, quote type, line part] + const groups = linepartMatch.slice(1); // discard full match + + let lineNumber = parseInt(groups.shift(), 10); + if (Number.isNaN(lineNumber)) { + return null; } + lineNumber += lineNumberOffset; + + groups.shift(); // discard quote type + const linePart = groups.shift().replace(/\\'/g, '\'').replace(/\\"/g, '"'); // unescape quotes + const bounds = HighlightRuleComponent.computeLinePartBounds(linePart, lines[lineNumber - 1]); - const bounds = groups.map(x => (x !== '' ? parseInt(x, 10) : -1)); return new HighlightRuleComponent(lineNumber, true, bounds); } - // match fails, so it is just line numbers - const lineNumber = parseInt(compString, 10); - return new HighlightRuleComponent(lineNumber); - } + // Match line-number syntax + if (!Number.isNaN(compString)) { // ensure the whole string can be converted to number + const lineNumber = parseInt(compString, 10) + lineNumberOffset; + return new HighlightRuleComponent(lineNumber); + } - offsetLineNumber(offset) { - this.lineNumber += offset; + // the string is an improperly written rule + return null; } /** @@ -46,6 +93,126 @@ class HighlightRuleComponent { isUnboundedSlice() { return this.isSlice && this.bounds.length === 0; } + + /** + * Computes the actual character bound given a user-defined character bound and a line, + * comparing the bounds and the line's range. + * + * If the bound does not specify either the start or the end bound, the computed bound will default + * to the start or end of line, excluding leading whitespaces. + * + * @param bound The user-defined bound + * @param line The given line + * @returns {[number, number]} The actual bound computed + */ + static computeCharBounds(bound, line) { + const [indents] = splitCodeAndIndentation(line); + let [start, end] = bound; + + if (start === UNBOUNDED) { + start = indents.length; + } else { + start += indents.length; + // Clamp values + if (start < indents.length) { + start = indents.length; + } else if (start > line.length) { + start = line.length; + } + } + + if (end === UNBOUNDED) { + end = line.length; + } else { + end += indents.length; + // Clamp values + if (end < indents.length) { + end = indents.length; + } else if (end > line.length) { + end = line.length; + } + } + + return [start, end]; + } + + /** + * Computes the actual character bounds given a user-defined word bound and a line, + * comparing the bounds and the line's range. + * + * If the bound does not specify either the start or the end bound, the computed bound will default + * to the start or end of line, excluding leading whitespaces. + * + * @param bound The user-defined bound + * @param line The given line + * @returns {[number, number]} The actual bound computed + */ + static computeWordBounds(bound, line) { + const [indents, content] = splitCodeAndIndentation(line); + const words = content.split(/\s+/); + const wordPositions = []; + let contentRemaining = content; + let curr = indents.length; + words.forEach((word) => { + const start = contentRemaining.indexOf(word); + const end = start + word.length; + wordPositions.push([curr + start, curr + end]); + contentRemaining = contentRemaining.substring(end); + curr += end; + }); + + let [start, end] = bound; + + if (start === UNBOUNDED || start < 0) { + start = indents.length; + } else if (start > words.length) { + start = line.length; + } else { + const [wordStart] = wordPositions[start]; + start = wordStart; + } + + if (end === UNBOUNDED || end > words.length) { + end = line.length; + } else if (end < 0) { + end = indents.length; + } else { + const [, wordEnd] = wordPositions[end - 1]; + end = wordEnd; + } + + return [start, end]; + } + + /** + * Computes the actual bounds given a user-defined line part and a line. + * + * @param linePart The user-defined line part + * @param line The given line + * @returns {Array<[number, number]>} The bounds computed, each indicates the range of each + * occurrences of the line part in the line + */ + static computeLinePartBounds(linePart, line) { + const [indents, content] = splitCodeAndIndentation(line); + let contentRemaining = content; + let start = contentRemaining.indexOf(linePart); + + if (linePart === '' || start === -1) { + return [[0, 0]]; + } + + const bounds = []; + let curr = indents.length; + while (start !== -1) { + const end = start + linePart.length; + bounds.push([curr + start, curr + end]); + curr += end; + contentRemaining = contentRemaining.substring(end); + start = contentRemaining.indexOf(linePart); + } + + return bounds; + } } module.exports = { diff --git a/packages/core/src/lib/markdown-it/highlight/helper.js b/packages/core/src/lib/markdown-it/highlight/helper.js new file mode 100644 index 0000000000..3ea405455f --- /dev/null +++ b/packages/core/src/lib/markdown-it/highlight/helper.js @@ -0,0 +1,12 @@ +// Common helper functions to be used in HighlightRule or HighlightRuleComponent + +function splitCodeAndIndentation(codeStr) { + const codeStartIdx = codeStr.search(/\S|$/); + const indents = codeStr.substring(0, codeStartIdx); + const content = codeStr.substring(codeStartIdx); + return [indents, content]; +} + +module.exports = { + splitCodeAndIndentation, +}; diff --git a/packages/core/src/lib/markdown-it/index.js b/packages/core/src/lib/markdown-it/index.js index 216f3467b9..52b5c44d9e 100644 --- a/packages/core/src/lib/markdown-it/index.js +++ b/packages/core/src/lib/markdown-it/index.js @@ -12,6 +12,8 @@ const logger = require('../../utils/logger'); const { HighlightRule } = require('./highlight/HighlightRule.js'); +const HIGHLIGHT_LINES_DELIMITER_REGEX = new RegExp(',(?![^\\[\\]]*])'); + const createDoubleDelimiterInlineRule = require('./plugins/markdown-it-double-delimiter'); // markdown-it plugins @@ -58,7 +60,25 @@ markdownIt.renderer.rules.fence = (tokens, idx, options, env, slf) => { const lang = token.info || ''; let str = token.content; let highlighted = false; - let lines; + let lines = str.split('\n'); + + const startFromOneBased = Math.max(1, parseInt(getAttributeAndDelete(token, 'start-from'), 10) || 1); + const startFromZeroBased = startFromOneBased - 1; + + if (startFromOneBased > 1) { + // counter is incremented on each span, so we need to subtract 1 + token.attrJoin('style', `counter-reset: line ${startFromZeroBased};`); + } + + const highlightLinesInput = getAttributeAndDelete(token, 'highlight-lines'); + let highlightRules = []; + if (highlightLinesInput) { + const highlightLines = highlightLinesInput.split(HIGHLIGHT_LINES_DELIMITER_REGEX); + highlightRules = highlightLines + .map(ruleStr => HighlightRule.parseRule(ruleStr, -startFromZeroBased, lines)) + .filter(rule => rule); // discards invalid rules + } + if (lang && hljs.getLanguage(lang)) { try { /* We cannot syntax highlight THEN split by lines. For eg: @@ -74,10 +94,9 @@ markdownIt.renderer.rules.fence = (tokens, idx, options, env, slf) => { Note the line break contained inside a element. So we have to split by lines THEN syntax highlight. */ - // state stores the current parse state of hljs, so that we can pass it on line by line let state = null; - lines = str.split('\n').map((line) => { + lines = lines.map((line) => { const highlightedLine = hljs.highlight(lang, line, true, state); state = highlightedLine.top; return highlightedLine.value; @@ -91,31 +110,13 @@ markdownIt.renderer.rules.fence = (tokens, idx, options, env, slf) => { lines = markdownIt.utils.escapeHtml(str).split('\n'); } - const startFromOneBased = Math.max(1, parseInt(getAttributeAndDelete(token, 'start-from'), 10) || 1); - const startFromZeroBased = startFromOneBased - 1; - - if (startFromOneBased > 1) { - // counter is incremented on each span, so we need to subtract 1 - token.attrJoin('style', `counter-reset: line ${startFromZeroBased};`); - } - - const highlightLinesInput = getAttributeAndDelete(token, 'highlight-lines'); - let highlightRules = []; - if (highlightLinesInput) { - const highlightLines = highlightLinesInput.split(','); - highlightRules = highlightLines.map(HighlightRule.parseRule); - // Note: authors provide line numbers based on the 'start-from' attribute if it exists, - // so we need to shift line numbers back down to start at 0 - highlightRules.forEach(rule => rule.offsetLines(-startFromZeroBased)); - } - lines.pop(); // last line is always a single '\n' newline, so we remove it // wrap all lines with so we can number them str = lines.map((line, index) => { const currentLineNumber = index + 1; const rule = highlightRules.find(highlightRule => highlightRule.shouldApplyHighlight(currentLineNumber)); if (rule) { - return rule.applyHighlight(line); + return rule.applyHighlight(line, currentLineNumber); } // not highlighted diff --git a/packages/core/src/lib/markdown-it/utils/index.js b/packages/core/src/lib/markdown-it/utils/index.js new file mode 100644 index 0000000000..df1aae71ec --- /dev/null +++ b/packages/core/src/lib/markdown-it/utils/index.js @@ -0,0 +1,29 @@ +/* + Extra utility functions related to markdown-it. + markdown-it library exposes a utility module in markdown-it/utils, + below are additional functions that can be used as helpers alongside markdown-it/utils + */ + +// This mapping is taken from markdown-it/utils, just flipped. +// Refer to the original file at markdown-it/lib/common/utils.js +const htmlUnescapedMapping = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': '\'', +}; + +// markdown-it/utils have an escapeHtml function, but not the +// complementary un-escaping function +function unescapeHtml(str) { + let unescaped = str; + Object.entries(htmlUnescapedMapping).forEach(([key, value]) => { + unescaped = unescaped.split(key).join(value); + }); + return unescaped; +} + +module.exports = { + unescapeHtml, +};