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,
+};