class LineFeed { originalText = undefined; /** * @type {string[]} */ feed = undefined; /** * * @param {string} markdown */ constructor(markdown) { this.originalText = markdown; // TODO: Need to identify line endings this.feed = markdown.split("\n"); } /** * @returns {string} */ peek() { return this.feed[0] ?? undefined; } /** * * @returns {string} */ claim() { const line = this.feed.shift(); if (line === undefined) throw new Error("Feed is empty"); return line; } /** * * @param {string} line */ push(line) { if (line === undefined || line === null) return; this.feed = [line, ...this.feed]; } /** * * @returns {boolean} */ isEmpty() { return this.feed.length === 0; } } class Symbol { static canParse(line) { throw new Error("Not implemented"); } static create(lineFeed, assetDirectory) { throw new Error("Not implemented"); } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { throw new Error("Not Implemented"); } render() { throw new Error("Not implemented"); } } class Heading extends Symbol { /** * @type {string} */ text = ""; /** * @type {number} */ level = 1; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return line.trim().startsWith("#"); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new Heading(); const line = lineFeed.claim(); instance.text = line.replaceAll("#", "").trim(); instance.level = line.split("").reduce((aggregate, current) => { return current === "#" ? aggregate + 1 : aggregate; }, 0); return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `${this.text}`; } } class JustALineBreak extends Symbol { /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return line === ""; } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new JustALineBreak(); lineFeed.claim(); return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { throw new Error("Symbol cannot be rendered directly"); } } class Paragraph extends Symbol { /** * @type {string[]} */ lines = []; /** * @type {Symbol[]} */ children = []; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return line === ""; } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new Paragraph(); lineFeed.claim(); const lines = []; let endOfParagraph = false; while (!endOfParagraph && !lineFeed.isEmpty()) { const line = lineFeed.peek(); if (line === "") { endOfParagraph = true; continue; } lines.push(lineFeed.claim()); } instance.lines = lines; return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `

${this.children.map((c) => c.render()).join("")}

`; } } class UnorderedListItem extends Symbol { /** * @type {string} */ text = ""; /** * @type {number} */ level = 0; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return line.trim().startsWith("- "); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new UnorderedListItem(); const line = lineFeed.claim(); instance.text = line.replaceAll("-", "").trim(); instance.level = getAmountOfTokenInBeginningOfFile(" ", line); return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `
  • ${this.text} indentation level ${this.level}
  • `; } } class OrderedListItem extends Symbol { /** * @type {string} */ text = ""; /** * @type {number} */ level = 0; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return new RegExp(/^\d\. /).test(line.trim()); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new UnorderedListItem(); const line = lineFeed.claim(); instance.text = line.trim().replace(/^\d\. /, ""); instance.level = getAmountOfTokenInBeginningOfFile(" ", line); return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `
  • ${this.text} indentation level ${this.level}
  • `; } } class Link extends Symbol { /** * @type {RegExp} */ static canParseRegExp = new RegExp(/^\[.*\]\(.*\)/); /** * @type {RegExp} */ static textAndLinkRegExp = new RegExp(/\[(?.*)\]\((?.*)\)/); /** * @type {string} */ text = ""; /** * @type {string} */ link = ""; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return Link.canParseRegExp.test(line.trim()); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new Link(); const line = lineFeed.claim().trim(); const [linkLine, rest] = extractTokenAndRest(Link.textAndLinkRegExp, line); lineFeed.push(rest); const { text, link } = Link.textAndLinkRegExp.exec(linkLine)?.groups ?? {}; instance.link = link ?? ""; instance.text = text ?? ""; return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `${this.text}`; } } class ImageLink extends Symbol { /** * @type {RegExp} */ static canParseRegExp = new RegExp(/^!\[.*\]\(.*\)/); /** * @type {RegExp} */ static textAndLinkRegExp = new RegExp(/!\[(?.*)\]\((?.*)\)/); /** * @type {string} */ static assetDirectoryToken = "@asset/"; /** * @type {string} */ alt = ""; /** * @type {string} */ link = ""; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return ImageLink.canParseRegExp.test(line.trim()); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed, assetDirectory) { const instance = new ImageLink(); const line = lineFeed.claim().trim(); const [linkLine, rest] = extractTokenAndRest( ImageLink.textAndLinkRegExp, line ); lineFeed.push(rest); const { text, link } = ImageLink.textAndLinkRegExp.exec(linkLine)?.groups ?? {}; instance.link = link.replace(ImageLink.assetDirectoryToken, assetDirectory) ?? ""; instance.alt = text ?? ""; return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `${this.alt}`; } } class Italic extends Symbol { /** * @type {string} */ text = ""; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return line.startsWith("_"); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new Italic(); const line = lineFeed.claim().trim(); const lineWithoutStartingUnderscore = line.slice(1, line.length); const lineEndsWithUnderscore = lineWithoutStartingUnderscore.endsWith("_"); if (lineEndsWithUnderscore) { const lineWithoutUnderscores = lineWithoutStartingUnderscore.slice( 0, lineWithoutStartingUnderscore.length - 1 ); instance.text = lineWithoutUnderscores; return instance; } const endOfItalicString = lineWithoutStartingUnderscore.indexOf("_"); const lineWithoutUnderscores = lineWithoutStartingUnderscore.slice( 0, endOfItalicString ); const rest = lineWithoutStartingUnderscore.slice(endOfItalicString + 1); lineFeed.push(rest); instance.text = lineWithoutUnderscores; return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `${this.text}`; } } class Bold extends Symbol { /** * @type {string} */ text = ""; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return line.startsWith("**"); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new Bold(); const line = lineFeed.claim().trim(); const lineWithoutStartingUnderscore = line.slice(2, line.length); const lineEndsWithStars = lineWithoutStartingUnderscore.endsWith("**"); if (lineEndsWithStars) { const lineWithoutUnderscores = lineWithoutStartingUnderscore.slice( 0, lineWithoutStartingUnderscore.length - 2 ); instance.text = lineWithoutUnderscores; return instance; } const endOfBoldString = lineWithoutStartingUnderscore.indexOf("**"); const lineWithoutUnderscores = lineWithoutStartingUnderscore.slice( 0, endOfBoldString ); const rest = lineWithoutStartingUnderscore.slice(endOfBoldString + 2); lineFeed.push(rest); instance.text = lineWithoutUnderscores; return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `${this.text}`; } } class SingleLineCode extends Symbol { /** * @type {string} */ text = ""; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return line.startsWith("`"); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new SingleLineCode(); const line = lineFeed.claim().trim(); const lineWithoutStartingUnderscore = line.slice(1, line.length); const lineEndsWithUnderscore = lineWithoutStartingUnderscore.endsWith("`"); if (lineEndsWithUnderscore) { const lineWithoutUnderscores = lineWithoutStartingUnderscore.slice( 0, lineWithoutStartingUnderscore.length - 1 ); instance.text = lineWithoutUnderscores; return instance; } const endOfItalicString = lineWithoutStartingUnderscore.indexOf("`"); const lineWithoutUnderscores = lineWithoutStartingUnderscore.slice( 0, endOfItalicString ); const rest = lineWithoutStartingUnderscore.slice(endOfItalicString + 1); lineFeed.push(rest); instance.text = lineWithoutUnderscores; return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `${this.text}`; } } class MultiLineCode extends Symbol { /** * @type {string} */ text = ""; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { const trimmedLine = line.trim(); return trimmedLine.startsWith("```"); } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new MultiLineCode(); const line = lineFeed.claim().trim(); const lineWithoutStartTag = line.slice(3, line.length); const sameLineContainsEnding = lineWithoutStartTag.includes("```"); if (sameLineContainsEnding) { const endsWithTag = lineWithoutStartTag.endsWith("```"); if (endsWithTag) { instance.text = lineWithoutStartTag.slice( 0, lineWithoutStartTag.length - 3 ); return instance; } const [text, rest] = splitStringAtToken("```", lineWithoutStartTag); lineFeed.push(rest); instance.text = text; return instance; } const lines = [lineWithoutStartTag]; let endTokenFound = false; while (!endTokenFound && !lineFeed.isEmpty()) { const nextLine = lineFeed.claim(); if (!nextLine.includes("```")) { lines.push(nextLine); continue; } if (nextLine.endsWith("```")) { lines.push(nextLine.slice(0, nextLine.length - 3)); break; } const [lastLine, rest] = splitStringAtToken("```", nextLine); lineFeed.push(rest); lines.push(lastLine); break; } instance.text = lines.join("\n"); return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `
    ${this.text}
    `; } } class TextRow extends Symbol { /** * @type {string} */ text = ""; /** * @type {Symbol[]} */ children = []; /** * @type {Symbol[]} */ allowedChildren = [ Bold, Italic, ImageLink, Link, MultiLineCode, SingleLineCode, TextRow, ]; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return true; } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new TextRow(); instance.text = lineFeed.claim(); return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { for (let i = 0; i < this.text.length; i++) { if (i === this.text.length - 1) { const plainText = new PlainText(); plainText.text = this.text; this.children = [plainText]; break; } const line = this.text.slice(i); const symbols = toSymbols(line, assetDirectory, this.allowedChildren); const symbolsIsOnlyOneTextRow = symbols.length === 1 && symbols[0] instanceof TextRow; if (!symbols.length || symbolsIsOnlyOneTextRow) { continue; } const plainText = new PlainText(); plainText.text = this.text.slice(0, i); this.children = [plainText, ...symbols]; break; } this.children.forEach((c) => c.process(assetDirectory)); } render() { return this.children.map((c) => c.render()).join(""); } } class PlainText extends Symbol { /** * @type {string} */ text = ""; /** * @type {Symbol[]} */ children = []; /** * * @param {string} line * @returns {boolean} */ static canParse(line) { return true; } /** * * @param {LineFeed} lineFeed * @returns {Symbol} */ static create(lineFeed) { const instance = new PlainText(); instance.text = lineFeed.claim(); return instance; } /** * * @param {string} assetDirectory * @returns {void} */ process(assetDirectory) { return; } render() { return `${this.text}`; } } /** * * @param {string|RexExp} token * @param {string} string * @returns {string[]} */ const splitStringAtToken = (token, string) => { let index = 0; let length = 0; if (typeof token?.exec === "function") { const exp = new RegExp(token); const match = exp.exec(string); index = match?.index ?? -1; length = match?.[0].length ?? 0; } else { index = string.indexOf(token); length = token.length; } const splitTokenNotFound = index === -1; if (splitTokenNotFound) return [string, ""]; return [string.slice(0, index), string.slice(index + length)]; }; /** * * @param {string|RexExp} token * @param {string} string * @returns {string[]} */ const extractTokenAndRest = (token, string) => { let index = 0; let length = 0; if (typeof token?.exec === "function") { const exp = new RegExp(token); const match = exp.exec(string); index = match?.index ?? -1; length = match?.[0].length ?? 0; } else { index = string.indexOf(token); length = token.length; } const splitTokenNotFound = index === -1; if (splitTokenNotFound) return [string, ""]; return [string.slice(0, index + length), string.slice(index + length)]; }; /** * * @param {string} token * @param {string} string */ const getAmountOfTokenInBeginningOfFile = (token, string) => { let count = 0; let searchIndex = 0; while (true) { if (searchIndex + (token.length - 1) > string.length) return count; const index = string.slice(searchIndex).indexOf(token); const indexNotAtStartOfString = index !== 0; if (indexNotAtStartOfString) return count; count++; searchIndex += token.length; } }; /** * @type {Symbol[]} */ const AllSymbols = [ JustALineBreak, Heading, UnorderedListItem, OrderedListItem, Bold, Italic, ImageLink, Link, MultiLineCode, SingleLineCode, TextRow, ]; /** * * @param {string} markdown * @param {string} assetDirectory * @returns {string} */ export const toHtml = (markdown, assetDirectory) => { // Stage one, markdown to symbols const symbols = toSymbols(markdown, assetDirectory); // Stage two, expanding symbols symbols.forEach((s) => s.process(assetDirectory)); // Stage three, operations based on symbol relationship const stageThree = stageThreeProcessing(symbols); // Stage four, rendering const html = stageThree // .filter((s) => !(s instanceof JustALineBreak)) .map((s) => s.render()) .join(""); return `
    ${html}
    `; }; /** * * @param {Symbol[]} symbols * @returns {Symbol[]} */ const stageThreeProcessing = (symbols) => { const localSymbols = [...symbols]; // Turn line break pairs into paragraphs while (localSymbols.filter((s) => s instanceof JustALineBreak).length !== 0) { const startIndex = localSymbols.findIndex( (s) => s instanceof JustALineBreak ); const subEndIndex = localSymbols .slice(startIndex + 1) .findIndex((s) => s instanceof JustALineBreak); const endIndex = subEndIndex === -1 ? localSymbols.length - 1 : startIndex + 1 + subEndIndex; const lastItemIsNotLineBreak = subEndIndex === -1; const children = localSymbols.slice( startIndex + 1, lastItemIsNotLineBreak ? localSymbols.length : endIndex ); const paragraph = new Paragraph(); paragraph.children = children; localSymbols.splice(startIndex, children.length + 1, paragraph); } // Fix indentation of bullet elements return localSymbols; }; /** * * @param {string} markdown * @param {string} assetDirectory * @param {Symbol[]} allowedSymbols * @returns {Symbol[]} */ const toSymbols = (markdown, assetDirectory, allowedSymbols = AllSymbols) => { const lineFeed = new LineFeed(markdown); const symbols = []; while (!lineFeed.isEmpty()) { const line = lineFeed.peek(); const factory = allowedSymbols.find((factory) => factory.canParse(line)); const symbol = factory.create(lineFeed, assetDirectory); symbols.push(symbol); } return symbols; };