marked/src/Lexer.ts
import { _Tokenizer } from './Tokenizer.ts';
import { _defaults } from './defaults.ts';
import { other, block, inline } from './rules.ts';
import type { Token, TokensList, Tokens } from './Tokens.ts';
import type { MarkedOptions } from './MarkedOptions.ts';
/**
* Block Lexer
*/
export class _Lexer<ParserOutput = string, RendererOutput = string> {
tokens: TokensList;
options: MarkedOptions<ParserOutput, RendererOutput>;
state: {
inLink: boolean;
inRawBlock: boolean;
/** a link was produced in the inline run currently being scanned */
linkEmitted: boolean;
top: boolean;
};
public inlineQueue: { src: string, tokens: Token[] }[];
private tokenizer: _Tokenizer<ParserOutput, RendererOutput>;
constructor(options?: MarkedOptions<ParserOutput, RendererOutput>) {
// TokenList cannot be created in one go
this.tokens = [] as unknown as TokensList;
this.tokens.links = Object.create(null);
this.options = options || _defaults;
this.options.tokenizer = this.options.tokenizer || new _Tokenizer<ParserOutput, RendererOutput>();
this.tokenizer = this.options.tokenizer;
this.tokenizer.options = this.options;
this.tokenizer.lexer = this;
this.inlineQueue = [];
this.state = {
inLink: false,
inRawBlock: false,
linkEmitted: false,
top: true,
};
const rules = {
other,
block: block.normal,
inline: inline.normal,
};
if (this.options.pedantic) {
rules.block = block.pedantic;
rules.inline = inline.pedantic;
} else if (this.options.gfm) {
rules.block = block.gfm;
if (this.options.breaks) {
rules.inline = inline.breaks;
} else {
rules.inline = inline.gfm;
}
}
this.tokenizer.rules = rules;
}
/**
* Expose Rules
*/
static get rules() {
return {
block,
inline,
};
}
/**
* Static Lex Method
*/
static lex<ParserOutput = string, RendererOutput = string>(src: string, options?: MarkedOptions<ParserOutput, RendererOutput>) {
const lexer = new _Lexer<ParserOutput, RendererOutput>(options);
return lexer.lex(src);
}
/**
* Static Lex Inline Method
*/
static lexInline<ParserOutput = string, RendererOutput = string>(src: string, options?: MarkedOptions<ParserOutput, RendererOutput>) {
const lexer = new _Lexer<ParserOutput, RendererOutput>(options);
return lexer.inlineTokens(src);
}
/**
* Preprocessing
*/
lex(src: string) {
src = src.replace(other.carriageReturn, '\n');
this.blockTokens(src, this.tokens);
for (let i = 0; i < this.inlineQueue.length; i++) {
const next = this.inlineQueue[i];
this.inlineTokens(next.src, next.tokens);
}
this.inlineQueue = [];
return this.tokens;
}
/**
* Lexing
*/
blockTokens(src: string, tokens?: Token[], lastParagraphClipped?: boolean): Token[];
blockTokens(src: string, tokens?: TokensList, lastParagraphClipped?: boolean): TokensList;
blockTokens(src: string, tokens: Token[] = [], lastParagraphClipped = false) {
this.tokenizer.lexer = this;
if (this.options.pedantic) {
src = src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, '');
}
let srcLength = Infinity;
while (src) {
if (src.length < srcLength) {
srcLength = src.length;
} else {
this.infiniteLoopError(src.charCodeAt(0));
break;
}
let token: Tokens.Generic | undefined;
if (this.options.extensions?.block?.some((extTokenizer) => {
if (token = extTokenizer.call({ lexer: this }, src, tokens)) {
src = src.substring(token.raw.length);
tokens.push(token);
return true;
}
return false;
})) {
continue;
}
// newline
if (token = this.tokenizer.space(src)) {
src = src.substring(token.raw.length);
const lastToken = tokens.at(-1);
if (token.raw.length === 1 && lastToken !== undefined) {
// if there's a single \n as a spacer, it's terminating the last line,
// so move it there so that we don't get unnecessary paragraph tags
lastToken.raw += '\n';
} else {
tokens.push(token);
}
continue;
}
// code
if (token = this.tokenizer.code(src)) {
src = src.substring(token.raw.length);
const lastToken = tokens.at(-1);
// An indented code block cannot interrupt a paragraph.
if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') {
lastToken.raw += (lastToken.raw.endsWith('\n') ? '' : '\n') + token.raw;
lastToken.text += '\n' + token.text;
this.inlineQueue.at(-1)!.src = lastToken.text;
} else {
tokens.push(token);
}
continue;
}
// fences
if (token = this.tokenizer.fences(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// heading
if (token = this.tokenizer.heading(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// hr
if (token = this.tokenizer.hr(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// blockquote
if (token = this.tokenizer.blockquote(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// list
if (token = this.tokenizer.list(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// html
if (token = this.tokenizer.html(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// def
if (token = this.tokenizer.def(src)) {
src = src.substring(token.raw.length);
const lastToken = tokens.at(-1);
if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') {
lastToken.raw += (lastToken.raw.endsWith('\n') ? '' : '\n') + token.raw;
lastToken.text += '\n' + token.raw;
this.inlineQueue.at(-1)!.src = lastToken.text;
} else if (!this.tokens.links[token.tag]) {
this.tokens.links[token.tag] = {
href: token.href,
title: token.title,
};
tokens.push(token);
}
continue;
}
// table (gfm)
if (token = this.tokenizer.table(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// lheading
if (token = this.tokenizer.lheading(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// top-level paragraph
// prevent paragraph consuming extensions by clipping 'src' to extension start
let cutSrc = src;
if (this.options.extensions?.startBlock) {
let startIndex = Infinity;
const tempSrc = src.slice(1);
let tempStart;
this.options.extensions.startBlock.forEach((getStartIndex) => {
tempStart = getStartIndex.call({ lexer: this }, tempSrc);
if (typeof tempStart === 'number' && tempStart >= 0) {
startIndex = Math.min(startIndex, tempStart);
}
});
if (startIndex < Infinity && startIndex >= 0) {
cutSrc = src.substring(0, startIndex + 1);
}
}
if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) {
const lastToken = tokens.at(-1);
if (lastParagraphClipped && lastToken?.type === 'paragraph') {
lastToken.raw += (lastToken.raw.endsWith('\n') ? '' : '\n') + token.raw;
lastToken.text += '\n' + token.text;
this.inlineQueue.pop();
this.inlineQueue.at(-1)!.src = lastToken.text;
} else {
tokens.push(token);
}
lastParagraphClipped = cutSrc.length !== src.length;
src = src.substring(token.raw.length);
continue;
}
// text
if (token = this.tokenizer.text(src)) {
src = src.substring(token.raw.length);
const lastToken = tokens.at(-1);
if (lastToken?.type === 'text') {
lastToken.raw += (lastToken.raw.endsWith('\n') ? '' : '\n') + token.raw;
lastToken.text += '\n' + token.text;
this.inlineQueue.pop();
this.inlineQueue.at(-1)!.src = lastToken.text;
} else {
tokens.push(token);
}
continue;
}
if (src) {
this.infiniteLoopError(src.charCodeAt(0));
break;
}
}
this.state.top = true;
return tokens;
}
inline(src: string, tokens: Token[] = []) {
this.inlineQueue.push({ src, tokens });
return tokens;
}
/**
* Does this link text hold a link already? An image does not count: an image
* may hold a link, a link may not.
*/
private linkInText(text: string): boolean {
if (!text.includes('[')) {
return false;
}
const linkRule = this.tokenizer.rules.inline.link;
for (const match of text.matchAll(this.tokenizer.rules.inline.blockSkip)) {
// blockSkip also matches code spans and html, and the `!` of an image is
// left out of the match, so read the character before it.
if (linkRule.test(match[0]) && text.charAt(match.index - 1) !== '!') {
return true;
}
}
for (const match of text.matchAll(this.tokenizer.rules.inline.reflinkSearch)) {
const match0 = match[0];
const refStart = match0.lastIndexOf('[');
if (match0.charAt(0) === '!' || !Object.hasOwn(this.tokens.links, match0.slice(refStart + 1, -1))) {
continue;
}
// a candidate holding a link is not a link either, so it does not count
if (refStart > 1 && this.linkInText(match0.slice(1, refStart - 1))) {
continue;
}
return true;
}
return false;
}
/**
* Lexing/Compiling
*/
inlineTokens(src: string, tokens: Token[] = []): Token[] {
this.tokenizer.lexer = this;
// String with links masked to avoid interference with em and strong
let maskedSrc = src;
// Mask out reflinks
if (this.tokens.links && src.includes('[')) {
const reflinkSearch = this.tokenizer.rules.inline.reflinkSearch;
const maskReflink = (match0: string): string => {
const refStart = match0.lastIndexOf('[');
if (!Object.hasOwn(this.tokens.links, match0.slice(refStart + 1, -1))) {
return match0;
}
// CommonMark: "Links may not contain other links, at any level of
// nesting." A candidate whose text already holds one never becomes a
// link, so flattening the whole span would hide the emphasis that
// does still apply inside it. Mask the links it holds instead.
// Images are exempt: their text is flattened into an alt attribute.
if (refStart > 1 && match0.charAt(0) !== '!') {
const text = match0.slice(1, refStart - 1);
if (this.linkInText(text)) {
return '[' + text.replace(reflinkSearch, maskReflink)
+ '][' + 'a'.repeat(match0.length - refStart - 2) + ']';
}
}
return '[' + 'a'.repeat(match0.length - 2) + ']';
};
maskedSrc = maskedSrc.replace(reflinkSearch, maskReflink);
}
// Mask out escaped characters.
// Every mask must keep the length it replaces: emStrong and del line
// maskedSrc up with src by slicing from the end. `anyPunctuation` matches
// unicode punctuation, so an escaped astral character is 3 code units.
maskedSrc = maskedSrc.replace(this.tokenizer.rules.inline.anyPunctuation, match0 => '+'.repeat(match0.length));
// Mask out other blocks
maskedSrc = maskedSrc.replace(this.tokenizer.rules.inline.blockSkip, (match0, _link, context) => {
const offset = context ? context.length : 0;
return match0.slice(0, offset) + '[' + 'a'.repeat(match0.length - offset - 2) + ']';
});
// Mask out blocks from extensions
maskedSrc = this.options.hooks?.emStrongMask?.call({ lexer: this }, maskedSrc) ?? maskedSrc;
let keepPrevChar = false;
let prevChar = '';
let srcLength = Infinity;
while (src) {
if (src.length < srcLength) {
srcLength = src.length;
} else {
this.infiniteLoopError(src.charCodeAt(0));
break;
}
if (!keepPrevChar) {
prevChar = '';
}
keepPrevChar = false;
let token: Tokens.Generic | undefined;
// extensions
if (this.options.extensions?.inline?.some((extTokenizer) => {
if (token = extTokenizer.call({ lexer: this }, src, tokens)) {
src = src.substring(token.raw.length);
tokens.push(token);
return true;
}
return false;
})) {
continue;
}
// escape
if (token = this.tokenizer.escape(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// tag
if (token = this.tokenizer.tag(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// link
if (token = this.tokenizer.link(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// reflink, nolink
if (token = this.tokenizer.reflink(src, this.tokens.links)) {
src = src.substring(token.raw.length);
const lastToken = tokens.at(-1);
if (token.type === 'text' && lastToken?.type === 'text') {
lastToken.raw += token.raw;
lastToken.text += token.text;
} else {
tokens.push(token);
}
continue;
}
// em & strong
if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// code
if (token = this.tokenizer.codespan(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// br
if (token = this.tokenizer.br(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// del (gfm)
if (token = this.tokenizer.del(src, maskedSrc, prevChar)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// autolink
if (token = this.tokenizer.autolink(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// url (gfm)
if (!this.state.inLink && (token = this.tokenizer.url(src))) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
// text
// prevent inlineText consuming extensions by clipping 'src' to extension start
let cutSrc = src;
if (this.options.extensions?.startInline) {
let startIndex = Infinity;
const tempSrc = src.slice(1);
let tempStart;
this.options.extensions.startInline.forEach((getStartIndex) => {
tempStart = getStartIndex.call({ lexer: this }, tempSrc);
if (typeof tempStart === 'number' && tempStart >= 0) {
startIndex = Math.min(startIndex, tempStart);
}
});
if (startIndex < Infinity && startIndex >= 0) {
cutSrc = src.substring(0, startIndex + 1);
}
}
if (token = this.tokenizer.inlineText(cutSrc)) {
src = src.substring(token.raw.length);
if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started
prevChar = token.raw.slice(-1);
}
keepPrevChar = true;
const lastToken = tokens.at(-1);
if (lastToken?.type === 'text') {
lastToken.raw += token.raw;
lastToken.text += token.text;
} else {
tokens.push(token);
}
continue;
}
if (src) {
this.infiniteLoopError(src.charCodeAt(0));
break;
}
}
return tokens;
}
private infiniteLoopError(byte: number) {
const errMsg = 'Infinite loop on byte: ' + byte;
if (this.options.silent) {
console.error(errMsg);
} else {
throw new Error(errMsg);
}
}
}