blob: ddca5c5a1d0e25cfa589d0cde60cf4be8f35629b [file] [log] [blame]
/**
* @license
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../gr-linked-text/gr-linked-text';
import {CommentLinks} from '../../../types/common';
import {appContext} from '../../../services/app-context';
import {GrLitElement} from '../../lit/gr-lit-element';
import {css, customElement, html, property} from 'lit-element';
const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
interface Block {
type: string;
text?: string;
blocks?: Block[];
items?: string[];
}
declare global {
interface HTMLElementTagNameMap {
'gr-formatted-text': GrFormattedText;
}
}
@customElement('gr-formatted-text')
export class GrFormattedText extends GrLitElement {
@property({type: String})
content?: string;
@property({type: Object})
config?: CommentLinks;
@property({type: Boolean, reflect: true})
noTrailingMargin = false;
private readonly reporting = appContext.reportingService;
static get styles() {
return [
css`
:host {
display: block;
font-family: var(--font-family);
}
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);
}
:host([noTrailingMargin]) p:last-child,
:host([noTrailingMargin]) ul:last-child,
:host([noTrailingMargin]) blockquote:last-child,
: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-wrap;
color: var(--deemphasized-text-color);
}
li {
list-style-type: disc;
margin-left: var(--spacing-xl);
}
code,
gr-linked-text.pre {
font-family: var(--monospace-font-family);
font-size: var(--font-size-code);
/* usually 16px = 12px + 4px */
line-height: calc(var(--font-size-code) + var(--spacing-s));
}
`,
];
}
render() {
const nodes = this._computeNodes(this._computeBlocks(this.content));
return html`<div id="container">${nodes}</div>`;
}
/**
* Given a source string, parse into an array of block objects. Each block
* has a `type` property which takes any of the following values.
* * 'paragraph'
* * 'quote' (Block quote.)
* * 'pre' (Pre-formatted text.)
* * 'list' (Unordered list.)
* * 'code' (code blocks.)
*
* 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.
*
* For blocks of type 'quote', there is a `blocks` property that maps to a
* list of blocks contained in the quote.
*
* NOTE: Strings appearing in all block objects are NOT escaped.
*/
_computeBlocks(content?: string): Block[] {
if (!content) return [];
const result = [];
const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
for (let i = 0; i < lines.length; i++) {
if (!lines[i].length) {
continue;
}
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]) &&
!this._isWhitespaceLine(lines[nextI]) &&
lines[nextI].length)
) {
nextI++;
}
result.push({
type: 'pre',
text: lines.slice(i, nextI).join('\n'),
});
i = nextI - 1;
} else {
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, generate appropriate
* block objects and append them to the output list.
*
* * Item one.
* * Item two.
* * item three.
*
* TODO(taoalpha): maybe we should also support nested list
*
* @param lines The block containing the list.
*/
_makeList(lines: string[]) {
const items = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
line = line.substring(1).trim();
items.push(line);
}
return {type: 'list', items};
}
_isRegularLine(line: string) {
// 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(line: string) {
return line && (line.startsWith('> ') || line.startsWith(' > '));
}
_isCodeMarkLine(line: string) {
return line && line.trim() === '```';
}
_isSingleLineCode(line: string) {
return line && CODE_MARKER_PATTERN.test(line);
}
_isPreFormat(line: string) {
return line && /^[ \t]/.test(line) && !this._isWhitespaceLine(line);
}
_isList(line: string) {
return line && /^[-*] /.test(line);
}
_isWhitespaceLine(line: string) {
return line && /^\s+$/.test(line);
}
_makeLinkedText(content = '', isPre?: boolean) {
const text = document.createElement('gr-linked-text');
text.config = this.config;
text.content = content;
text.pre = true;
if (isPre) {
text.classList.add('pre');
}
return text;
}
/**
* Map an array of block objects to an array of DOM nodes.
*/
_computeNodes(blocks: Block[]): HTMLElement[] {
return blocks.map(block => {
if (block.type === 'paragraph') {
const p = document.createElement('p');
p.appendChild(this._makeLinkedText(block.text));
return p;
}
if (block.type === 'quote') {
const bq = document.createElement('blockquote');
for (const node of this._computeNodes(block.blocks || [])) {
if (node) bq.appendChild(node);
}
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);
}
if (block.type === 'list') {
const ul = document.createElement('ul');
const items = block.items || [];
for (const item of items) {
const li = document.createElement('li');
li.appendChild(this._makeLinkedText(item));
ul.appendChild(li);
}
return ul;
}
this.reporting.error(new Error(`Unrecognized block type: ${block.type}`));
return document.createElement('span');
});
}
}