Merge pull request #4470 from mermaid-js/sidv/splitUnicode

Split formatted markdown strings with unicode support.
This commit is contained in:
Knut Sveidqvist 2023-07-25 15:16:01 +00:00 committed by GitHub
commit c99e1c689e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 356 additions and 134 deletions

View File

@ -228,7 +228,7 @@ mermaid fully supports webpack. Here is a [working demo](https://github.com/merm
The main idea of the API is to be able to call a render function with the graph definition as a string. The render function will render the graph and call a callback with the resulting SVG code. With this approach it is up to the site creator to fetch the graph definition from the site (perhaps from a textarea), render it and place the graph somewhere in the site.
The example below show an outline of how this could be used. The example just logs the resulting SVG to the JavaScript console.
The example below shows an example of how this could be used. The example just logs the resulting SVG to the JavaScript console.
```html
<script type="module">

View File

@ -1,16 +0,0 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
// @ts-ignore - no types
import { LALRGenerator } from 'jison';
const getAbsolutePath = (relativePath: string) => {
return fileURLToPath(new URL(relativePath, import.meta.url));
};
describe('class diagram grammar', function () {
it('should have no conflicts', async function () {
const grammarSource = await readFile(getAbsolutePath('./parser/classDiagram.jison'), 'utf8');
const grammarParser = new LALRGenerator(grammarSource, {});
expect(grammarParser.conflicts).toBe(0);
});
});

View File

@ -1,31 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// @ts-nocheck TODO: Fix types
import { log } from '../logger.js';
import { decodeEntities } from '../mermaidAPI.js';
import { markdownToHTML, markdownToLines } from '../rendering-util/handle-markdown-text.js';
/**
* @param dom
* @param styleFn
*/
import { splitLineToFitWidth } from './splitText.js';
import { MarkdownLine, MarkdownWord } from './types.js';
function applyStyle(dom, styleFn) {
if (styleFn) {
dom.attr('style', styleFn);
}
}
/**
* @param element
* @param {any} node
* @param width
* @param classes
* @param addBackground
* @returns {SVGForeignObjectElement} Node
*/
function addHtmlSpan(element, node, width, classes, addBackground = false) {
const fo = element.append('foreignObject');
// const newEl = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
// const newEl = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
const div = fo.append('xhtml:div');
// const div = body.append('div');
// const div = fo.append('div');
const label = node.label;
const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel';
@ -64,12 +53,12 @@ function addHtmlSpan(element, node, width, classes, addBackground = false) {
/**
* Creates a tspan element with the specified attributes for text positioning.
*
* @param {object} textElement - The parent text element to append the tspan element.
* @param {number} lineIndex - The index of the current line in the structuredText array.
* @param {number} lineHeight - The line height value for the text.
* @returns {object} The created tspan element.
* @param textElement - The parent text element to append the tspan element.
* @param lineIndex - The index of the current line in the structuredText array.
* @param lineHeight - The line height value for the text.
* @returns The created tspan element.
*/
function createTspan(textElement, lineIndex, lineHeight) {
function createTspan(textElement: any, lineIndex: number, lineHeight: number) {
return textElement
.append('tspan')
.attr('class', 'text-outer-tspan')
@ -78,17 +67,10 @@ function createTspan(textElement, lineIndex, lineHeight) {
.attr('dy', lineHeight + 'em');
}
/**
* Compute the width of rendered text
* @param {object} parentNode
* @param {number} lineHeight
* @param {string} text
* @returns {number}
*/
function computeWidthOfText(parentNode, lineHeight, text) {
function computeWidthOfText(parentNode: any, lineHeight: number, line: MarkdownLine): number {
const testElement = parentNode.append('text');
const testSpan = createTspan(testElement, 1, lineHeight);
updateTextContentAndStyles(testSpan, [{ content: text, type: 'normal' }]);
updateTextContentAndStyles(testSpan, line);
const textLength = testSpan.node().getComputedTextLength();
testElement.remove();
return textLength;
@ -98,59 +80,37 @@ function computeWidthOfText(parentNode, lineHeight, text) {
* Creates a formatted text element by breaking lines and applying styles based on
* the given structuredText.
*
* @param {number} width - The maximum allowed width of the text.
* @param {object} g - The parent group element to append the formatted text.
* @param {Array} structuredText - The structured text data to format.
* @param addBackground
* @param width - The maximum allowed width of the text.
* @param g - The parent group element to append the formatted text.
* @param structuredText - The structured text data to format.
* @param addBackground - Whether to add a background to the text.
*/
function createFormattedText(width, g, structuredText, addBackground = false) {
function createFormattedText(
width: number,
g: any,
structuredText: MarkdownWord[][],
addBackground = false
) {
const lineHeight = 1.1;
const labelGroup = g.append('g');
let bkg = labelGroup.insert('rect').attr('class', 'background');
const bkg = labelGroup.insert('rect').attr('class', 'background');
const textElement = labelGroup.append('text').attr('y', '-10.1');
// .attr('dominant-baseline', 'middle')
// .attr('text-anchor', 'middle');
// .attr('text-anchor', 'middle');
let lineIndex = 0;
structuredText.forEach((line) => {
for (const line of structuredText) {
/**
* Preprocess raw string content of line data
* Creating an array of strings pre-split to satisfy width limit
*/
let fullStr = line.map((data) => data.content).join(' ');
let tempStr = '';
let linesUnderWidth = [];
let prevIndex = 0;
if (computeWidthOfText(labelGroup, lineHeight, fullStr) <= width) {
linesUnderWidth.push(fullStr);
} else {
for (let i = 0; i <= fullStr.length; i++) {
tempStr = fullStr.slice(prevIndex, i);
log.info(tempStr, prevIndex, i);
if (computeWidthOfText(labelGroup, lineHeight, tempStr) > width) {
const subStr = fullStr.slice(prevIndex, i);
// Break at space if any
const lastSpaceIndex = subStr.lastIndexOf(' ');
if (lastSpaceIndex > -1) {
i = prevIndex + lastSpaceIndex + 1;
}
linesUnderWidth.push(fullStr.slice(prevIndex, i).trim());
prevIndex = i;
tempStr = null;
}
}
if (tempStr != null) {
linesUnderWidth.push(tempStr);
}
}
const checkWidth = (line: MarkdownLine) =>
computeWidthOfText(labelGroup, lineHeight, line) <= width;
const linesUnderWidth = checkWidth(line) ? [line] : splitLineToFitWidth(line, checkWidth);
/** Add each prepared line as a tspan to the parent node */
const preparedLines = linesUnderWidth.map((w) => ({ content: w, type: line.type }));
for (const preparedLine of preparedLines) {
let tspan = createTspan(textElement, lineIndex, lineHeight);
updateTextContentAndStyles(tspan, [preparedLine]);
for (const preparedLine of linesUnderWidth) {
const tspan = createTspan(textElement, lineIndex, lineHeight);
updateTextContentAndStyles(tspan, preparedLine);
lineIndex++;
}
});
}
if (addBackground) {
const bbox = textElement.node().getBBox();
const padding = 2;
@ -159,7 +119,6 @@ function createFormattedText(width, g, structuredText, addBackground = false) {
.attr('y', -padding)
.attr('width', bbox.width + 2 * padding)
.attr('height', bbox.height + 2 * padding);
// .style('fill', 'red');
return labelGroup.node();
} else {
@ -171,40 +130,27 @@ function createFormattedText(width, g, structuredText, addBackground = false) {
* Updates the text content and styles of the given tspan element based on the
* provided wrappedLine data.
*
* @param {object} tspan - The tspan element to update.
* @param {Array} wrappedLine - The line data to apply to the tspan element.
* @param tspan - The tspan element to update.
* @param wrappedLine - The line data to apply to the tspan element.
*/
function updateTextContentAndStyles(tspan, wrappedLine) {
function updateTextContentAndStyles(tspan: any, wrappedLine: MarkdownWord[]) {
tspan.text('');
wrappedLine.forEach((word, index) => {
const innerTspan = tspan
.append('tspan')
.attr('font-style', word.type === 'em' ? 'italic' : 'normal')
.attr('font-style', word.type === 'emphasis' ? 'italic' : 'normal')
.attr('class', 'text-inner-tspan')
.attr('font-weight', word.type === 'strong' ? 'bold' : 'normal');
const special = ['"', "'", '.', ',', ':', ';', '!', '?', '(', ')', '[', ']', '{', '}'];
if (index === 0) {
innerTspan.text(word.content);
} else {
// TODO: check what joiner to use.
innerTspan.text(' ' + word.content);
}
});
}
/**
*
* @param el
* @param {*} text
* @param {*} param1
* @param root0
* @param root0.style
* @param root0.isTitle
* @param root0.classes
* @param root0.useHtmlLabels
* @param root0.isNode
* @returns
*/
// Note when using from flowcharts converting the API isNode means classes should be set accordingly. When using htmlLabels => to sett classes to'nodeLabel' when isNode=true otherwise 'edgeLabel'
// When not using htmlLabels => to set classes to 'title-row' when isTitle=true otherwise 'title-row'
export const createText = (
@ -234,7 +180,7 @@ export const createText = (
),
labelStyle: style.replace('fill:', 'color:'),
};
let vertexNode = addHtmlSpan(el, node, width, classes, addSvgBackground);
const vertexNode = addHtmlSpan(el, node, width, classes, addSvgBackground);
return vertexNode;
} else {
const structuredText = markdownToLines(text);

View File

@ -152,9 +152,8 @@ test('markdownToLines - Only italic formatting', () => {
});
it('markdownToLines - Mixed formatting', () => {
const input = `*Italic* and **bold** formatting`;
const expectedOutput = [
let input = `*Italic* and **bold** formatting`;
let expected = [
[
{ content: 'Italic', type: 'emphasis' },
{ content: 'and', type: 'normal' },
@ -162,9 +161,21 @@ it('markdownToLines - Mixed formatting', () => {
{ content: 'formatting', type: 'normal' },
],
];
expect(markdownToLines(input)).toEqual(expected);
const output = markdownToLines(input);
expect(output).toEqual(expectedOutput);
input = `*Italic with space* and **bold ws** formatting`;
expected = [
[
{ content: 'Italic', type: 'emphasis' },
{ content: 'with', type: 'emphasis' },
{ content: 'space', type: 'emphasis' },
{ content: 'and', type: 'normal' },
{ content: 'bold', type: 'strong' },
{ content: 'ws', type: 'strong' },
{ content: 'formatting', type: 'normal' },
],
];
expect(markdownToLines(input)).toEqual(expected);
});
it('markdownToLines - Mixed formatting', () => {

View File

@ -1,11 +1,13 @@
import type { Content } from 'mdast';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { dedent } from 'ts-dedent';
import { MarkdownLine, MarkdownWordType } from './types.js';
/**
* @param {string} markdown markdown to process
* @returns {string} processed markdown
* @param markdown - markdown to process
* @returns processed markdown
*/
function preprocessMarkdown(markdown) {
function preprocessMarkdown(markdown: string): string {
// Replace multiple newlines with a single newline
const withoutMultipleNewlines = markdown.replace(/\n{2,}/g, '\n');
// Remove extra spaces at the beginning of each line
@ -14,19 +16,15 @@ function preprocessMarkdown(markdown) {
}
/**
* @param {string} markdown markdown to split into lines
* @param markdown - markdown to split into lines
*/
export function markdownToLines(markdown) {
export function markdownToLines(markdown: string): MarkdownLine[] {
const preprocessedMarkdown = preprocessMarkdown(markdown);
const { children } = fromMarkdown(preprocessedMarkdown);
const lines = [[]];
const lines: MarkdownLine[] = [[]];
let currentLine = 0;
/**
* @param {import('mdast').Content} node
* @param {string} [parentType]
*/
function processNode(node, parentType = 'normal') {
function processNode(node: Content, parentType: MarkdownWordType = 'normal') {
if (node.type === 'text') {
const textLines = node.value.split('\n');
textLines.forEach((textLine, index) => {
@ -58,17 +56,10 @@ export function markdownToLines(markdown) {
return lines;
}
/**
* @param {string} markdown markdown to convert to HTML
* @returns {string} HTML
*/
export function markdownToHTML(markdown) {
export function markdownToHTML(markdown: string) {
const { children } = fromMarkdown(markdown);
/**
* @param {import('mdast').Content} node
*/
function output(node) {
function output(node: Content): string {
if (node.type === 'text') {
return node.value.replace(/\n/g, '<br/>');
} else if (node.type === 'strong') {

View File

@ -0,0 +1,147 @@
import { splitTextToChars, splitLineToFitWidth, splitLineToWords } from './splitText.js';
import { describe, it, expect, vi } from 'vitest';
import type { CheckFitFunction, MarkdownLine, MarkdownWordType } from './types.js';
describe('when Intl.Segmenter is available', () => {
describe('splitText', () => {
it.each([
{ str: '', split: [] },
{ str: '🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻', split: ['🏳️‍⚧️', '🏳️‍🌈', '👩🏾‍❤️‍👨🏻'] },
{ str: 'ok', split: ['o', 'k'] },
{ str: 'abc', split: ['a', 'b', 'c'] },
])('should split $str into graphemes', ({ str, split }: { str: string; split: string[] }) => {
expect(splitTextToChars(str)).toEqual(split);
});
});
describe('split lines', () => {
it('should create valid checkFit function', () => {
const checkFit5 = createCheckFn(5);
expect(checkFit5([{ content: 'hello', type: 'normal' }])).toBe(true);
expect(
checkFit5([
{ content: 'hello', type: 'normal' },
{ content: 'world', type: 'normal' },
])
).toBe(false);
const checkFit1 = createCheckFn(1);
expect(checkFit1([{ content: 'A', type: 'normal' }])).toBe(true);
expect(checkFit1([{ content: '🏳️‍⚧️', type: 'normal' }])).toBe(true);
expect(checkFit1([{ content: '🏳️‍⚧️🏳️‍⚧️', type: 'normal' }])).toBe(false);
});
it.each([
// empty string
{ str: 'hello world', width: 7, split: ['hello', 'world'] },
// width > full line
{ str: 'hello world', width: 20, split: ['hello world'] },
// width < individual word
{ str: 'hello world', width: 3, split: ['hel', 'lo', 'wor', 'ld'] },
{ str: 'hello 12 world', width: 4, split: ['hell', 'o 12', 'worl', 'd'] },
{ str: 'hello 1 2 world', width: 4, split: ['hell', 'o 1', '2', 'worl', 'd'] },
{ str: 'hello 1 2 world', width: 6, split: ['hello', ' 1 2', 'world'] },
// width = 0, impossible, so split into individual characters
{ str: '🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻', width: 0, split: ['🏳️‍⚧️', '🏳️‍🌈', '👩🏾‍❤️‍👨🏻'] },
{ str: '🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻', width: 1, split: ['🏳️‍⚧️', '🏳️‍🌈', '👩🏾‍❤️‍👨🏻'] },
{ str: '🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻', width: 2, split: ['🏳️‍⚧️🏳️‍🌈', '👩🏾‍❤️‍👨🏻'] },
{ str: '🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻', width: 3, split: ['🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻'] },
{ str: '中文中', width: 1, split: ['中', '文', '中'] },
{ str: '中文中', width: 2, split: ['中文', '中'] },
{ str: '中文中', width: 3, split: ['中文中'] },
{ str: 'Flag 🏳️‍⚧️ this 🏳️‍🌈', width: 6, split: ['Flag 🏳️‍⚧️', 'this 🏳️‍🌈'] },
])(
'should split $str into lines of $width characters',
({ str, split, width }: { str: string; width: number; split: string[] }) => {
const checkFn = createCheckFn(width);
const line: MarkdownLine = getLineFromString(str);
expect(splitLineToFitWidth(line, checkFn)).toEqual(
split.map((str) => getLineFromString(str))
);
}
);
});
});
/**
* Intl.Segmenter is not supported in Firefox yet,
* see https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
*/
describe('when Intl.Segmenter is not available', () => {
beforeAll(() => {
vi.stubGlobal('Intl', { Segmenter: undefined });
});
afterAll(() => {
vi.unstubAllGlobals();
});
it.each([
{ str: '', split: [] },
{
str: '🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻',
split: [...'🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻'],
},
{ str: 'ok', split: ['o', 'k'] },
{ str: 'abc', split: ['a', 'b', 'c'] },
])('should split $str into characters', ({ str, split }: { str: string; split: string[] }) => {
expect(splitTextToChars(str)).toEqual(split);
});
it.each([
// empty string
{ str: 'hello world', width: 7, split: ['hello', 'world'] },
// width > full line
{ str: 'hello world', width: 20, split: ['hello world'] },
// width < individual word
{ str: 'hello world', width: 3, split: ['hel', 'lo', 'wor', 'ld'] },
{ str: 'hello 12 world', width: 4, split: ['hell', 'o 12', 'worl', 'd'] },
{ str: 'hello 1 2 world', width: 4, split: ['hell', 'o 1', '2', 'worl', 'd'] },
{ str: 'hello 1 2 world', width: 6, split: ['hello', ' 1 2', 'world'] },
// width = 0, impossible, so split into individual characters
{ str: 'abc', width: 0, split: ['a', 'b', 'c'] },
{ str: '🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻', width: 1, split: [...'🏳️‍⚧️🏳️‍🌈👩🏾‍❤️‍👨🏻'] },
{ str: '中文中', width: 1, split: ['中', '文', '中'] },
{ str: '中文中', width: 2, split: ['中文', '中'] },
{ str: '中文中', width: 3, split: ['中文中'] },
])(
'should split $str into lines of $width characters',
({ str, split, width }: { str: string; width: number; split: string[] }) => {
const checkFn = createCheckFn(width);
const line: MarkdownLine = getLineFromString(str);
expect(splitLineToFitWidth(line, checkFn)).toEqual(
split.map((str) => getLineFromString(str))
);
}
);
});
it('should handle strings with newlines', () => {
const checkFn: CheckFitFunction = createCheckFn(6);
const str = `Flag
🏳 this 🏳🌈`;
expect(() =>
splitLineToFitWidth(getLineFromString(str), checkFn)
).toThrowErrorMatchingInlineSnapshot(
'"splitLineToFitWidth does not support newlines in the line"'
);
});
const getLineFromString = (str: string, type: MarkdownWordType = 'normal'): MarkdownLine => {
return splitLineToWords(str).map((content) => ({
content,
type,
}));
};
/**
* Creates a checkFunction for a given width
* @param width - width of characters to fit in a line
* @returns checkFunction
*/
const createCheckFn = (width: number): CheckFitFunction => {
return (text: MarkdownLine) => {
// Join all words into a single string
const joinedContent = text.map((w) => w.content).join('');
const characters = splitTextToChars(joinedContent);
return characters.length <= width;
};
};

View File

@ -0,0 +1,135 @@
import type { CheckFitFunction, MarkdownLine, MarkdownWord, MarkdownWordType } from './types.js';
/**
* Splits a string into graphemes if available, otherwise characters.
*/
export function splitTextToChars(text: string): string[] {
if (Intl.Segmenter) {
return [...new Intl.Segmenter().segment(text)].map((s) => s.segment);
}
return [...text];
}
/**
* Splits a string into words by using `Intl.Segmenter` if available, or splitting by ' '.
* `Intl.Segmenter` uses the default locale, which might be different across browsers.
*/
export function splitLineToWords(text: string): string[] {
if (Intl.Segmenter) {
return [...new Intl.Segmenter(undefined, { granularity: 'word' }).segment(text)].map(
(s) => s.segment
);
}
// Split by ' ' removes the ' 's from the result.
const words = text.split(' ');
// Add the ' 's back to the result.
const wordsWithSpaces = words.flatMap((s) => [s, ' ']).filter((s) => s);
// Remove last space.
wordsWithSpaces.pop();
return wordsWithSpaces;
}
/**
* Splits a word into two parts, the first part fits the width and the remaining part.
* @param checkFit - Function to check if word fits
* @param word - Word to split
* @returns [first part of word that fits, rest of word]
*/
export function splitWordToFitWidth(
checkFit: CheckFitFunction,
word: MarkdownWord
): [MarkdownWord, MarkdownWord] {
const characters = splitTextToChars(word.content);
return splitWordToFitWidthRecursion(checkFit, [], characters, word.type);
}
function splitWordToFitWidthRecursion(
checkFit: CheckFitFunction,
usedChars: string[],
remainingChars: string[],
type: MarkdownWordType
): [MarkdownWord, MarkdownWord] {
if (remainingChars.length === 0) {
return [
{ content: usedChars.join(''), type },
{ content: '', type },
];
}
const [nextChar, ...rest] = remainingChars;
const newWord = [...usedChars, nextChar];
if (checkFit([{ content: newWord.join(''), type }])) {
return splitWordToFitWidthRecursion(checkFit, newWord, rest, type);
}
if (usedChars.length === 0 && nextChar) {
// If the first character does not fit, split it anyway
usedChars.push(nextChar);
remainingChars.shift();
}
return [
{ content: usedChars.join(''), type },
{ content: remainingChars.join(''), type },
];
}
/**
* Splits a line into multiple lines that satisfy the checkFit function.
* @param line - Line to split
* @param checkFit - Function to check if line fits
* @returns Array of lines that fit
*/
export function splitLineToFitWidth(
line: MarkdownLine,
checkFit: CheckFitFunction
): MarkdownLine[] {
if (line.some(({ content }) => content.includes('\n'))) {
throw new Error('splitLineToFitWidth does not support newlines in the line');
}
return splitLineToFitWidthRecursion(line, checkFit);
}
function splitLineToFitWidthRecursion(
words: MarkdownWord[],
checkFit: CheckFitFunction,
lines: MarkdownLine[] = [],
newLine: MarkdownLine = []
): MarkdownLine[] {
// Return if there is nothing left to split
if (words.length === 0) {
// If there is a new line, add it to the lines
if (newLine.length > 0) {
lines.push(newLine);
}
return lines.length > 0 ? lines : [];
}
let joiner = '';
if (words[0].content === ' ') {
joiner = ' ';
words.shift();
}
const nextWord: MarkdownWord = words.shift() ?? { content: ' ', type: 'normal' };
const lineWithNextWord: MarkdownLine = [...newLine];
if (joiner !== '') {
lineWithNextWord.push({ content: joiner, type: 'normal' });
}
lineWithNextWord.push(nextWord);
if (checkFit(lineWithNextWord)) {
// nextWord fits, so we can add it to the new line and continue
return splitLineToFitWidthRecursion(words, checkFit, lines, lineWithNextWord);
}
// nextWord doesn't fit, so we need to split it
if (newLine.length > 0) {
// There was text in newLine, so add it to lines and push nextWord back into words.
lines.push(newLine);
words.unshift(nextWord);
} else if (nextWord.content) {
// There was no text in newLine, so we need to split nextWord
const [line, rest] = splitWordToFitWidth(checkFit, nextWord);
lines.push([line]);
if (rest.content) {
words.unshift(rest);
}
}
return splitLineToFitWidthRecursion(words, checkFit, lines);
}

View File

@ -0,0 +1,8 @@
export type MarkdownWordType = 'normal' | 'strong' | 'emphasis';
export interface MarkdownWord {
content: string;
type: MarkdownWordType;
}
export type MarkdownLine = MarkdownWord[];
/** Returns `true` if the line fits a constraint (e.g. it's under 𝑛 chars) */
export type CheckFitFunction = (text: MarkdownLine) => boolean;

View File

@ -14,7 +14,7 @@
"target": "ES6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"DOM",
"ES2021"
"ES2022"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */