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, p,
ul, ul,
code,
blockquote, blockquote,
gr-linked-text.pre { gr-linked-text.pre {
margin: 0 0 var(--spacing-m) 0; margin: 0 0 var(--spacing-m) 0;
} }
p, p,
ul, ul,
code,
blockquote { blockquote {
max-width: var(--gr-formatted-text-prose-max-width, none); 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 { :host(.noTrailingMargin) gr-linked-text.pre:last-child {
margin: 0; margin: 0;
} }
code,
blockquote { blockquote {
border-left: 1px solid #aaa; border-left: 1px solid #aaa;
padding: 0 var(--spacing-m); padding: 0 var(--spacing-m);
} }
code {
display: block;
white-space: pre;
color: var(--deemphasized-text-color);
}
li { li {
list-style-type: disc; list-style-type: disc;
margin-left: var(--spacing-xl); margin-left: var(--spacing-xl);

View File

@@ -19,6 +19,7 @@
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const QUOTE_MARKER_PATTERN = /\n\s?>\s/g; const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
/** @extends Polymer.Element */ /** @extends Polymer.Element */
class GrFormattedText extends Polymer.GestureEventListeners( 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) { _contentChanged(content) {
// In the case where the config may not be set (perhaps due to the // 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 // request for it still being in flight), set the content anyway to
@@ -99,9 +87,10 @@
* * 'quote' (Block quote.) * * 'quote' (Block quote.)
* * 'pre' (Pre-formatted text.) * * 'pre' (Pre-formatted text.)
* * 'list' (Unordered list.) * * 'list' (Unordered list.)
* * 'code' (code blocks.)
* *
* For blocks of type 'paragraph' and 'pre' there is a `text` property that * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
* maps to a string of the block's content. * 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 * For blocks of type 'list', there is an `items` property that maps to a
* list of strings representing the list items. * list of strings representing the list items.
@@ -118,123 +107,134 @@
if (!content) { return []; } if (!content) { return []; }
const result = []; const result = [];
const split = content.split('\n\n'); const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
let p;
for (let i = 0; i < split.length; i++) { for (let i = 0; i < lines.length; i++) {
p = split[i]; if (!lines[i].length) {
if (!p.length) { continue; } continue;
}
if (this._isQuote(p)) { if (this._isCodeMarkLine(lines[i])) {
result.push(this._makeQuote(p)); // handle multi-line code
} else if (this._isPreFormat(p)) { let nextI = i+1;
result.push({type: 'pre', text: p}); while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
} else if (this._isList(p)) { nextI++;
this._makeList(p, result); }
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 { } 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; return result;
} }
/** /**
* Take a block of comment text that contains a list and potentially * Take a block of comment text that contains a list, generate appropriate
* a paragraph (but does not contain blank lines), generate appropriate
* block objects and append them to the output list. * 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 one.
* * Item two. * * Item two.
* * item three. * * item three.
* *
* However, if the list starts with a paragraph, it will need to also * TODO(taoalpha): maybe we should also support nested list
* generate that paragraph. Consider the following input.
* *
* A bit of text describing the context of the list: * @param {!Array<string>} lines The block containing 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.
*/ */
_makeList(p, out) { _makeList(lines) {
let block = null; const block = {type: 'list', items: []};
let inList = false;
let inParagraph = false;
const lines = p.split('\n');
let line; let line;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
line = lines[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(); 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;
}
block.items.push(line); block.items.push(line);
} }
if (block !== null) { return block;
out.push(block);
}
} }
_makeQuote(p) { _isRegularLine(line) {
const quotedLines = p // line can not be recognized by existing patterns
.split('\n') if (line === undefined) return false;
.map(l => l.replace(/^[ ]?>[ ]?/, '')) return !this._isQuote(line) && !this._isCodeMarkLine(line)
.join('\n'); && !this._isSingleLineCode(line) && !this._isList(line) &&
return { !this._isPreFormat(line);
type: 'quote',
blocks: this._computeBlocks(quotedLines),
};
} }
_isQuote(p) { _isQuote(line) {
return p.startsWith('> ') || p.startsWith(' > '); return line && (line.startsWith('> ') || line.startsWith(' > '));
} }
_isPreFormat(p) { _isCodeMarkLine(line) {
return p.includes('\n ') || p.includes('\n\t') || return line && line.trim() === '```';
p.startsWith(' ') || p.startsWith('\t');
} }
_isList(p) { _isSingleLineCode(line) {
return p.includes('\n- ') || p.includes('\n* ') || return line && CODE_MARKER_PATTERN.test(line);
p.startsWith('- ') || p.startsWith('* '); }
_isPreFormat(line) {
return line && /^[ \t]/.test(line);
}
_isList(line) {
return line && /^[-*] /.test(line);
} }
/** /**
@@ -274,6 +274,12 @@
return bq; return bq;
} }
if (block.type === 'code') {
const code = document.createElement('code');
code.textContent = block.text;
return code;
}
if (block.type === 'pre') { if (block.type === 'pre') {
return this._makeLinkedText(block.text, true); 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); customElements.define(GrFormattedText.is, GrFormattedText);

View File

@@ -80,13 +80,11 @@ limitations under the License.
assertBlock(result, 0, 'paragraph', comment); 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 comment = 'Para 1\n\nPara 2\n\nPara 3';
const result = element._computeBlocks(comment); const result = element._computeBlocks(comment);
assert.lengthOf(result, 3); assert.lengthOf(result, 1);
assertBlock(result, 0, 'paragraph', 'Para 1'); assertBlock(result, 0, 'paragraph', comment);
assertBlock(result, 1, 'paragraph', 'Para 2');
assertBlock(result, 2, 'paragraph', 'Para 3');
}); });
test('parse quote', () => { test('parse quote', () => {
@@ -107,14 +105,6 @@ limitations under the License.
assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text'); 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', () => { test('parse multiline quote', () => {
const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n'; const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
const result = element._computeBlocks(comment); const result = element._computeBlocks(comment);
@@ -122,7 +112,7 @@ limitations under the License.
assert.equal(result[0].type, 'quote'); assert.equal(result[0].type, 'quote');
assert.lengthOf(result[0].blocks, 1); assert.lengthOf(result[0].blocks, 1);
assertBlock(result[0].blocks, 0, 'paragraph', 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', () => { test('parse pre', () => {
@@ -146,13 +136,6 @@ limitations under the License.
assertBlock(result, 0, 'pre', comment); 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', () => { test('parse star list', () => {
const comment = '* Item 1\n* Item 2\n* Item 3'; const comment = '* Item 1\n* Item 2\n* Item 3';
const result = element._computeBlocks(comment); const result = element._computeBlocks(comment);
@@ -197,19 +180,19 @@ limitations under the License.
'Parting words.'; 'Parting words.';
const result = element._computeBlocks(comment); const result = element._computeBlocks(comment);
assert.lengthOf(result, 7); 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.equal(result[1].type, 'quote');
assert.lengthOf(result[1].blocks, 1); assert.lengthOf(result[1].blocks, 1);
assertBlock(result[1].blocks, 0, 'paragraph', assertBlock(result[1].blocks, 0, 'paragraph',
'Quote\nacross\nnot many lines.'); '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, 0, 'Series');
assertListBlock(result, 3, 1, 'of'); assertListBlock(result, 3, 1, 'of');
assertListBlock(result, 3, 2, 'list'); assertListBlock(result, 3, 2, 'list');
assertListBlock(result, 3, 3, 'items'); 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, 5, 'pre', '\tPreformatted text.');
assertBlock(result, 6, 'paragraph', 'Parting words.'); 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 comment = 'A\n\n* line 1\n* 2nd line';
const result = element._computeBlocks(comment); const result = element._computeBlocks(comment);
assert.lengthOf(result, 2); assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'A'); assertBlock(result, 0, 'paragraph', 'A\n');
assertListBlock(result, 1, 0, 'line 1'); assertListBlock(result, 1, 0, 'line 1');
assertListBlock(result, 1, 1, '2nd line'); assertListBlock(result, 1, 1, '2nd line');
}); });
test('bullet list 2', () => { 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); const result = element._computeBlocks(comment);
assert.lengthOf(result, 3); assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A'); assertBlock(result, 0, 'paragraph', 'A');
@@ -260,13 +243,13 @@ limitations under the License.
'* Be very unlucky\n'; '* Be very unlucky\n';
const result = element._computeBlocks(comment); const result = element._computeBlocks(comment);
assert.lengthOf(result, 2); 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, 0, 'Be on IMAP or EAS (not on POP)');
assertListBlock(result, 1, 1, 'Be very unlucky'); assertListBlock(result, 1, 1, 'Be very unlucky');
}); });
test('dash list 1', () => { 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); const result = element._computeBlocks(comment);
assert.lengthOf(result, 2); assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'A'); assertBlock(result, 0, 'paragraph', 'A');
@@ -275,7 +258,7 @@ limitations under the License.
}); });
test('dash list 2', () => { 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); const result = element._computeBlocks(comment);
assert.lengthOf(result, 3); assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A'); assertBlock(result, 0, 'paragraph', 'A');
@@ -293,8 +276,18 @@ limitations under the License.
assertBlock(result, 1, 'paragraph', 'B'); 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', () => { 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); const result = element._computeBlocks(comment);
assert.lengthOf(result, 2); assert.lengthOf(result, 2);
assertBlock(result, 0, 'paragraph', 'A'); assertBlock(result, 0, 'paragraph', 'A');
@@ -302,7 +295,7 @@ limitations under the License.
}); });
test('pre format 2', () => { 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); const result = element._computeBlocks(comment);
assert.lengthOf(result, 3); assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A'); assertBlock(result, 0, 'paragraph', 'A');
@@ -311,7 +304,7 @@ limitations under the License.
}); });
test('pre format 3', () => { 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); const result = element._computeBlocks(comment);
assert.lengthOf(result, 3); assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'A'); assertBlock(result, 0, 'paragraph', 'A');
@@ -338,7 +331,7 @@ limitations under the License.
}); });
test('quote 2', () => { 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); const result = element._computeBlocks(comment);
assert.lengthOf(result, 3); assert.lengthOf(result, 3);
assertBlock(result, 0, 'paragraph', 'See this said:'); assertBlock(result, 0, 'paragraph', 'See this said:');
@@ -357,15 +350,65 @@ limitations under the License.
assert.equal(result[0].blocks[0].type, 'quote'); assert.equal(result[0].blocks[0].type, 'quote');
assert.lengthOf(result[0].blocks[0].blocks, 1); assert.lengthOf(result[0].blocks[0].blocks, 1);
assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior'); 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', () => { test('code 1', () => {
const comment = 'Paragraph\n\n pre\n\n* List\n* Of\n* Items\n\n> Quote'; const comment = '```\n// test code\n```';
element.content = comment; const result = element._computeBlocks(comment);
const result = element.getTextContent(); assert.lengthOf(result, 1);
const expected = 'Paragraph\n\n pre\n\nList\nOf\nItems\n\nQuote'; assert.equal(result[0].type, 'code');
assert.equal(result, expected); 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', () => { test('_computeNodes called without config', () => {