blob: 5a1db301167eb73eb27fba8fb9aa8e79e97c653a [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 {KnownExperimentId} from '../../../services/flags/flags';
import {getAppContext} from '../../../services/app-context';
/**
* 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 flagsService = getAppContext().flagsService;
private readonly getConfigModel = resolve(this, configModelToken);
/**
* Note: Do not use sharedStyles or other styles here that should not affect
* the generated HTML of the markdown.
*/
static override styles = [
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;
}
p {
/* 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;
}
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);
}
`,
];
constructor() {
super();
subscribe(
this,
() => this.getConfigModel().repoCommentLinks$,
repoCommentLinks => (this.repoCommentLinks = repoCommentLinks)
);
}
override render() {
if (this.markdown) {
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() {
// <marked-element> internals will be in charge of calling our custom
// renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
// closure.
const boundRewriteText = (text: string) =>
linkifyUrlsAndApplyRewrite(text, this.repoCommentLinks);
// 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.
function customRenderer(renderer: {[type: string]: Function}) {
renderer['link'] = (href: string, title: string, text: string) =>
/* HTML */
`<a
href="${href}"
target="_blank"
${title ? `title="${title}"` : ''}
rel="noopener"
>${text}</a
>`;
renderer['image'] = (href: string, _title: string, text: string) =>
`![${text}](${href})`;
renderer['codespan'] = (text: string) =>
`<code>${unescapeHTML(text)}</code>`;
renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
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 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.
text = text.replace(/(^|\n)&gt;/g, '$1>');
return text;
}
override updated() {
// Look for @mentions and replace them with an account-label chip.
if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
this.convertEmailsToAccountChips();
}
}
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);
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-formatted-text': GrFormattedText;
}
}