blob: b45edc3f9169142445cd6073d132a99ef5d6fc94 [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {css, html, LitElement} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {
htmlEscape,
sanitizeHtml,
sanitizeHtmlToFragment,
} from '../../../utils/inner-html-util';
import {unescapeHTML} from '../../../utils/syntax-util';
import '@polymer/marked-element';
import {resolve} from '../../../models/dependency';
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
import {CommentLinks, EmailAddress} from '../../../api/rest-api';
import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
import '../gr-account-chip/gr-account-chip';
import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
import {
getUserSuggestionFromString,
USER_SUGGESTION_INFO_STRING,
} from '../../../utils/comment-util';
import {sameOrigin} from '../../../utils/url-util';
/**
* This element optionally renders markdown and also applies some regex
* replacements to linkify key parts of the text defined by the host's config.
*/
@customElement('gr-formatted-text')
export class GrFormattedText extends LitElement {
@property({type: String})
content = '';
@property({type: Boolean})
markdown = false;
@state()
private repoCommentLinks: CommentLinks = {};
private readonly getConfigModel = resolve(this, configModelToken);
// Private const but used in tests.
// Limit the length of markdown because otherwise the markdown lexer will
// run out of memory causing the tab to crash.
@state()
MARKDOWN_LIMIT = 100000;
/**
* Note: Do not use sharedStyles or other styles here that should not affect
* the generated HTML of the markdown.
*/
static override get styles() {
return [
css`
a {
color: var(--link-color);
}
p,
ul,
code,
blockquote {
margin: 0 0 var(--spacing-m) 0;
max-width: var(--gr-formatted-text-prose-max-width, none);
}
p:last-child,
ul:last-child,
blockquote:last-child,
pre:last-child {
margin: 0;
}
blockquote {
border-left: var(--spacing-xxs) solid
var(--comment-quote-marker-color);
padding: 0 var(--spacing-m);
}
code {
background-color: var(--background-color-secondary);
border: var(--spacing-xxs) solid var(--border-color);
display: block;
font-family: var(--monospace-font-family);
font-size: var(--font-size-code);
line-height: var(--line-height-mono);
margin: var(--spacing-m) 0;
padding: var(--spacing-xxs) var(--spacing-s);
overflow-x: auto;
/* Pre will preserve whitespace and line breaks but not wrap */
white-space: pre;
}
/* Non-multiline code elements need display:inline to shrink and not take
a whole row */
:not(pre) > code {
display: inline;
}
li {
margin-left: var(--spacing-xl);
}
gr-account-chip {
display: inline;
}
.plaintext {
font: inherit;
white-space: var(--linked-text-white-space, pre-wrap);
word-wrap: var(--linked-text-word-wrap, break-word);
}
.markdown-html {
/* code overrides white-space to pre, everything else should wrap as
normal. */
white-space: normal;
/* prose will automatically wrap but inline <code> blocks won't and we
should overflow in that case rather than wrapping or leaking out */
overflow-x: auto;
}
`,
];
}
constructor() {
super();
subscribe(
this,
() => this.getConfigModel().repoCommentLinks$,
repoCommentLinks => {
this.repoCommentLinks = repoCommentLinks;
// Always linkify URLs starting with https?://
this.repoCommentLinks['ALWAYS_LINK_HTTP'] = {
match: '(https?://((?!&(gt|lt|quot|apos);)\\S)+[\\w/~-])',
link: '$1',
enabled: true,
};
}
);
}
override render() {
if (this.markdown && this.content.length < this.MARKDOWN_LIMIT) {
return this.renderAsMarkdown();
} else {
return this.renderAsPlaintext();
}
}
private renderAsPlaintext() {
const linkedText = linkifyUrlsAndApplyRewrite(
htmlEscape(this.content).toString(),
this.repoCommentLinks
);
return html`
<pre class="plaintext">${sanitizeHtmlToFragment(linkedText)}</pre>
`;
}
private renderAsMarkdown() {
// Bind `this` via closure.
const boundRewriteText = (text: string) => {
const nonAsteriskRewrites = Object.fromEntries(
Object.entries(this.repoCommentLinks).filter(
([_name, rewrite]) => !rewrite.match.includes('\\*')
)
);
return linkifyUrlsAndApplyRewrite(text, nonAsteriskRewrites);
};
// Due to a tokenizer bug in the old version of markedjs we use, text with a
// single asterisk is separated into 2 tokens before passing to renderer
// ['text'] which breaks our rewrites that would span across the 2 tokens.
// Since upgrading our markedjs version is infeasible, we are applying those
// asterisk rewrites again at the end (using renderer['paragraph'] hook)
// after all the nodes are combined.
// Bind `this` via closure.
const boundRewriteAsterisks = (text: string) => {
const asteriskRewrites = Object.fromEntries(
Object.entries(this.repoCommentLinks).filter(([_name, rewrite]) =>
rewrite.match.includes('\\*')
)
);
const linkedText = linkifyUrlsAndApplyRewrite(text, asteriskRewrites);
return `<p>${linkedText}</p>`;
};
// We are overriding some marked-element renderers for a few reasons:
// 1. Disable inline images as a design/policy choice.
// 2. Inline code blocks ("codespan") do not unescape HTML characters when
// rendering without <pre> and so we must do this manually.
// <marked-element> is already escaping these internally. See test
// covering this.
// 3. Multiline code blocks ("code") is similarly handling escaped
// characters using <pre>. The convention is to only use <pre> for multi-
// line code blocks so it is not used for inline code blocks. See test
// for this.
// 4. Rewrite plain text ("text") to apply linking and other config-based
// rewrites. Text within code blocks is not passed here.
// 5. Open links in a new tab by rendering with target="_blank" attribute.
// 6. Relative links without "/" prefix are assumed to be absolute links.
function customRenderer(renderer: {[type: string]: Function}) {
renderer['link'] = (href: string, title: string, text: string) => {
if (
!href.startsWith('https://') &&
!href.startsWith('mailto:') &&
!href.startsWith('http://') &&
!href.startsWith('/')
) {
href = `https://${href}`;
}
/* HTML */
return `<a
href="${href}"
${sameOrigin(href) ? '' : 'target="_blank" rel="noopener noreferrer"'}
${title ? `title="${title}"` : ''}
>${text}</a
>`;
};
renderer['image'] = (href: string, _title: string, text: string) =>
`![${text}](${href})`;
renderer['codespan'] = (text: string) =>
`<code>${unescapeHTML(text)}</code>`;
renderer['code'] = (text: string, infostring: string) => {
if (infostring === USER_SUGGESTION_INFO_STRING) {
// default santizer in markedjs is very restrictive, we need to use
// existing html element to mark element. We cannot use css class for
// it. Therefore we pick mark - as not frequently used html element to
// represent unconverted gr-user-suggestion-fix.
// TODO(milutin): Find a way to override sanitizer to directly use
// gr-user-suggestion-fix
return `<mark>${text}</mark>`;
} else {
return `<pre><code>${text}</code></pre>`;
}
};
// <marked-element> internals will be in charge of calling our custom
// renderer so we write these functions separately so that 'this' is
// preserved via closure.
renderer['paragraph'] = boundRewriteAsterisks;
renderer['text'] = boundRewriteText;
}
// The child with slot is optional but allows us control over the styling.
// The `callback` property lets us do a final sanitization of the output
// HTML string before it is rendered by `<marked-element>` in case any
// rewrites have been abused to attempt an XSS attack.
return html`
<marked-element
.markdown=${this.escapeAllButBlockQuotes(this.content)}
.breaks=${true}
.renderer=${customRenderer}
.callback=${(_error: string | null, contents: string) =>
sanitizeHtml(contents)}
>
<div class="markdown-html" slot="markdown-html"></div>
</marked-element>
`;
}
private escapeAllButBlockQuotes(text: string) {
// Escaping the message should be done first to make sure user's literal
// input does not get rendered without affecting html added in later steps.
text = htmlEscape(text).toString();
// Unescape block quotes '>'. This is slightly dangerous as '>' can be used
// in HTML fragments, but it is insufficient on it's own.
for (;;) {
const newText = text.replace(
/(^|\n)((?:\s{0,3}&gt;)*\s{0,3})&gt;/g,
'$1$2>'
);
if (newText === text) {
break;
}
text = newText;
}
return text;
}
override updated() {
// Look for @mentions and replace them with an account-label chip.
this.convertEmailsToAccountChips();
this.convertCodeToSuggestions();
}
private convertEmailsToAccountChips() {
for (const emailLink of this.renderRoot.querySelectorAll(
'a[href^="mailto"]'
)) {
const previous = emailLink.previousSibling;
// This Regexp matches the beginning of the MENTIONS_REGEX at the end of
// an element.
if (
previous?.nodeName === '#text' &&
previous?.textContent?.match(/(^|\s)@$/)
) {
const accountChip = document.createElement('gr-account-chip');
accountChip.account = {
email: emailLink.textContent as EmailAddress,
};
accountChip.removable = false;
// Remove the trailing @ from the previous element.
previous.textContent = previous.textContent.slice(0, -1);
emailLink.parentNode?.replaceChild(accountChip, emailLink);
}
}
}
private convertCodeToSuggestions() {
const marks = this.renderRoot.querySelectorAll('mark');
if (marks.length > 0) {
const userSuggestionMark = marks[0];
const userSuggestion = document.createElement('gr-user-suggestion-fix');
// Temporary workaround for bug - tabs replacement
if (this.content.includes('\t')) {
userSuggestion.textContent = getUserSuggestionFromString(this.content);
} else {
userSuggestion.textContent = userSuggestionMark.textContent ?? '';
}
userSuggestionMark.parentNode?.replaceChild(
userSuggestion,
userSuggestionMark
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-formatted-text': GrFormattedText;
}
}