wip: fluent setup, locale compiler & typechecker, markdown parser

This commit is contained in:
Benjamin Singleton 2026-02-22 21:25:07 -06:00
parent 4d875b1416
commit a240a1b954
12 changed files with 1083 additions and 0 deletions

View file

@ -0,0 +1,541 @@
import type {
SmartDest,
SmartExternalDest,
SmartInternalDest,
} from "@/utils/smart-dest";
import type { Result } from "@/utils/types";
import type { RouteNamedMap } from "vue-router/auto-routes";
import { routes } from "vue-router/auto-routes";
export type MarkdownLine = {
type: "paragraph" | "header";
elements: MarkdownLineElement[];
};
export type MarkdownFeature =
| Exclude<MarkdownLine["type"], "paragraph">
| Exclude<MarkdownLineElement["type"], "plain">;
export type MarkdownLineElement =
| { type: "plain"; plain: string }
| { type: "italic"; italic: MarkdownLineElement[] }
| { type: "bold"; bold: MarkdownLineElement[] }
| { type: "link"; link: { name: string; to: SmartDest } }
| { type: "slot"; slot: string };
export function parseMarkdown(
markdownString: string,
): Result<MarkdownLine[], string> {
const lines = markdownString.split("\n");
const markdownLines: MarkdownLine[] = [];
for (const line of lines) {
const markdownLineRes = parseMarkdownLine(line);
if (markdownLineRes.type === "err") {
return {
type: "err",
err: `On line ${String(markdownLines.length + 1)}:\n${markdownLineRes.err}`,
};
}
const markdownLine = markdownLineRes.ok;
markdownLines.push(markdownLine);
}
return { type: "ok", ok: markdownLines };
}
function parseMarkdownLine(line: string): Result<MarkdownLine, string> {
if (line.startsWith("#")) {
const elementsRes = parseMarkdownLineElements(line.substring(1));
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing elements:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "header", elements } };
}
const elementsRes = parseMarkdownLineElements(line);
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing elements:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "paragraph", elements } };
}
function parseMarkdownLineElements(
line: string,
): Result<MarkdownLineElement[], string> {
if (line.startsWith("#")) {
// subheaders may be supported in the future,
// so ignoring them or treating them as h1 headers now would be a breaking change when
// subheader support is implemented.
// making subheaders a compile error for now ensures
// all current i18n is backwards-compatible when/if they are implemented
return { type: "err", err: "Subheaders are not supported." };
}
const trimmedLine = line.trim();
const chars = trimmedLine.split("");
const elementsRes = readMarkdownLineElements(chars, {
inItalic: false,
inBold: false,
});
if (elementsRes.type === "err") {
return elementsRes;
}
const elements = elementsRes.ok;
return { type: "ok", ok: elements };
}
interface ReadMarkdownLineElementCtx {
inItalic: boolean;
inBold: boolean;
}
function readMarkdownLineElements(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const elements: MarkdownLineElement[] = [];
while (true) {
const elementRes = readMarkdownLineElement(chars, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
}
return { type: "ok", ok: elements };
}
function readMarkdownLineElement(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const [firstChar, secondChar] = chars;
if (firstChar === undefined) {
return { type: "ok", ok: [] };
} else if (firstChar === "<") {
return readMarkdownLineElementSlot(chars);
} else if (firstChar === "[") {
return readMarkdownLineElementLink(chars);
} else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) {
return readMarkdownLineElementBold(chars, ctx);
} else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) {
return readMarkdownLineElementItalic(chars, ctx);
} else {
return readMarkdownLineElementPlain(chars);
}
}
function readMarkdownLineElementSlot(
chars: string[],
): Result<MarkdownLineElement[], string> {
const openAngleRes = expectReadChar(chars, "<");
if (openAngleRes.type === "err") {
return openAngleRes;
}
const slotNameRes = readUntilClosing({
chars,
elementName: "slot",
closingChar: ">",
});
if (slotNameRes.type === "err") {
return slotNameRes;
}
const slotName = slotNameRes.ok;
return { type: "ok", ok: [{ type: "slot", slot: slotName }] };
}
function readMarkdownLineElementLink(
chars: string[],
): Result<MarkdownLineElement[], string> {
const openSquareRes = expectReadChar(chars, "[");
if (openSquareRes.type === "err") {
return openSquareRes;
}
const destRes = ((): Result<SmartDest, string> => {
if (peekStringEq(chars, "external:")) {
const externalRes = expectReadString(chars, "external:");
if (externalRes.type === "err") {
return externalRes;
}
const destRes = readUntilClosing({
chars,
elementName: "link dest",
closingChar: "]",
});
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const externalDestRes = validateExternalDest(dest);
if (externalDestRes.type === "err") {
return externalDestRes;
}
const externalDest = externalDestRes.ok;
return {
type: "ok",
ok: { type: "external", external: externalDest },
};
} else if (peekStringEq(chars, "internal:")) {
const internalRes = expectReadString(chars, "internal:");
if (internalRes.type === "err") {
return internalRes;
}
const destRes = readUntilClosing({
chars,
elementName: "link dest",
closingChar: "]",
});
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const internalDestRes = validateInternalDest(dest);
if (internalDestRes.type === "err") {
return internalDestRes;
}
const internalDest = internalDestRes.ok;
return {
type: "ok",
ok: { type: "internal", internal: internalDest },
};
} else {
return {
type: "err",
err: `Expected external: or internal: link prefix; Found: "${chars.slice(0, 10).join("")}..."`,
};
}
})();
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const openParenRes = expectReadChar(chars, "(");
if (openParenRes.type === "err") {
return openParenRes;
}
const nameRes = readUntilClosing({
chars,
elementName: "link name",
closingChar: ")",
});
if (nameRes.type === "err") {
return nameRes;
}
const name = nameRes.ok;
return { type: "ok", ok: [{ type: "link", link: { name, to: dest } }] };
}
function validateExternalDest(dest: string): Result<SmartExternalDest, string> {
const HTTPS_PREFIX = "https://";
const HTTP_PREFIX = "http://";
if (dest.startsWith(HTTPS_PREFIX)) {
return {
type: "ok",
ok: `${HTTPS_PREFIX}${dest.substring(HTTPS_PREFIX.length)}`,
};
}
if (dest.startsWith(HTTP_PREFIX)) {
return {
type: "ok",
ok: `${HTTP_PREFIX}${dest.substring(HTTP_PREFIX.length)}`,
};
}
return {
type: "err",
err: `External dest must start with https:// or http://`,
};
}
function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
const [routeString, id] = dest.split("#");
const validatedRouteRes = ((): Result<
keyof RouteNamedMap | undefined,
string
> => {
if (routeString === undefined || routeString.length === 0) {
return { type: "ok", ok: undefined };
}
const route = routes.find((route) => route.path === routeString);
if (route === undefined) {
return {
type: "err",
err: `Route with ID \`${routeString}\` does not exist`,
};
}
return {
type: "ok",
// SAFETY: we validated the route exists in the router about
ok: route.path as keyof RouteNamedMap,
};
})();
if (validatedRouteRes.type === "err") {
return validatedRouteRes;
}
const validatedRoute = validatedRouteRes.ok;
if (validatedRoute !== undefined) {
return { type: "ok", ok: { route: validatedRoute, id } };
} else if (id !== undefined) {
return { type: "ok", ok: { route: validatedRoute, id } };
} else {
return {
type: "err",
err: `Either route or ID must be defined for internal dest`,
};
}
}
function readMarkdownLineElementBold(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const firstStarRes = expectReadChar(chars, "*");
if (firstStarRes.type === "err") {
return firstStarRes;
}
const secondStarRes = expectReadChar(chars, "*");
if (secondStarRes.type === "err") {
return secondStarRes;
}
ctx.inBold = true;
const elements: MarkdownLineElement[] = [];
let closed = false;
while (true) {
const elementRes = readMarkdownLineElement(chars, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
const [firstChar, secondChar] = chars;
if (firstChar === "*" && secondChar === "*") {
chars.shift();
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
ctx.inBold = !closed;
if (closed) {
return { type: "ok", ok: [{ type: "bold", bold: elements }] };
} else {
return {
type: "ok",
ok: [{ type: "plain", plain: "**" }, ...elements],
};
}
}
function readMarkdownLineElementItalic(
chars: string[],
ctx: ReadMarkdownLineElementCtx,
): Result<MarkdownLineElement[], string> {
const starRes = expectReadChar(chars, "*");
if (starRes.type === "err") {
return starRes;
}
ctx.inItalic = true;
const elements: MarkdownLineElement[] = [];
let closed = false;
while (true) {
const elementRes = readMarkdownLineElement(chars, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
const [firstChar] = chars;
if (firstChar === "*") {
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
ctx.inItalic = !closed;
if (closed) {
return { type: "ok", ok: [{ type: "italic", italic: elements }] };
} else {
return { type: "ok", ok: [{ type: "plain", plain: "*" }, ...elements] };
}
}
function readMarkdownLineElementPlain(
chars: string[],
): Result<MarkdownLineElement[], string> {
let plain = "";
let escaped = false;
while (true) {
const peek = chars[0];
if (peek === undefined) {
break;
}
if (escaped) {
escaped = false;
} else {
if (peek === "\\") {
escaped = true;
chars.shift();
continue;
}
if (peek === "*" || peek === "<") {
break;
}
}
plain += peek;
chars.shift();
}
return { type: "ok", ok: [{ type: "plain", plain }] };
}
function expectReadChar(
chars: string[],
expectedChar: string,
): Result<void, string> {
const nextChar = chars.shift();
if (nextChar !== expectedChar) {
return {
type: "err",
err: `Expected: "${expectedChar}"; Found: ${nextChar === undefined ? "undefined" : `"${nextChar}"`}`,
};
}
return { type: "ok", ok: undefined };
}
function peekStringEq(chars: string[], expectedString: string): boolean {
return chars.slice(0, expectedString.length).join("") === expectedString;
}
function expectReadString(
chars: string[],
expectedString: string,
): Result<void, string> {
let foundString: string | undefined = undefined;
for (const expectedChar of expectedString) {
const nextChar = chars.shift();
if (nextChar !== undefined) {
foundString = (foundString ?? "") + nextChar;
}
if (expectedChar !== nextChar) {
return {
type: "err",
err: `Expected: "${expectedString}"; Found: ${foundString === undefined ? "undefined" : `"${foundString}"`}`,
};
}
}
return { type: "ok", ok: undefined };
}
interface ReadUntilClosingCtx {
chars: string[];
elementName: string;
closingChar: string;
}
function readUntilClosing(ctx: ReadUntilClosingCtx): Result<string, string> {
const { chars, elementName, closingChar } = ctx;
let value = "";
let closed = false;
let escaped = false;
while (true) {
const char = chars.shift();
if (char === undefined) {
closed = false;
break;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === closingChar && !escaped) {
closed = true;
break;
}
value += char;
}
if (!closed) {
return {
type: "err",
err: `Unclosed ${elementName}: <${value.replaceAll(closingChar, `\\${closingChar}`)}`,
};
}
return { type: "ok", ok: value };
}