Files
blog/packages/@zblog/toolchain/src/markdown-parser.ts
wholteza 63a240a8fe Styled note template
Fixed link regex
Fixed pre width

Squashed commit of the following:

commit 20038e290a
Author: wholteza <zackarias@montell.se>
Date:   Sat Sep 20 20:55:21 2025 +0200

    Fix width of pre element

commit 52b49e18a2
Author: wholteza <zackarias@montell.se>
Date:   Sat Sep 20 18:59:36 2025 +0200

    Fix link regexp

commit 5036b3bca6
Author: wholteza <zackarias@montell.se>
Date:   Sat Sep 20 18:53:00 2025 +0200

    Changed font size

commit 5063c088eb
Author: wholteza <zackarias@montell.se>
Date:   Sat Sep 20 18:52:22 2025 +0200

    Fixed margins

commit acd6ed63e1
Author: wholteza <zackarias@montell.se>
Date:   Sat Sep 20 18:51:03 2025 +0200

    Add leading space to links

commit 8b3c7871ab
Author: wholteza <zackarias@montell.se>
Date:   Sat Sep 20 18:50:38 2025 +0200

    Add color to links

commit 2658d688ca
Author: wholteza <zackarias@montell.se>
Date:   Sat Sep 20 18:45:49 2025 +0200

    Fixed reloading of browser when developing

commit 03e2361798
Author: wholteza <zackarias@montell.se>
Date:   Fri Sep 19 21:45:40 2025 +0200

    Change header line height

commit a1f6994c02
Author: wholteza <zackarias@montell.se>
Date:   Fri Sep 19 21:42:08 2025 +0200

    Align note with index
2025-09-20 20:57:17 +02:00

968 lines
21 KiB
TypeScript

class LineFeed {
originalText: string | undefined = undefined;
/**
* @type {string[]}
*/
feed: string[] | undefined = undefined;
/**
*
* @param {string} markdown
*/
constructor(markdown: string) {
this.originalText = markdown;
// TODO: Need to identify line endings
this.feed = markdown.split("\n");
}
/**
* @returns {string}
*/
peek(): string {
return this.feed?.[0] ?? "";
}
/**
*
* @returns {string}
*/
claim(): string {
const line = this.feed?.shift();
if (line === undefined) throw new Error("Feed is empty");
return line;
}
/**
*
* @param {string} line
*/
push(line: string | null | undefined) {
if (line === undefined || line === null) return;
this.feed = [line, ...(this.feed ?? [])];
}
/**
*
* @returns {boolean}
*/
isEmpty(): boolean {
return this.feed?.length === 0;
}
}
class Symbol {
static canParse(line: string) {
throw new Error("Not implemented");
}
static create(lineFeed: LineFeed, assetDirectory: string): Symbol {
throw new Error("Not implemented");
}
/**
*
* @param {string} assetDirectory
* @returns {void}
*/
process(assetDirectory: string): void {
throw new Error("Not Implemented");
}
render() {
throw new Error("Not implemented");
}
}
class Heading extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
* @type {number}
*/
level: number = 1;
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return line.trim().startsWith("#");
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<h${this.level}>${this.text}</h${this.level}>`;
}
}
class JustALineBreak extends Symbol {
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return line === "";
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
const instance = new JustALineBreak();
lineFeed.claim();
return instance;
}
/**
*
* @param {string} assetDirectory
* @returns {void}
*/
process(assetDirectory: string): void {
return;
}
render() {
throw new Error("Symbol cannot be rendered directly");
}
}
class Paragraph extends Symbol {
/**
* @type {string[]}
*/
lines: string[] = [];
/**
* @type {Symbol[]}
*/
children: Symbol[] = [];
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return line === "";
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<p>${this.children.map((c) => c.render()).join("")}</p>`;
}
}
class UnorderedListItem extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
* @type {number}
*/
level: number = 0;
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return line.trim().startsWith("- ");
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<li class="indent-${this.level}">${this.text} indentation level ${this.level}</li>`;
}
}
class OrderedListItem extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
* @type {number}
*/
level: number = 0;
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return new RegExp(/^\d\. /).test(line.trim());
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<li class="indent-${this.level}">${this.text} indentation level ${this.level}</li>`;
}
}
class Link extends Symbol {
/**
* @type {RegExp}
*/
static canParseRegExp: RegExp = new RegExp(/^\[.*\]\(.*\)/);
/**
* @type {RegExp}
*/
static textAndLinkRegExp: RegExp = new RegExp
(
/\[(?<text>[^\]]+)\]\((?<link>[^)]+)\)/
);
/**
* @type {string}
*/
text: string = "";
/**
* @type {string}
*/
link: string = "";
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return Link.canParseRegExp.test(line.trim());
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
// TODO: This leading space should probably be somewhere else.
return ` <a href="${this.link}">${this.text}</a>`;
}
}
class ImageLink extends Symbol {
/**
* @type {RegExp}
*/
static canParseRegExp: RegExp = new RegExp(/^!\[.*\]\(.*\)/);
/**
* @type {RegExp}
*/
static textAndLinkRegExp: RegExp = new RegExp(
/!\[(?<text>.*)\]\((?<link>.*)\)/
);
/**
* @type {string}
*/
static assetDirectoryToken: string = "@asset/";
/**
* @type {string}
*/
alt: string = "";
/**
* @type {string}
*/
link: string = "";
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return ImageLink.canParseRegExp.test(line.trim());
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed, assetDirectory: string): Symbol {
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: string): void {
return;
}
render() {
return `<img src="${this.link}" alt="${this.alt}" loading="lazy"/>`;
}
}
class Italic extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return line.startsWith("_");
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<i>${this.text}</i>`;
}
}
class Bold extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return line.startsWith("**");
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<b>${this.text}</b>`;
}
}
class SingleLineCode extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return line.startsWith("`");
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<code>${this.text}</code>`;
}
}
class MultiLineCode extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
const trimmedLine = line.trim();
return trimmedLine.startsWith("```");
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
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: string): void {
return;
}
render() {
return `<pre><code>${this.text}</code></pre>`;
}
}
class TextRow extends Symbol {
/**
* @type {string}
*/
text: string = "";
/**
* @type {Symbol[]}
*/
children: Symbol[] = [];
/**
* @type {Symbol[]}
*/
allowedChildren: (typeof Symbol)[] = [
Bold,
Italic,
ImageLink,
Link,
MultiLineCode,
SingleLineCode,
TextRow,
];
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return true;
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
const instance = new TextRow();
instance.text = lineFeed.claim();
return instance;
}
/**
*
* @param {string} assetDirectory
* @returns {void}
*/
process(assetDirectory: string): void {
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: string = "";
/**
* @type {Symbol[]}
*/
children: Symbol[] = [];
/**
*
* @param {string} line
* @returns {boolean}
*/
static canParse(line: string): boolean {
return true;
}
/**
*
* @param {LineFeed} lineFeed
* @returns {Symbol}
*/
static create(lineFeed: LineFeed): Symbol {
const instance = new PlainText();
instance.text = lineFeed.claim();
return instance;
}
/**
*
* @param {string} assetDirectory
* @returns {void}
*/
process(assetDirectory: string): void {
return;
}
render() {
return `<span>${this.text}</span>`;
}
}
const isRegExp = (value: unknown): value is RegExp => value instanceof RegExp;
/**
*
* @param {string|RexExp} token
* @param {string} string
* @returns {string[]}
*/
const splitStringAtToken = (
token: string | RegExp,
string: string
): string[] => {
let index = 0;
let length = 0;
if (isRegExp(token)) {
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|RegExp} token
* @param {string} string
* @returns {string[]}
*/
const extractTokenAndRest = (
token: string | RegExp,
string: string
): string[] => {
let index = 0;
let length = 0;
if (isRegExp(token)) {
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, string: 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: (typeof Symbol)[] = [
JustALineBreak,
Heading,
UnorderedListItem,
OrderedListItem,
Bold,
Italic,
ImageLink,
Link,
MultiLineCode,
SingleLineCode,
TextRow,
];
/**
*
* @param {string} markdown
* @param {string} assetDirectory
* @returns {string}
*/
export const toHtml = (markdown: string, assetDirectory: string): string => {
// 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 `<div class="content">${html}</div>`;
};
/**
*
* @param {Symbol[]} symbols
* @returns {Symbol[]}
*/
const stageThreeProcessing = (symbols: Symbol[]): Symbol[] => {
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: string,
assetDirectory: string,
allowedSymbols: (typeof Symbol)[] = AllSymbols
): Symbol[] => {
const lineFeed = new LineFeed(markdown);
const symbols: Symbol[] = [];
while (!lineFeed.isEmpty()) {
const line = lineFeed.peek();
const factory = allowedSymbols.find((factory) => factory.canParse(line));
if (factory === undefined) {
const textRow = TextRow.create(lineFeed);
symbols.push(textRow);
continue;
}
const symbol = factory.create(lineFeed, assetDirectory);
symbols.push(symbol);
}
return symbols;
};