/**
* A JavaScript or JSX file's code, for a scan that reads JSX tags off it with a
* pattern.
*
* Text that only mentions a tag renders nothing: a JSDoc example, markup
* commented out (`{/* *\/}`), an error message ("You cannot
* render a inside another "), an attribute's value. So
* comments, and the contents of string, template and regex literals and of
* JSX attribute strings, are blanked. Every character keeps its offset and
* every line break its place, so a match in the result sits at the same line
* and column as in the source.
*
* Inside an element, a quote, a slash or a backtick is text: `
Don't have an
* account? `, `https://…`. Read as code, that
* apostrophe opens a string that blanks the `(x: T) =>`, `type Fn = (x: T) => T`) reads as an element
* only until it fails to: the lexer then reads it again as an operator.
*
* Null when the file does not read as one program that way: a string still
* open at the end of its line, a comment, template or element still open at
* the end of the file, braces that don't balance, a closing tag that names
* another element, or a `` or `/>` among code. A scan should then read the
* source as it is.
*/
/**
* What the lexer reads: code, a template's text, an element's opening tag (its
* attributes), or its children (JSX text, tags and `{…}`).
*/
type Mode = 'code' | 'template' | 'tag' | 'children';
interface Frame {
mode: Mode;
/** Code: braces opened in this frame and not closed yet. */
braces: number;
/** Tag and Children: the element's name, which its closing tag repeats. */
name: string;
}
/** Where an element opened among code: what to undo to read its `<` again. */
interface Root {
at: number;
depth: number;
blanks: number;
}
/** Keywords an expression follows: a `<` after one opens an element, a `/` a regex. */
const BEFORE_EXPRESSION = new Set([
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw',
'case', 'do', 'else', 'yield', 'await', 'default',
]);
/** How many elements may fail to read before the file is read as it is. */
const MAX_REREADS = 64;
const isSpace = (c: number): boolean =>
c === 32 || c === 10 || c === 13 || c === 9 || c === 11 || c === 12 || c === 0xa0 || c === 0xfeff;
const isIdStart = (c: number): boolean =>
(c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 36 || c === 95 || (c > 127 && !isSpace(c));
const isIdPart = (c: number): boolean => isIdStart(c) || (c >= 48 && c <= 57);
/** A JSX name's characters: an identifier's, and the `-`, `.` and `:` between its parts. */
const isNamePart = (c: number): boolean => isIdPart(c) || c === 45 || c === 46 || c === 58;
function skipSpace(src: string, k: number): number {
while (k < src.length && isSpace(src.charCodeAt(k))) k++;
return k;
}
function nameEnd(src: string, k: number): number {
while (k < src.length && isNamePart(src.charCodeAt(k))) k++;
return k;
}
function lineEnd(src: string, k: number): number {
for (let c = src.charCodeAt(k); k < src.length && c !== 10 && c !== 13; c = src.charCodeAt(++k));
return k;
}
/** The `/` closing the regex literal opened at `start`, or -1 when it doesn't close on its line. */
function regexEnd(src: string, start: number): number {
let inClass = false;
for (let k = start + 1; k < src.length; k++) {
const c = src.charCodeAt(k);
if (c === 10 || c === 13) return -1;
if (c === 92) {
k++;
if (src.charCodeAt(k) === 10 || src.charCodeAt(k) === 13) return -1;
} else if (c === 91) inClass = true;
else if (c === 93) inClass = false;
else if (c === 47 && !inClass) return k;
}
return -1;
}
/** The quote closing the string opened at `start`, or -1 when the string doesn't close. */
function stringEnd(src: string, start: number): number {
const quote = src.charCodeAt(start);
for (let k = start + 1; k < src.length; k++) {
const c = src.charCodeAt(k);
if (c === quote) return k;
if (c === 92) k += src.charCodeAt(k + 1) === 13 && src.charCodeAt(k + 2) === 10 ? 2 : 1;
else if (c === 10 || c === 13) return -1;
}
return -1;
}
/** `src` with the ranges (start and end pairs, in order) turned to spaces, line breaks kept. */
function blankRanges(src: string, blanks: number[]): string {
if (blanks.length === 0) return src;
const parts: string[] = [];
let from = 0;
for (let b = 0; b < blanks.length; b += 2) {
const start = blanks[b]!;
const end = blanks[b + 1]!;
if (start < from) continue;
parts.push(src.slice(from, start));
const text = src.slice(start, end);
parts.push(/[\r\n]/.test(text) ? text.replace(/[^\r\n]/g, ' ') : ' '.repeat(end - start));
from = end;
}
parts.push(src.slice(from));
return parts.join('');
}
export function jsxCode(src: string): string | null {
return new JsxLexer(src).read();
}
/**
* The lexer's state for one file. Its steps are methods, not closures made
* per file: a closure made anew for each file is another call target to the
* optimizer, which threw `jsxCode`'s code away and compiled it again for
* file after file, at 40 to 90 ms a time (excalidraw's index did it 15 times).
*/
class JsxLexer {
private readonly n: number;
/** Ranges to blank, as start and end pairs, in source order. */
private readonly blanks: number[] = [];
private readonly stack: Frame[] = [{ mode: 'code', braces: 0, name: '' }];
/** The elements opened among code that are still open, innermost last. */
private readonly roots: Root[] = [];
/** `<`s that failed to read as an element. */
private readonly operators = new Set();
private i = 0;
/** Whether the last token ends an expression: a `<` then compares, a `/` divides. */
private exprEnd = false;
/** Whether the last token is a `.`, so a keyword is a property's name. */
private afterDot = false;
private rereads = 0;
constructor(private readonly src: string) {
this.n = src.length;
}
read(): string | null {
const budget = 4 * this.n + 65536;
let steps = 0;
for (;;) {
while (this.i < this.n) {
if (++steps > budget) return null;
const top = this.stack[this.stack.length - 1]!;
const c = this.src.charCodeAt(this.i);
const ok =
top.mode === 'code' ? this.code(top, c)
: top.mode === 'template' ? this.template(c)
: top.mode === 'tag' ? this.tag(top, c)
: this.children(top, c);
if (!ok) return null;
}
if (this.stack.length === 1 && this.stack[0]!.braces === 0) break;
if (!this.reread()) return null;
}
return blankRanges(this.src, this.blanks);
}
private blank(from: number, to: number): void {
if (to > from) this.blanks.push(from, Math.min(to, this.n));
}
private codeFrame(): void {
this.stack.push({ mode: 'code', braces: 0, name: '' });
this.exprEnd = false;
this.afterDot = false;
}
/**
* Undo back to the innermost element still open among code and read its
* `<` again as an operator. False when there is none, or too many have
* failed: the file is then read as it is.
*/
private reread(): boolean {
const root = this.roots.pop();
if (!root || ++this.rereads > MAX_REREADS) return false;
this.stack.length = root.depth;
this.blanks.length = root.blanks;
for (const k of this.operators) if (k > root.at) this.operators.delete(k);
this.operators.add(root.at);
this.i = root.at;
this.exprEnd = false;
this.afterDot = false;
return true;
}
/** Open the element whose `<` is at `lt`. False when it cannot be one. */
private openElement(lt: number): boolean {
const src = this.src;
if (src.charCodeAt(lt + 1) === 62) {
this.stack.push({ mode: 'children', braces: 0, name: '' });
this.i = lt + 2;
return true;
}
let k = nameEnd(src, lt + 1);
const name = src.slice(lt + 1, k);
// `(…) =>`: a generic arrow function's type parameters.
const next = skipSpace(src, k);
if (next > k && src.startsWith('extends', next) && isSpace(src.charCodeAt(next + 7))) return false;
if (src.charCodeAt(k) === 60) {
// `