// Copyright 2020 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable @devtools/no-lit-render-outside-of-view, @devtools/enforce-custom-element-definitions-location */ import './CodeBlock.js'; import './MarkdownImage.js'; import '../../kit/kit.js'; import type * as Marked from '../../../third_party/marked/marked.js'; import * as Lit from '../../lit/lit.js'; import * as VisualLogging from '../../visual_logging/visual_logging.js'; import type * as Codeblock from './CodeBlock.js'; import {getMarkdownLink} from './MarkdownLinksMap.js'; import markdownViewStyles from './markdownView.css.js'; const html = Lit.html; const render = Lit.render; export interface MarkdownViewData { tokens: Marked.Marked.Token[]; renderer?: MarkdownLitRenderer; animationEnabled?: boolean; } export type CodeTokenWithCitation = Marked.Marked.Tokens.Generic&{ citations: Codeblock.Citation[], }; export class MarkdownView extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); #tokenData: readonly Marked.Marked.Token[] = []; #renderer = new MarkdownLitRenderer(); #animationEnabled = false; #isAnimating = false; set data(data: MarkdownViewData) { this.#tokenData = data.tokens; if (data.renderer) { this.#renderer = data.renderer; } if (data.animationEnabled) { this.#animationEnabled = true; this.#renderer.addCustomClasses({ paragraph: 'pending', heading: 'pending', list_item: 'pending', code: 'pending', }); } else { this.#finishAnimations(); } this.#update(); } #finishAnimations(): void { const animatingElements = this.#shadow.querySelectorAll('.animating'); for (const element of animatingElements) { element.classList.remove('animating'); } const pendingElements = this.#shadow.querySelectorAll('.pending'); for (const element of pendingElements) { element.classList.remove('pending'); } this.#isAnimating = false; this.#animationEnabled = false; this.#renderer.removeCustomClasses({ paragraph: 'pending', heading: 'pending', list_item: 'pending', code: 'pending', }); } #animate(): void { if (this.#isAnimating) { return; } this.#isAnimating = true; const reveal = (): void => { const pendingElement = this.#shadow.querySelector('.pending'); if (!pendingElement) { this.#isAnimating = false; return; } pendingElement.addEventListener('animationend', () => { pendingElement.classList.remove('animating'); reveal(); }, {once: true}); pendingElement.classList.remove('pending'); pendingElement.classList.add('animating'); }; reveal(); } #update(): void { this.#render(); if (this.#animationEnabled) { this.#animate(); } } #render(): void { // Disabled until https://crbug.com/1079231 is fixed. // clang-format off render(html`
${this.#tokenData.map(token => this.#renderer.renderToken(token))}
`, this.#shadow, {host: this}); // clang-format on } } customElements.define('devtools-markdown-view', MarkdownView); declare global { interface HTMLElementTagNameMap { 'devtools-markdown-view': MarkdownView; } } /** * Default renderer is used for the IssuesPanel and allows only well-known images and links to be embedded. */ export class MarkdownLitRenderer { #customClasses: Record> = {}; addCustomClasses(customClasses: Record): void { for (const [type, className] of Object.entries(customClasses)) { if (!this.#customClasses[type]) { this.#customClasses[type] = new Set(); } this.#customClasses[type].add(className); } } removeCustomClasses(customClasses: Record): void { for (const [type, className] of Object.entries(customClasses)) { if (this.#customClasses[type]) { this.#customClasses[type].delete(className); } } } protected customClassMapForToken(type: Marked.Marked.Token['type']): Lit.Directive.DirectiveResult { const classNames = this.#customClasses[type] || new Set(); const classInfo = Object.fromEntries([...classNames].map(className => [className, true])); return Lit.Directives.classMap(classInfo); } renderChildTokens(token: Marked.Marked.Token): Lit.LitTemplate[] { if ('tokens' in token && token.tokens) { return token.tokens.map(token => this.renderToken(token)); } throw new Error('Tokens not found'); } /** * Unescape will get rid of the escaping done by Marked to avoid double escaping due to escaping it also with lit. * Table taken from: front_end/third_party/marked/package/src/helpers.js */ unescape(text: string): string { const escapeReplacements = new Map([ ['&', '&'], ['<', '<'], ['>', '>'], ['"', '"'], [''', '\''], ]); return text.replace(/&(amp|lt|gt|quot|#39);/g, (matchedString: string) => { const replacement = escapeReplacements.get(matchedString); return replacement ? replacement : matchedString; }); } renderText(token: Marked.Marked.Token): Lit.TemplateResult { if ('tokens' in token && token.tokens) { return html`${this.renderChildTokens(token)}`; } // Due to unescaping, unescaped html entities (see escapeReplacements' keys) will be rendered // as their corresponding symbol while the rest will be rendered as verbatim. // Marked's escape function can be found in front_end/third_party/marked/package/src/helpers.js return html`${this.unescape('text' in token ? token.text : '')}`; } renderHeading(heading: Marked.Marked.Tokens.Heading): Lit.TemplateResult { const customClass = this.customClassMapForToken('heading'); switch (heading.depth) { case 1: return html`

${this.renderText(heading)}

`; case 2: return html`

${this.renderText(heading)}

`; case 3: return html`

${this.renderText(heading)}

`; case 4: return html`

${this.renderText(heading)}

`; case 5: return html`
${this.renderText(heading)}
`; default: return html`
${this.renderText(heading)}
`; } } renderCodeBlock(token: Marked.Marked.Tokens.Code): Lit.TemplateResult { // clang-format off return html` `; // clang-format on } templateForToken(token: Marked.Marked.MarkedToken): Lit.LitTemplate|null { switch (token.type) { case 'paragraph': return html`

${this.renderChildTokens(token)}

`; case 'list': return html`
    ${token.items.map(token => { return this.renderToken(token); })}
`; case 'list_item': return html`
  • ${this.renderChildTokens(token)}
  • `; case 'text': return this.renderText(token); case 'codespan': return html`${this.unescape(token.text)}`; case 'code': return this.renderCodeBlock(token); case 'space': return Lit.nothing; case 'link': return html`${token.text}`; case 'image': return html``; case 'heading': return this.renderHeading(token); case 'strong': return html`${this.renderText(token)}`; case 'em': return html`${this.renderText(token)}`; default: return null; } } renderToken(token: Marked.Marked.Token): Lit.LitTemplate { const template = this.templateForToken(token as Marked.Marked.MarkedToken); if (template === null) { throw new Error(`Markdown token type '${token.type}' not supported.`); } return template; } } /** * Renderer used in Console Insights and AI assistance for the text generated by an LLM. */ export class MarkdownInsightRenderer extends MarkdownLitRenderer { #citationClickHandler: (index: number) => void; constructor(citationClickHandler?: (index: number) => void) { super(); this.#citationClickHandler = citationClickHandler || (() => {}); this.addCustomClasses({heading: 'insight'}); } override renderToken(token: Marked.Marked.Token): Lit.LitTemplate { const template = this.templateForToken(token as Marked.Marked.MarkedToken); if (template === null) { return html`${token.raw}`; } return template; } sanitizeUrl(maybeUrl: string): string|null { try { const url = new URL(maybeUrl); if (url.protocol === 'https:' || url.protocol === 'http:') { return url.toString(); } return null; } catch { return null; } } detectCodeLanguage(token: Marked.Marked.Tokens.Code): string { if (token.lang) { return token.lang; } if (/^(\.|#)?[\w:\[\]="'-\.]+ ?{/m.test(token.text) || /^@import/.test(token.text)) { return 'css'; } if (/^(var|const|let|function|async|import)\s/.test(token.text)) { return 'js'; } return ''; } override templateForToken(token: Marked.Marked.Token): Lit.LitTemplate|null { switch (token.type) { case 'heading': return this.renderHeading(token as Marked.Marked.Tokens.Heading); case 'link': case 'image': { const sanitizedUrl = this.sanitizeUrl(token.href); if (!sanitizedUrl) { return null; } // Only links pointing to resources within DevTools can be rendered here. return html`${token.text ?? token.href}`; } case 'code': return html` `; case 'citation': // clang-format off return html``; // clang-format on } return super.templateForToken(token as Marked.Marked.MarkedToken); } }