From f7a504d5a31db156c9178d27b11db63e75cbd68e Mon Sep 17 00:00:00 2001 From: Benjamin Singleton <19498453+tetrogem@users.noreply.github.com> Date: Mon, 2 Mar 2026 00:16:07 -0600 Subject: [PATCH] wip: updated markdown parsing for starred spans, improved error handling behavior of i18n messages --- .../src/components/atoms/MarkdownDisplay.vue | 2 +- apps/vdn-static/src/new-i18n-lib/compile.ts | 186 +++++--- apps/vdn-static/src/new-i18n-lib/markdown.ts | 406 +++++++++++------- 3 files changed, 380 insertions(+), 214 deletions(-) diff --git a/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue b/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue index 585fc4d..daaa4f7 100644 --- a/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue +++ b/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue @@ -16,9 +16,9 @@ const props = defineProps<{ lineClass?: CssClass; tag?: string; }>(); + const providedSlots = defineSlots<{ [K in DeepReadonly]: () => VNode[] }>(); -console.log(Object.entries(props.markdown)); function tryResolveComponentName(type: unknown): string | undefined { if (!type || typeof type !== "object") return undefined; diff --git a/apps/vdn-static/src/new-i18n-lib/compile.ts b/apps/vdn-static/src/new-i18n-lib/compile.ts index 4cdecdd..9727835 100644 --- a/apps/vdn-static/src/new-i18n-lib/compile.ts +++ b/apps/vdn-static/src/new-i18n-lib/compile.ts @@ -113,7 +113,19 @@ export function compileLocale( return fallbackValue as GenericMessageFn; } - return () => `[#${fmtMessageIdChain(valueMessageIdChain)}#]`; + switch (configValue[configMessageTypeSymbol]) { + case configStringSymbol: { + return () => + createMissingStringFallback(valueMessageIdChain); + } + case configMarkdownSymbol: { + return () => + createMissingMarkdownFallback( + valueMessageIdChain, + Object.keys(configValue.features.slots), + ); + } + } } else { const uncompiledSubrecord = (() => { if (uncompiledValue?.type !== "subrecord") { @@ -298,33 +310,50 @@ function compileStringMessage( return { type: "ok", ok: (args: Record = {}) => { - const stringLiteralRes = parseMessageLiteral( - "string", - bundle.formatPattern(pattern, args), - ); - - if (stringLiteralRes.type === "err") { - // This should hopefully never happen since we've already - // verified all message variants parse as valid strings above - throw new Error( - `Failed to parse string literal after compilation!\n${stringLiteralRes.err}`, + const stringRes = ((): Result => { + const stringLiteralRes = parseMessageLiteral( + "string", + bundle.formatPattern(pattern, args), ); + + if (stringLiteralRes.type === "err") { + // This should hopefully never happen since we've already + // verified all message variants parse as valid strings above + return { + type: "err", + err: `Failed to parse string literal after compilation!\n${stringLiteralRes.err}`, + }; + } + + const stringLiteral = stringLiteralRes.ok; + + const res = parseString(stringLiteral); + + if (res.type === "err") { + // This should hopefully never happen since we've already + // verified all message variants parse as valid strings above + // TODO: no we dont, do that + return { + type: "err", + err: `Failed to parse string after compilation!\n${res.err}`, + }; + } + + const string = res.ok; + return { type: "ok", ok: string }; + })(); + + switch (stringRes.type) { + case "ok": { + const string = stringRes.ok; + return string; + } + case "err": { + const error = stringRes.err; + console.error(error); + return createMissingStringFallback(messageIdChain); + } } - - const stringLiteral = stringLiteralRes.ok; - - const res = parseString(stringLiteral); - - if (res.type === "err") { - // This should hopefully never happen since we've already - // verified all message variants parse as valid strings above - // TODO: no we dont, do that - throw new Error( - `Failed to parse string after compilation!\n${res.err}`, - ); - } - - return res.ok; }, }; } @@ -345,7 +374,7 @@ function compileMarkdownMessage( // typecheck markdown - const markdownSlots = configMarkdown.features.slots; + const markdownSlots = Object.keys(configMarkdown.features.slots); // check if all variants are valid markdown for (const variant of allVariants) { @@ -359,10 +388,7 @@ function compileMarkdownMessage( } const markdownLiteral = markdownLiteralRes.ok; - const markdownRes = parseMarkdown( - markdownLiteral, - Object.keys(markdownSlots), - ); + const markdownRes = parseMarkdown(markdownLiteral, markdownSlots); if (markdownRes.type === "err") { return { @@ -375,39 +401,83 @@ function compileMarkdownMessage( // TODO: will need to make sure markdown/slots are escapes when inserting variable values return { type: "ok", - ok: (args: Record = {}) => { - const markdownLiteralRes = parseMessageLiteral( - "md", - bundle.formatPattern(pattern, args), - ); - - if (markdownLiteralRes.type === "err") { - // This should hopefully never happen since we've already - // verified all message variants parse as valid markdown above - throw new Error( - `Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`, + ok: (args: Record = {}): Markdown => { + const markdownRes = ((): Result => { + const markdownLiteralRes = parseMessageLiteral( + "md", + bundle.formatPattern(pattern, args), ); + + if (markdownLiteralRes.type === "err") { + // This should hopefully never happen since we've already + // verified all message variants parse as valid markdown above + return { + type: "err", + err: `Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`, + }; + } + + const markdownLiteral = markdownLiteralRes.ok; + const res = parseMarkdown(markdownLiteral, markdownSlots); + + if (res.type === "err") { + // This should hopefully never happen since we've already + // verified all message variants parse as valid markdown above + return { + type: "err", + err: `Failed to parse markdown after compilation!\n${res.err}`, + }; + } + + return { type: "ok", ok: res.ok }; + })(); + + switch (markdownRes.type) { + case "ok": { + const markdown = markdownRes.ok; + return markdown; + } + case "err": { + const error = markdownRes.err; + console.error(error); + return createMissingMarkdownFallback( + messageIdChain, + markdownSlots, + ); + } } - - const markdownLiteral = markdownLiteralRes.ok; - const res = parseMarkdown( - markdownLiteral, - Object.keys(markdownSlots), - ); - - if (res.type === "err") { - // This should hopefully never happen since we've already - // verified all message variants parse as valid markdown above - throw new Error( - `Failed to parse markdown after compilation!\n${res.err}`, - ); - } - - return res.ok; }, }; } +function createMissingStringFallback( + messageIdChain: readonly [...string[], string], +): string { + return `[#${fmtMessageIdChain(messageIdChain)}#]`; +} + +function createMissingMarkdownFallback( + messageIdChain: readonly [...string[], string], + slots: Slot[], +): Markdown { + return { + elements: [ + { + type: "paragraph", + paragraph: { + spans: [ + { + type: "plain", + plain: createMissingStringFallback(messageIdChain), + }, + ], + }, + }, + ], + slots, + }; +} + interface CompileSublocaleCtx { subconfig: Subconfig; uncompiledSublocale: UncompiledLocale | undefined; diff --git a/apps/vdn-static/src/new-i18n-lib/markdown.ts b/apps/vdn-static/src/new-i18n-lib/markdown.ts index 72a0bae..4014dd8 100644 --- a/apps/vdn-static/src/new-i18n-lib/markdown.ts +++ b/apps/vdn-static/src/new-i18n-lib/markdown.ts @@ -144,44 +144,32 @@ function parseMarkdownLine( line: string, slots: readonly Slot[], ): Result, string> { - if (line.startsWith("#")) { - const elementsRes = parseMarkdownSpans(line.substring(1), slots); - - if (elementsRes.type === "err") { - return { - type: "err", - err: `While parsing header spans:\n${elementsRes.err}`, - }; - } - - const elements = elementsRes.ok; - return { type: "ok", ok: { type: "header", spans: elements } }; + interface ResolvedLine { + deprefixedLine: string; + type: MarkdownLine["type"]; } - if (line.startsWith("-")) { - const elementsRes = parseMarkdownSpans(line.substring(1), slots); - - if (elementsRes.type === "err") { - return { - type: "err", - err: `While parsing ulist item spans:\n${elementsRes.err}`, - }; + const { deprefixedLine, type } = ((): ResolvedLine => { + if (line.startsWith("#")) { + return { deprefixedLine: line.substring(1), type: "header" }; + } else if (line.startsWith("-")) { + return { deprefixedLine: line.substring(1), type: "ulistItem" }; + } else { + return { deprefixedLine: line, type: "paragraph" }; } + })(); - const elements = elementsRes.ok; - return { type: "ok", ok: { type: "ulistItem", spans: elements } }; - } + const spansRes = parseMarkdownSpans(deprefixedLine, slots); - const elementsRes = parseMarkdownSpans(line, slots); - if (elementsRes.type === "err") { + if (spansRes.type === "err") { return { type: "err", - err: `While parsing elements:\n${elementsRes.err}`, + err: `While parsing ${type} spans:\n${spansRes.err}`, }; } - const elements = elementsRes.ok; - return { type: "ok", ok: { type: "paragraph", spans: elements } }; + const spans = spansRes.ok; + return { type: "ok", ok: { type, spans } }; } function parseMarkdownSpans( @@ -197,64 +185,123 @@ function parseMarkdownSpans( return { type: "err", err: "Subheaders are not supported." }; } - const trimmedLine = line.trim(); - const chars = trimmedLine.split(""); - const elementsRes = readMarkdownSpans(chars, slots, { - inItalic: false, - inBold: false, - }); - if (elementsRes.type === "err") { - return elementsRes; + const chars = line.split(""); + const spansRes = readMarkdownSpans( + chars, + slots, + ParseMarkdownSpansManager.new(), + ); + + if (spansRes.type === "err") { + return spansRes; } - const elements = elementsRes.ok; - return { type: "ok", ok: elements }; + const spans = spansRes.ok; + + return { type: "ok", ok: spans }; } -interface ReadMarkdownSpanCtx { - inItalic: boolean; - inBold: boolean; +class ParseMarkdownSpansManager { + private inItalic: boolean; + private inBold: boolean; + + private constructor() { + this.inItalic = false; + this.inBold = false; + } + + public static new(): ParseMarkdownSpansManager { + return new this(); + } + + public tryUseItalic(f: () => Result): Result { + if (this.inItalic) { + return { + type: "err", + err: "Cannot nest italic span (*) inside of another italic span", + }; + } + + this.inItalic = true; + + const fRes = f(); + if (fRes.type === "err") { + return fRes; + } + + this.inItalic = false; + + const fOk = fRes.ok; + return { type: "ok", ok: fOk }; + } + + public tryUseBold(f: () => Result): Result { + if (this.inBold) { + return { + type: "err", + err: "Cannot nest bold span (**) inside of another bold span", + }; + } + + this.inBold = true; + + const fRes = f(); + if (fRes.type === "err") { + return fRes; + } + + this.inBold = false; + + const fOk = fRes.ok; + return { type: "ok", ok: fOk }; + } } function readMarkdownSpans( chars: string[], slots: readonly Slot[], - ctx: ReadMarkdownSpanCtx, + manager: ParseMarkdownSpansManager, ): Result[], string> { - const elements: MarkdownSpan[] = []; + const spans: MarkdownSpan[] = []; while (true) { - const elementRes = readMarkdownSpan(chars, slots, ctx); - if (elementRes.type === "err") { - return elementRes; + const spanRes = readMarkdownSpan(chars, slots, manager); + if (spanRes.type === "err") { + return spanRes; } - const element = elementRes.ok; - if (element.length === 0) { + const span = spanRes.ok; + if (span === undefined) { break; } - elements.push(...element); + spans.push(span); } - return { type: "ok", ok: elements }; + return { type: "ok", ok: spans }; } function readMarkdownSpan( chars: string[], slots: readonly Slot[], - ctx: ReadMarkdownSpanCtx, -): Result[], string> { - const [firstChar, secondChar] = chars; + manager: ParseMarkdownSpansManager, +): Result | undefined, string> { + const [firstChar, secondChar, thirdChar] = chars; if (firstChar === undefined) { - return { type: "ok", ok: [] }; + return { type: "ok", ok: undefined }; } else if (firstChar === "<") { return readMarkdownSpanSlot(chars, slots); } else if (firstChar === "[") { - return readMarkdownSpanLink(chars, slots, ctx); - } else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) { - return readMarkdownSpanBold(chars, slots, ctx); - } else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) { - return readMarkdownSpanItalic(chars, slots, ctx); + return readMarkdownSpanLink(chars, slots, manager); + } else if (firstChar === "*") { + if (secondChar !== "*") { + return readMarkdownSpanItalic(chars, slots, manager); + } + + if (thirdChar !== "*") { + return readMarkdownSpanBold(chars, slots, manager); + } + + return readMarkdownSpanBoldItalic(chars, slots, manager); } else { return readMarkdownSpanPlain(chars); } @@ -268,7 +315,7 @@ function isInArray(value: T, array: readonly U[]): value is U { function readMarkdownSpanSlot( chars: string[], slots: readonly Slot[], -): Result[], string> { +): Result, string> { const openAngleRes = expectReadChar(chars, "<"); if (openAngleRes.type === "err") { return openAngleRes; @@ -289,20 +336,20 @@ function readMarkdownSpanSlot( return { type: "err", err: `Unexpected slot name: ${slotName}` }; } - return { type: "ok", ok: [{ type: "slot", slot: slotName }] }; + return { type: "ok", ok: { type: "slot", slot: slotName } }; } function readMarkdownSpanLink( chars: string[], slots: readonly Slot[], - ctx: ReadMarkdownSpanCtx, -): Result[], string> { + manager: ParseMarkdownSpansManager, +): Result, string> { const openSquareRes = expectReadChar(chars, "["); if (openSquareRes.type === "err") { return openSquareRes; } - const labelElementsRes = readMarkdownSpans(chars, slots, ctx); + const labelElementsRes = readMarkdownSpans(chars, slots, manager); if (labelElementsRes.type === "err") { return labelElementsRes; } @@ -493,9 +540,7 @@ function readMarkdownSpanLink( return { type: "ok", - ok: [ - { type: "link", link: { label: resolvedLabel, to: dest, newTab } }, - ], + ok: { type: "link", link: { label: resolvedLabel, to: dest, newTab } }, }; } @@ -567,106 +612,157 @@ function validateInternalDest(dest: string): Result { } } -function readMarkdownSpanBold( - chars: string[], - slots: readonly Slot[], - ctx: ReadMarkdownSpanCtx, -): Result[], 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: MarkdownSpan[] = []; - let closed = false; - while (true) { - const elementRes = readMarkdownSpan(chars, slots, 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 readMarkdownSpanItalic( chars: string[], slots: readonly Slot[], - ctx: ReadMarkdownSpanCtx, -): Result[], string> { - const starRes = expectReadChar(chars, "*"); - if (starRes.type === "err") { - return starRes; - } - - ctx.inItalic = true; - const elements: MarkdownSpan[] = []; - let closed = false; - while (true) { - const elementRes = readMarkdownSpan(chars, slots, ctx); - if (elementRes.type === "err") { - return elementRes; + manager: ParseMarkdownSpansManager, +): Result, string> { + return manager.tryUseItalic(() => { + const singleStarRes = expectReadString(chars, "*"); + if (singleStarRes.type === "err") { + return singleStarRes; } - const element = elementRes.ok; - if (element.length === 0) { - break; + const spans: MarkdownSpan[] = []; + let closed = false; + while (true) { + const spanRes = readMarkdownSpan(chars, slots, manager); + if (spanRes.type === "err") { + return spanRes; + } + + const span = spanRes.ok; + if (span === undefined) { + break; + } + + spans.push(span); + + const [firstChar] = chars; + if (firstChar === "*") { + chars.shift(); + closed = true; + break; + } else if (firstChar === undefined) { + closed = false; + break; + } } - elements.push(...element); - const [firstChar] = chars; - if (firstChar === "*") { - chars.shift(); - closed = true; - break; - } else if (firstChar === undefined) { - closed = false; - break; + if (!closed) { + return { type: "err", err: "Italic span (*) is never closed" }; } - } - ctx.inItalic = !closed; - if (closed) { - return { type: "ok", ok: [{ type: "italic", italic: elements }] }; - } else { - return { type: "ok", ok: [{ type: "plain", plain: "*" }, ...elements] }; - } + return { type: "ok", ok: { type: "italic", italic: spans } }; + }); +} + +function readMarkdownSpanBold( + chars: string[], + slots: readonly Slot[], + manager: ParseMarkdownSpansManager, +): Result, string> { + return manager.tryUseBold(() => { + const doubleStarRes = expectReadString(chars, "**"); + if (doubleStarRes.type === "err") { + return doubleStarRes; + } + + const spans: MarkdownSpan[] = []; + let closed = false; + while (true) { + const spanRes = readMarkdownSpan(chars, slots, manager); + if (spanRes.type === "err") { + return spanRes; + } + + const span = spanRes.ok; + if (span === undefined) { + break; + } + + spans.push(span); + const [firstChar, secondChar] = chars; + if (firstChar === "*" && secondChar === "*") { + chars.shift(); + chars.shift(); + closed = true; + break; + } else if (firstChar === undefined) { + closed = false; + break; + } + } + + if (!closed) { + return { type: "err", err: "Bold span (**) is never closed" }; + } + + return { type: "ok", ok: { type: "bold", bold: spans } }; + }); +} + +function readMarkdownSpanBoldItalic( + chars: string[], + slots: readonly Slot[], + manager: ParseMarkdownSpansManager, +): Result, string> { + return manager.tryUseBold(() => + manager.tryUseItalic(() => { + const tripleStarRes = expectReadString(chars, "***"); + if (tripleStarRes.type === "err") { + return tripleStarRes; + } + + const spans: MarkdownSpan[] = []; + let closed = false; + while (true) { + const spanRes = readMarkdownSpan(chars, slots, manager); + if (spanRes.type === "err") { + return spanRes; + } + + const span = spanRes.ok; + if (span === undefined) { + break; + } + + spans.push(span); + const [firstChar, secondChar, thirdChar] = chars; + if ( + firstChar === "*" + && secondChar === "*" + && thirdChar === "*" + ) { + chars.shift(); + chars.shift(); + chars.shift(); + closed = true; + break; + } else if (firstChar === undefined) { + closed = false; + break; + } + } + + if (!closed) { + return { + type: "err", + err: "Bold italic span (***) is never closed", + }; + } + + return { + type: "ok", + ok: { type: "bold", bold: [{ type: "italic", italic: spans }] }, + }; + }), + ); } function readMarkdownSpanPlain( chars: string[], -): Result[], string> { +): Result | undefined, string> { let plain = ""; let escaped = false; while (true) { @@ -701,7 +797,7 @@ function readMarkdownSpanPlain( return { type: "ok", - ok: plain.length > 0 ? [{ type: "plain", plain }] : [], + ok: plain.length === 0 ? undefined : { type: "plain", plain }, }; }