blob: 17b659fce963d949f317fef73fd1637bd361e8d6 [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 '../../../styles/shared-styles';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {customElement, property} from '@polymer/decorators';
import {htmlTemplate} from './gr-formatted-text_html';
import {CommentLinks} from '../../../types/common';
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;
}
}
export interface GrFormattedText {
$: {
container: HTMLElement;
};
}
@customElement('gr-formatted-text')
export class GrFormattedText extends PolymerElement {
static get template() {
return htmlTemplate;
}
@property({type: String, observer: '_contentChanged'})
content?: string;
@property({type: Object})
config?: CommentLinks;
@property({type: Boolean})
noTrailingMargin = false;
static get observers() {
return ['_contentOrConfigChanged(content, config)'];
}
/** @override */
ready() {
super.ready();
if (this.noTrailingMargin) {
this.classList.add('noTrailingMargin');
}
}
_contentChanged(content: string) {
// 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
// prevent waiting on the config to display the text.
if (this.config) return;
this._contentOrConfigChanged(content);
}
/**
* Given a source string, update the DOM inside #container.
*/
_contentOrConfigChanged(content?: string) {
const container = this.$.container;
// Remove existing content.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Add new content.
for (const node of this._computeNodes(this._computeBlocks(content))) {
if (node) container.appendChild(node);
}
}
/**
* 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]) && 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);
}
_isList(line: string) {
return line && /^[-*] /.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;
}
console.warn('Unrecognized type.');
return document.createElement('span');
});
}
}