chrome-devtools-frontend
Version:
Chrome DevTools UI
153 lines (132 loc) • 5.72 kB
text/typescript
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Marked from '../../third_party/marked/marked.js';
/**
* The description that subclasses of `Issue` use define the issue appearance:
* `file` specifies the markdown file, substitutions can be used to replace
* placeholders with, e.g. URLs. The `links` property is used to specify the
* links at the bottom of the issue.
*/
export interface MarkdownIssueDescription {
file: string;
substitutions?: Map<string, string>;
links: {link: string, linkTitle: string}[];
}
export interface LazyMarkdownIssueDescription {
file: string;
substitutions?: Map<string, () => string>;
links: {link: string, linkTitle: () => string}[];
}
/**
* A lazy version of the description. Allows to specify a description as a
* constant and at the same time delays resolution of the substitutions
* and/or link titles to allow localization.
*/
export function resolveLazyDescription(lazyDescription: LazyMarkdownIssueDescription): MarkdownIssueDescription {
function linksMap(currentLink: {link: string, linkTitle: () => string}): {link: string, linkTitle: string} {
return {link: currentLink.link, linkTitle: currentLink.linkTitle()};
}
const substitutionMap = new Map();
lazyDescription.substitutions?.forEach((value, key) => {
substitutionMap.set(key, value());
});
const description = {
file: lazyDescription.file,
links: lazyDescription.links.map(linksMap),
substitutions: substitutionMap,
};
return description;
}
/**
* A loaded and parsed issue description. This is usually obtained by loading
* a `MarkdownIssueDescription` via `createIssueDescriptionFromMarkdown`.
*/
export interface IssueDescription {
title: string;
markdown: Marked.Marked.Token[];
links: {link: string, linkTitle: string}[];
}
export async function getFileContent(url: URL): Promise<string> {
try {
const response = await fetch(url.toString());
return response.text();
} catch (error) {
throw new Error(
`Markdown file ${url.toString()} not found. Make sure it is correctly listed in the relevant BUILD.gn files.`);
}
}
export async function getMarkdownFileContent(filename: string): Promise<string> {
return getFileContent(new URL(`descriptions/${filename}`, import.meta.url));
}
export async function createIssueDescriptionFromMarkdown(description: MarkdownIssueDescription):
Promise<IssueDescription> {
const rawMarkdown = await getMarkdownFileContent(description.file);
const rawMarkdownWithPlaceholdersReplaced = substitutePlaceholders(rawMarkdown, description.substitutions);
return createIssueDescriptionFromRawMarkdown(rawMarkdownWithPlaceholdersReplaced, description);
}
/**
* This function is exported separately for unit testing.
*/
export function createIssueDescriptionFromRawMarkdown(
markdown: string, description: MarkdownIssueDescription): IssueDescription {
const markdownAst = Marked.Marked.lexer(markdown);
const title = findTitleFromMarkdownAst(markdownAst);
if (!title) {
throw new Error('Markdown issue descriptions must start with a heading');
}
return {
title,
markdown: markdownAst.slice(1),
links: description.links,
};
}
const validPlaceholderMatchPattern = /\{(PLACEHOLDER_[a-zA-Z][a-zA-Z0-9]*)\}/g;
const validPlaceholderNamePattern = /PLACEHOLDER_[a-zA-Z][a-zA-Z0-9]*/;
/**
* Replaces placeholders in markdown text with a string provided by the
* `substitutions` map. To keep mental overhead to a minimum, the same
* syntax is used as for l10n placeholders. Please note that the
* placeholders require a mandatory 'PLACEHOLDER_' prefix.
*
* Example:
* const str = "This is markdown with `code` and two placeholders, namely {PLACEHOLDER_PH1} and {PLACEHOLDER_PH2}".
* const result = substitePlaceholders(str, new Map([['PLACEHOLDER_PH1', 'foo'], ['PLACEHOLDER_PH2', 'bar']]));
*
* Exported only for unit testing.
*/
export function substitutePlaceholders(markdown: string, substitutions?: Map<string, string>): string {
const unusedPlaceholders = new Set(substitutions ? substitutions.keys() : []);
validatePlaceholders(unusedPlaceholders);
const result = markdown.replace(validPlaceholderMatchPattern, (_, placeholder) => {
const replacement = substitutions ? substitutions.get(placeholder) : undefined;
if (!replacement) {
throw new Error(`No replacment provided for placeholder '${placeholder}'.`);
}
unusedPlaceholders.delete(placeholder);
return replacement;
});
if (unusedPlaceholders.size > 0) {
throw new Error(`Unused replacements provided: ${[...unusedPlaceholders]}`);
}
return result;
}
// Ensure that all provided placeholders match the naming pattern.
function validatePlaceholders(placeholders: Set<string>): void {
const invalidPlaceholders = [...placeholders].filter(placeholder => !validPlaceholderNamePattern.test(placeholder));
if (invalidPlaceholders.length > 0) {
throw new Error(`Invalid placeholders provided in the substitutions map: ${invalidPlaceholders}`);
}
}
export function findTitleFromMarkdownAst(markdownAst: Marked.Marked.Token[]): string|null {
if (markdownAst.length === 0 || markdownAst[0].type !== 'heading' || markdownAst[0].depth !== 1) {
return null;
}
return markdownAst[0].text;
}
export async function getIssueTitleFromMarkdownDescription(description: MarkdownIssueDescription):
Promise<string|null> {
const rawMarkdown = await getMarkdownFileContent(description.file);
const markdownAst = Marked.Marked.lexer(rawMarkdown);
return findTitleFromMarkdownAst(markdownAst);
}