Support code with backticks in gr-formatted-text

screenshot: https://imgur.com/a/irP6kYk

- recognize single line usage of '`code`' or '```code```'
(as long as begins and ends with same number of '`')
- recognize multi-line usuage when starts and ends with '```' (three backticks)

Also refactored the formatted-text a little bit:
- instead of using `\n\n`, parse by line (\n) now
- remove the `getTextContent` as its not used anywhere

Change-Id: I2c6f9077311cbdbbef49200e00da1fe8a8bf2847
This commit is contained in:
Tao Zhou
2020-03-09 22:04:17 +01:00
parent d7c8ba913a
commit 33b4482c44
3 changed files with 201 additions and 158 deletions

View File

@@ -27,12 +27,14 @@ limitations under the License.
}
p,
ul,
code,
blockquote,
gr-linked-text.pre {
margin: 0 0 var(--spacing-m) 0;
}
p,
ul,
code,
blockquote {
max-width: var(--gr-formatted-text-prose-max-width, none);
}
@@ -42,10 +44,16 @@ limitations under the License.
:host(.noTrailingMargin) gr-linked-text.pre:last-child {
margin: 0;
}
code,
blockquote {
border-left: 1px solid #aaa;
padding: 0 var(--spacing-m);
}
code {
display: block;
white-space: pre;
color: var(--deemphasized-text-color);
}
li {
list-style-type: disc;
margin-left: var(--spacing-xl);

View File

@@ -19,6 +19,7 @@
// eslint-disable-next-line no-unused-vars
const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
/** @extends Polymer.Element */
class GrFormattedText extends Polymer.GestureEventListeners(
@@ -54,19 +55,6 @@
}
}
/**
* Get the plain text as it appears in the generated DOM.
*
* This differs from the `content` property in that it will not include
* formatting markers such as > characters to make quotes or * and - markers
* to make list items.
*
* @return {string}
*/
getTextContent() {
return this._blocksToText(this._computeBlocks(this.content));
}
_contentChanged(content) {
// In the case where the config may not be set (perhaps due to the
// request for it still being in flight), set the content anyway to
@@ -99,9 +87,10 @@
* * 'quote' (Block quote.)
* * 'pre' (Pre-formatted text.)
* * 'list' (Unordered list.)
* * 'code' (code blocks.)
*
* For blocks of type 'paragraph' and 'pre' there is a `text` property that
* maps to a string of the block's content.
* For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
* property that maps to a string of the block's content.
*
* For blocks of type 'list', there is an `items` property that maps to a
* list of strings representing the list items.
@@ -118,123 +107,134 @@
if (!content) { return []; }
const result = [];
const split = content.split('\n\n');
let p;
const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
for (let i = 0; i < split.length; i++) {
p = split[i];
if (!p.length) { continue; }
for (let i = 0; i < lines.length; i++) {
if (!lines[i].length) {
continue;
}
if (this._isQuote(p)) {
result.push(this._makeQuote(p));
} else if (this._isPreFormat(p)) {
result.push({type: 'pre', text: p});
} else if (this._isList(p)) {
this._makeList(p, result);
if (this._isCodeMarkLine(lines[i])) {
// handle multi-line code
let nextI = i+1;
while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
nextI++;
}
if (this._isCodeMarkLine(lines[nextI])) {
result.push({
type: 'code',
text: lines.slice(i+1, nextI).join('\n'),
});
i = nextI;
continue;
}
// otherwise treat it as regular line and continue
// check for other cases
}
if (this._isSingleLineCode(lines[i])) {
// no guard check as _isSingleLineCode tested on the pattern
const codeContent = lines[i].match(CODE_MARKER_PATTERN)[2];
result.push({type: 'code', text: codeContent});
} else if (this._isList(lines[i])) {
let nextI = i + 1;
while (this._isList(lines[nextI])) {
nextI++;
}
result.push(this._makeList(lines.slice(i, nextI)));
i = nextI - 1;
} else if (this._isQuote(lines[i])) {
let nextI = i + 1;
while (this._isQuote(lines[nextI])) {
nextI++;
}
const blockLines = lines.slice(i, nextI)
.map(l => l.replace(/^[ ]?>[ ]?/, ''));
result.push({
type: 'quote',
blocks: this._computeBlocks(blockLines.join('\n')),
});
i = nextI - 1;
} else if (this._isPreFormat(lines[i])) {
let nextI = i + 1;
// include pre or all regular lines but stop at next new line
while (this._isPreFormat(lines[nextI])
|| (this._isRegularLine(lines[nextI]) && lines[nextI].length)) {
nextI++;
}
result.push({
type: 'pre',
text: lines.slice(i, nextI).join('\n'),
});
i = nextI - 1;
} else {
result.push({type: 'paragraph', text: p});
let nextI = i + 1;
while (this._isRegularLine(lines[nextI])) {
nextI++;
}
result.push({
type: 'paragraph',
text: lines.slice(i, nextI).join('\n'),
});
i = nextI - 1;
}
}
return result;
}
/**
* Take a block of comment text that contains a list and potentially
* a paragraph (but does not contain blank lines), generate appropriate
* Take a block of comment text that contains a list, generate appropriate
* block objects and append them to the output list.
*
* In simple cases, this will generate a single list block. For example, on
* the following input.
* * Item one.
* * Item two.
* * item three.
*
* * Item one.
* * Item two.
* * item three.
* TODO(taoalpha): maybe we should also support nested list
*
* However, if the list starts with a paragraph, it will need to also
* generate that paragraph. Consider the following input.
*
* A bit of text describing the context of the list:
* * List item one.
* * List item two.
* * Et cetera.
*
* In this case, `_makeList` generates a paragraph block object
* containing the non-bullet-prefixed text, followed by a list block.
*
* @param {!string} p The block containing the list (as well as a
* potential paragraph).
* @param {!Array<!Object>} out The list of blocks to append to.
* @param {!Array<string>} lines The block containing the list.
*/
_makeList(p, out) {
let block = null;
let inList = false;
let inParagraph = false;
const lines = p.split('\n');
_makeList(lines) {
const block = {type: 'list', items: []};
let line;
for (let i = 0; i < lines.length; i++) {
line = lines[i];
if (line[0] === '-' || line[0] === '*') {
// The next line looks like a list item. If not building a list
// already, then create one. Remove the list item marker (* or -) from
// the line.
if (!inList) {
if (inParagraph) {
// Add the finished paragraph block to the result.
inParagraph = false;
if (block !== null) {
out.push(block);
}
}
inList = true;
block = {type: 'list', items: []};
}
line = line.substring(1).trim();
} else if (!inList) {
// Otherwise, if a list has not yet been started, but the next line
// does not look like a list item, then add the line to a paragraph
// block. If a paragraph block has not yet been started, then create
// one.
if (!inParagraph) {
inParagraph = true;
block = {type: 'paragraph', text: ''};
} else {
block.text += ' ';
}
block.text += line;
continue;
}
line = line.substring(1).trim();
block.items.push(line);
}
if (block !== null) {
out.push(block);
}
return block;
}
_makeQuote(p) {
const quotedLines = p
.split('\n')
.map(l => l.replace(/^[ ]?>[ ]?/, ''))
.join('\n');
return {
type: 'quote',
blocks: this._computeBlocks(quotedLines),
};
_isRegularLine(line) {
// line can not be recognized by existing patterns
if (line === undefined) return false;
return !this._isQuote(line) && !this._isCodeMarkLine(line)
&& !this._isSingleLineCode(line) && !this._isList(line) &&
!this._isPreFormat(line);
}
_isQuote(p) {
return p.startsWith('> ') || p.startsWith(' > ');
_isQuote(line) {
return line && (line.startsWith('> ') || line.startsWith(' > '));
}
_isPreFormat(p) {
return p.includes('\n ') || p.includes('\n\t') ||
p.startsWith(' ') || p.startsWith('\t');
_isCodeMarkLine(line) {
return line && line.trim() === '```';
}
_isList(p) {
return p.includes('\n- ') || p.includes('\n* ') ||
p.startsWith('- ') || p.startsWith('* ');
_isSingleLineCode(line) {
return line && CODE_MARKER_PATTERN.test(line);
}
_isPreFormat(line) {
return line && /^[ \t]/.test(line);
}
_isList(line) {
return line && /^[-*] /.test(line);
}
/**
@@ -274,6 +274,12 @@
return bq;
}
if (block.type === 'code') {
const code = document.createElement('code');
code.textContent = block.text;
return code;
}
if (block.type === 'pre') {
return this._makeLinkedText(block.text, true);
}
@@ -289,20 +295,6 @@
}
});
}
_blocksToText(blocks) {
return blocks.map(block => {
if (block.type === 'paragraph' || block.type === 'pre') {
return block.text;
}
if (block.type === 'quote') {
return this._blocksToText(block.blocks);
}
if (block.type === 'list') {
return block.items.join('\n');
}
}).join('\n\n');
}
}
customElements.define(GrFormattedText.is, GrFormattedText);

View File

@@ -80,13 +80,11 @@ limitations under the License.
assertBlock(result, 0, 'paragraph', comment);
});
test('parse para break', () => {
test('parse para break without special blocks', () => {
const comment = 'Para 1\n\nPara 2\n\nPara 3';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'Para 1');
assertBlock(result, 1, 'paragraph', 'Para 2');
assertBlock(result, 2, 'paragraph', 'Para 3');
assert.lengthOf(result, 1);
assertBlock(result, 0, 'paragraph', comment);
});
test('parse quote', () => {
@@ -107,14 +105,6 @@ limitations under the License.
assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
});
test('parse excludes empty', () => {
const comment = 'Para 1\n\n\n\nPara 2';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'Para 1');
assertBlock(result, 1, 'paragraph', 'Para 2');
});
test('parse multiline quote', () => {
const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
const result = element._computeBlocks(comment);
@@ -122,7 +112,7 @@ limitations under the License.
assert.equal(result[0].type, 'quote');
assert.lengthOf(result[0].blocks, 1);
assertBlock(result[0].blocks, 0, 'paragraph',
'Quote line 1\nQuote line 2\nQuote line 3\n');
'Quote line 1\nQuote line 2\nQuote line 3');
});
test('parse pre', () => {
@@ -146,13 +136,6 @@ limitations under the License.
assertBlock(result, 0, 'pre', comment);
});
test('parse intermediate leading whitespace pre', () => {
const comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 1);
assertBlock(result, 0, 'pre', comment);
});
test('parse star list', () => {
const comment = '* Item 1\n* Item 2\n* Item 3';
const result = element._computeBlocks(comment);
@@ -197,19 +180,19 @@ limitations under the License.
'Parting words.';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 7);
assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.');
assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n');
assert.equal(result[1].type, 'quote');
assert.lengthOf(result[1].blocks, 1);
assertBlock(result[1].blocks, 0, 'paragraph',
'Quote\nacross\nnot many lines.');
assertBlock(result, 2, 'paragraph', 'Another paragraph');
assertBlock(result, 2, 'paragraph', 'Another paragraph\n');
assertListBlock(result, 3, 0, 'Series');
assertListBlock(result, 3, 1, 'of');
assertListBlock(result, 3, 2, 'list');
assertListBlock(result, 3, 3, 'items');
assertBlock(result, 4, 'paragraph', 'Yet another paragraph');
assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n');
assertBlock(result, 5, 'pre', '\tPreformatted text.');
assertBlock(result, 6, 'paragraph', 'Parting words.');
});
@@ -218,13 +201,13 @@ limitations under the License.
const comment = 'A\n\n* line 1\n* 2nd line';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'A');
assertBlock(result, 0, 'paragraph', 'A\n');
assertListBlock(result, 1, 0, 'line 1');
assertListBlock(result, 1, 1, '2nd line');
});
test('bullet list 2', () => {
const comment = 'A\n\n* line 1\n* 2nd line\n\nB';
const comment = 'A\n* line 1\n* 2nd line\n\nB';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A');
@@ -260,13 +243,13 @@ limitations under the License.
'* Be very unlucky\n';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:');
assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
assertListBlock(result, 1, 1, 'Be very unlucky');
});
test('dash list 1', () => {
const comment = 'A\n\n- line 1\n- 2nd line';
const comment = 'A\n- line 1\n- 2nd line';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'A');
@@ -275,7 +258,7 @@ limitations under the License.
});
test('dash list 2', () => {
const comment = 'A\n\n- line 1\n- 2nd line\n\nB';
const comment = 'A\n- line 1\n- 2nd line\n\nB';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A');
@@ -293,8 +276,18 @@ limitations under the License.
assertBlock(result, 1, 'paragraph', 'B');
});
test('nested list will NOT be recognized', () => {
// will be rendered as two separate lists
const comment = '- line 1\n - line with indentation\n- line 2';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 3);
assertListBlock(result, 0, 0, 'line 1');
assert.equal(result[1].type, 'pre');
assertListBlock(result, 2, 0, 'line 2');
});
test('pre format 1', () => {
const comment = 'A\n\n This is pre\n formatted';
const comment = 'A\n This is pre\n formatted';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'A');
@@ -302,7 +295,7 @@ limitations under the License.
});
test('pre format 2', () => {
const comment = 'A\n\n This is pre\n formatted\n\nbut this is not';
const comment = 'A\n This is pre\n formatted\n\nbut this is not';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A');
@@ -311,7 +304,7 @@ limitations under the License.
});
test('pre format 3', () => {
const comment = 'A\n\n Q\n <R>\n S\n\nB';
const comment = 'A\n Q\n <R>\n S\n\nB';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A');
@@ -338,7 +331,7 @@ limitations under the License.
});
test('quote 2', () => {
const comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'See this said:');
@@ -357,15 +350,65 @@ limitations under the License.
assert.equal(result[0].blocks[0].type, 'quote');
assert.lengthOf(result[0].blocks[0].blocks, 1);
assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
assertBlock(result[0].blocks, 1, 'paragraph', 'next\n');
assertBlock(result[0].blocks, 1, 'paragraph', 'next');
});
test('getTextContent', () => {
const comment = 'Paragraph\n\n pre\n\n* List\n* Of\n* Items\n\n> Quote';
element.content = comment;
const result = element.getTextContent();
const expected = 'Paragraph\n\n pre\n\nList\nOf\nItems\n\nQuote';
assert.equal(result, expected);
test('code 1', () => {
const comment = '```\n// test code\n```';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 1);
assert.equal(result[0].type, 'code');
assert.equal(result[0].text, '// test code');
});
test('code 2', () => {
const comment = 'test code\n```// test code```';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assert.equal(result[0].type, 'paragraph');
assert.equal(result[0].text, 'test code');
assert.equal(result[1].type, 'code');
assert.equal(result[1].text, '// test code');
});
test('code 3', () => {
const comment = 'test code\n```// test code```';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assert.equal(result[0].type, 'paragraph');
assert.equal(result[0].text, 'test code');
assert.equal(result[1].type, 'code');
assert.equal(result[1].text, '// test code');
});
test('not a code', () => {
const comment = 'test code\n```// test code';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 1);
assert.equal(result[0].type, 'paragraph');
assert.equal(result[0].text, 'test code\n```// test code');
});
test('not a code 2', () => {
const comment = 'test code\n```\n// test code';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 2);
assert.equal(result[0].type, 'paragraph');
assert.equal(result[0].text, 'test code');
assert.equal(result[1].type, 'paragraph');
assert.equal(result[1].text, '```\n// test code');
});
test('mix all 1', () => {
const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
'```// test code```\n\n> reference is here';
const result = element._computeBlocks(comment);
assert.lengthOf(result, 5);
assert.equal(result[0].type, 'pre');
assert.equal(result[1].type, 'list');
assert.equal(result[2].type, 'paragraph');
assert.equal(result[3].type, 'code');
assert.equal(result[4].type, 'quote');
});
test('_computeNodes called without config', () => {