wip: updated markdown parsing for starred spans, improved error handling behavior of i18n messages

This commit is contained in:
Benjamin Singleton 2026-03-02 00:16:07 -06:00
parent 00d7e1afc7
commit f7a504d5a3
3 changed files with 380 additions and 214 deletions

View file

@ -16,9 +16,9 @@ const props = defineProps<{
lineClass?: CssClass; lineClass?: CssClass;
tag?: string; tag?: string;
}>(); }>();
const providedSlots = const providedSlots =
defineSlots<{ [K in DeepReadonly<Slot>]: () => VNode[] }>(); defineSlots<{ [K in DeepReadonly<Slot>]: () => VNode[] }>();
console.log(Object.entries(props.markdown));
function tryResolveComponentName(type: unknown): string | undefined { function tryResolveComponentName(type: unknown): string | undefined {
if (!type || typeof type !== "object") return undefined; if (!type || typeof type !== "object") return undefined;

View file

@ -113,7 +113,19 @@ export function compileLocale<Config extends LocaleConfig>(
return fallbackValue as GenericMessageFn; 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 { } else {
const uncompiledSubrecord = (() => { const uncompiledSubrecord = (() => {
if (uncompiledValue?.type !== "subrecord") { if (uncompiledValue?.type !== "subrecord") {
@ -298,6 +310,7 @@ function compileStringMessage(
return { return {
type: "ok", type: "ok",
ok: (args: Record<string, FluentVariable> = {}) => { ok: (args: Record<string, FluentVariable> = {}) => {
const stringRes = ((): Result<string, string> => {
const stringLiteralRes = parseMessageLiteral( const stringLiteralRes = parseMessageLiteral(
"string", "string",
bundle.formatPattern(pattern, args), bundle.formatPattern(pattern, args),
@ -306,9 +319,10 @@ function compileStringMessage(
if (stringLiteralRes.type === "err") { if (stringLiteralRes.type === "err") {
// This should hopefully never happen since we've already // This should hopefully never happen since we've already
// verified all message variants parse as valid strings above // verified all message variants parse as valid strings above
throw new Error( return {
`Failed to parse string literal after compilation!\n${stringLiteralRes.err}`, type: "err",
); err: `Failed to parse string literal after compilation!\n${stringLiteralRes.err}`,
};
} }
const stringLiteral = stringLiteralRes.ok; const stringLiteral = stringLiteralRes.ok;
@ -319,12 +333,27 @@ function compileStringMessage(
// This should hopefully never happen since we've already // This should hopefully never happen since we've already
// verified all message variants parse as valid strings above // verified all message variants parse as valid strings above
// TODO: no we dont, do that // TODO: no we dont, do that
throw new Error( return {
`Failed to parse string after compilation!\n${res.err}`, type: "err",
); err: `Failed to parse string after compilation!\n${res.err}`,
};
} }
return res.ok; 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);
}
}
}, },
}; };
} }
@ -345,7 +374,7 @@ function compileMarkdownMessage(
// typecheck markdown // typecheck markdown
const markdownSlots = configMarkdown.features.slots; const markdownSlots = Object.keys(configMarkdown.features.slots);
// check if all variants are valid markdown // check if all variants are valid markdown
for (const variant of allVariants) { for (const variant of allVariants) {
@ -359,10 +388,7 @@ function compileMarkdownMessage(
} }
const markdownLiteral = markdownLiteralRes.ok; const markdownLiteral = markdownLiteralRes.ok;
const markdownRes = parseMarkdown( const markdownRes = parseMarkdown(markdownLiteral, markdownSlots);
markdownLiteral,
Object.keys(markdownSlots),
);
if (markdownRes.type === "err") { if (markdownRes.type === "err") {
return { return {
@ -375,7 +401,8 @@ function compileMarkdownMessage(
// TODO: will need to make sure markdown/slots are escapes when inserting variable values // TODO: will need to make sure markdown/slots are escapes when inserting variable values
return { return {
type: "ok", type: "ok",
ok: (args: Record<string, FluentVariable> = {}) => { ok: (args: Record<string, FluentVariable> = {}): Markdown => {
const markdownRes = ((): Result<Markdown, string> => {
const markdownLiteralRes = parseMessageLiteral( const markdownLiteralRes = parseMessageLiteral(
"md", "md",
bundle.formatPattern(pattern, args), bundle.formatPattern(pattern, args),
@ -384,30 +411,73 @@ function compileMarkdownMessage(
if (markdownLiteralRes.type === "err") { if (markdownLiteralRes.type === "err") {
// This should hopefully never happen since we've already // This should hopefully never happen since we've already
// verified all message variants parse as valid markdown above // verified all message variants parse as valid markdown above
throw new Error( return {
`Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`, type: "err",
); err: `Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`,
};
} }
const markdownLiteral = markdownLiteralRes.ok; const markdownLiteral = markdownLiteralRes.ok;
const res = parseMarkdown( const res = parseMarkdown(markdownLiteral, markdownSlots);
markdownLiteral,
Object.keys(markdownSlots),
);
if (res.type === "err") { if (res.type === "err") {
// This should hopefully never happen since we've already // This should hopefully never happen since we've already
// verified all message variants parse as valid markdown above // verified all message variants parse as valid markdown above
throw new Error( return {
`Failed to parse markdown after compilation!\n${res.err}`, type: "err",
); err: `Failed to parse markdown after compilation!\n${res.err}`,
};
} }
return res.ok; 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,
);
}
}
}, },
}; };
} }
function createMissingStringFallback(
messageIdChain: readonly [...string[], string],
): string {
return `[#${fmtMessageIdChain(messageIdChain)}#]`;
}
function createMissingMarkdownFallback<Slot extends string>(
messageIdChain: readonly [...string[], string],
slots: Slot[],
): Markdown<Slot> {
return {
elements: [
{
type: "paragraph",
paragraph: {
spans: [
{
type: "plain",
plain: createMissingStringFallback(messageIdChain),
},
],
},
},
],
slots,
};
}
interface CompileSublocaleCtx<Subconfig extends LocaleConfig> { interface CompileSublocaleCtx<Subconfig extends LocaleConfig> {
subconfig: Subconfig; subconfig: Subconfig;
uncompiledSublocale: UncompiledLocale | undefined; uncompiledSublocale: UncompiledLocale | undefined;

View file

@ -144,44 +144,32 @@ function parseMarkdownLine<Slot extends string>(
line: string, line: string,
slots: readonly Slot[], slots: readonly Slot[],
): Result<MarkdownLine<Slot>, string> { ): Result<MarkdownLine<Slot>, string> {
interface ResolvedLine {
deprefixedLine: string;
type: MarkdownLine["type"];
}
const { deprefixedLine, type } = ((): ResolvedLine => {
if (line.startsWith("#")) { if (line.startsWith("#")) {
const elementsRes = parseMarkdownSpans(line.substring(1), slots); return { deprefixedLine: line.substring(1), type: "header" };
} else if (line.startsWith("-")) {
return { deprefixedLine: line.substring(1), type: "ulistItem" };
} else {
return { deprefixedLine: line, type: "paragraph" };
}
})();
if (elementsRes.type === "err") { const spansRes = parseMarkdownSpans(deprefixedLine, slots);
if (spansRes.type === "err") {
return { return {
type: "err", type: "err",
err: `While parsing header spans:\n${elementsRes.err}`, err: `While parsing ${type} spans:\n${spansRes.err}`,
}; };
} }
const elements = elementsRes.ok; const spans = spansRes.ok;
return { type: "ok", ok: { type: "header", spans: elements } }; return { type: "ok", ok: { type, spans } };
}
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 elements = elementsRes.ok;
return { type: "ok", ok: { type: "ulistItem", spans: elements } };
}
const elementsRes = parseMarkdownSpans(line, slots);
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing elements:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "paragraph", spans: elements } };
} }
function parseMarkdownSpans<Slot extends string>( function parseMarkdownSpans<Slot extends string>(
@ -197,64 +185,123 @@ function parseMarkdownSpans<Slot extends string>(
return { type: "err", err: "Subheaders are not supported." }; return { type: "err", err: "Subheaders are not supported." };
} }
const trimmedLine = line.trim(); const chars = line.split("");
const chars = trimmedLine.split(""); const spansRes = readMarkdownSpans(
const elementsRes = readMarkdownSpans(chars, slots, { chars,
inItalic: false, slots,
inBold: false, ParseMarkdownSpansManager.new(),
}); );
if (elementsRes.type === "err") {
return elementsRes; if (spansRes.type === "err") {
return spansRes;
} }
const elements = elementsRes.ok; const spans = spansRes.ok;
return { type: "ok", ok: elements };
return { type: "ok", ok: spans };
} }
interface ReadMarkdownSpanCtx { class ParseMarkdownSpansManager {
inItalic: boolean; private inItalic: boolean;
inBold: boolean; private inBold: boolean;
private constructor() {
this.inItalic = false;
this.inBold = false;
}
public static new(): ParseMarkdownSpansManager {
return new this();
}
public tryUseItalic<R>(f: () => Result<R, string>): Result<R, string> {
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<R>(f: () => Result<R, string>): Result<R, string> {
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<Slot extends string>( function readMarkdownSpans<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx, manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
const elements: MarkdownSpan<Slot>[] = []; const spans: MarkdownSpan<Slot>[] = [];
while (true) { while (true) {
const elementRes = readMarkdownSpan(chars, slots, ctx); const spanRes = readMarkdownSpan(chars, slots, manager);
if (elementRes.type === "err") { if (spanRes.type === "err") {
return elementRes; return spanRes;
} }
const element = elementRes.ok; const span = spanRes.ok;
if (element.length === 0) { if (span === undefined) {
break; break;
} }
elements.push(...element); spans.push(span);
} }
return { type: "ok", ok: elements }; return { type: "ok", ok: spans };
} }
function readMarkdownSpan<Slot extends string>( function readMarkdownSpan<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx, manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>[], string> { ): Result<MarkdownSpan<Slot> | undefined, string> {
const [firstChar, secondChar] = chars; const [firstChar, secondChar, thirdChar] = chars;
if (firstChar === undefined) { if (firstChar === undefined) {
return { type: "ok", ok: [] }; return { type: "ok", ok: undefined };
} else if (firstChar === "<") { } else if (firstChar === "<") {
return readMarkdownSpanSlot(chars, slots); return readMarkdownSpanSlot(chars, slots);
} else if (firstChar === "[") { } else if (firstChar === "[") {
return readMarkdownSpanLink(chars, slots, ctx); return readMarkdownSpanLink(chars, slots, manager);
} else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) { } else if (firstChar === "*") {
return readMarkdownSpanBold(chars, slots, ctx); if (secondChar !== "*") {
} else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) { return readMarkdownSpanItalic(chars, slots, manager);
return readMarkdownSpanItalic(chars, slots, ctx); }
if (thirdChar !== "*") {
return readMarkdownSpanBold(chars, slots, manager);
}
return readMarkdownSpanBoldItalic(chars, slots, manager);
} else { } else {
return readMarkdownSpanPlain(chars); return readMarkdownSpanPlain(chars);
} }
@ -268,7 +315,7 @@ function isInArray<T, U extends T>(value: T, array: readonly U[]): value is U {
function readMarkdownSpanSlot<Slot extends string>( function readMarkdownSpanSlot<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
): Result<MarkdownSpan<Slot>[], string> { ): Result<MarkdownSpan<Slot>, string> {
const openAngleRes = expectReadChar(chars, "<"); const openAngleRes = expectReadChar(chars, "<");
if (openAngleRes.type === "err") { if (openAngleRes.type === "err") {
return openAngleRes; return openAngleRes;
@ -289,20 +336,20 @@ function readMarkdownSpanSlot<Slot extends string>(
return { type: "err", err: `Unexpected slot name: ${slotName}` }; 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<Slot extends string>( function readMarkdownSpanLink<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx, manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>[], string> { ): Result<MarkdownSpan<Slot>, string> {
const openSquareRes = expectReadChar(chars, "["); const openSquareRes = expectReadChar(chars, "[");
if (openSquareRes.type === "err") { if (openSquareRes.type === "err") {
return openSquareRes; return openSquareRes;
} }
const labelElementsRes = readMarkdownSpans(chars, slots, ctx); const labelElementsRes = readMarkdownSpans(chars, slots, manager);
if (labelElementsRes.type === "err") { if (labelElementsRes.type === "err") {
return labelElementsRes; return labelElementsRes;
} }
@ -493,9 +540,7 @@ function readMarkdownSpanLink<Slot extends string>(
return { return {
type: "ok", type: "ok",
ok: [ ok: { type: "link", link: { label: resolvedLabel, to: dest, newTab } },
{ type: "link", link: { label: resolvedLabel, to: dest, newTab } },
],
}; };
} }
@ -567,36 +612,76 @@ function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
} }
} }
function readMarkdownSpanBold<Slot extends string>( function readMarkdownSpanItalic<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx, manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>[], string> { ): Result<MarkdownSpan<Slot>, string> {
const firstStarRes = expectReadChar(chars, "*"); return manager.tryUseItalic(() => {
if (firstStarRes.type === "err") { const singleStarRes = expectReadString(chars, "*");
return firstStarRes; if (singleStarRes.type === "err") {
return singleStarRes;
} }
const secondStarRes = expectReadChar(chars, "*"); const spans: MarkdownSpan<Slot>[] = [];
if (secondStarRes.type === "err") {
return secondStarRes;
}
ctx.inBold = true;
const elements: MarkdownSpan<Slot>[] = [];
let closed = false; let closed = false;
while (true) { while (true) {
const elementRes = readMarkdownSpan(chars, slots, ctx); const spanRes = readMarkdownSpan(chars, slots, manager);
if (elementRes.type === "err") { if (spanRes.type === "err") {
return elementRes; return spanRes;
} }
const element = elementRes.ok; const span = spanRes.ok;
if (element.length === 0) { if (span === undefined) {
break; break;
} }
elements.push(...element); spans.push(span);
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" };
}
return { type: "ok", ok: { type: "italic", italic: spans } };
});
}
function readMarkdownSpanBold<Slot extends string>(
chars: string[],
slots: readonly Slot[],
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
return manager.tryUseBold(() => {
const doubleStarRes = expectReadString(chars, "**");
if (doubleStarRes.type === "err") {
return doubleStarRes;
}
const spans: MarkdownSpan<Slot>[] = [];
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; const [firstChar, secondChar] = chars;
if (firstChar === "*" && secondChar === "*") { if (firstChar === "*" && secondChar === "*") {
chars.shift(); chars.shift();
@ -609,44 +694,48 @@ function readMarkdownSpanBold<Slot extends string>(
} }
} }
ctx.inBold = !closed; if (!closed) {
if (closed) { return { type: "err", err: "Bold span (**) is never closed" };
return { type: "ok", ok: [{ type: "bold", bold: elements }] };
} else {
return {
type: "ok",
ok: [{ type: "plain", plain: "**" }, ...elements],
};
} }
return { type: "ok", ok: { type: "bold", bold: spans } };
});
} }
function readMarkdownSpanItalic<Slot extends string>( function readMarkdownSpanBoldItalic<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx, manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>[], string> { ): Result<MarkdownSpan<Slot>, string> {
const starRes = expectReadChar(chars, "*"); return manager.tryUseBold(() =>
if (starRes.type === "err") { manager.tryUseItalic(() => {
return starRes; const tripleStarRes = expectReadString(chars, "***");
if (tripleStarRes.type === "err") {
return tripleStarRes;
} }
ctx.inItalic = true; const spans: MarkdownSpan<Slot>[] = [];
const elements: MarkdownSpan<Slot>[] = [];
let closed = false; let closed = false;
while (true) { while (true) {
const elementRes = readMarkdownSpan(chars, slots, ctx); const spanRes = readMarkdownSpan(chars, slots, manager);
if (elementRes.type === "err") { if (spanRes.type === "err") {
return elementRes; return spanRes;
} }
const element = elementRes.ok; const span = spanRes.ok;
if (element.length === 0) { if (span === undefined) {
break; break;
} }
elements.push(...element); spans.push(span);
const [firstChar] = chars; const [firstChar, secondChar, thirdChar] = chars;
if (firstChar === "*") { if (
firstChar === "*"
&& secondChar === "*"
&& thirdChar === "*"
) {
chars.shift();
chars.shift();
chars.shift(); chars.shift();
closed = true; closed = true;
break; break;
@ -656,17 +745,24 @@ function readMarkdownSpanItalic<Slot extends string>(
} }
} }
ctx.inItalic = !closed; if (!closed) {
if (closed) { return {
return { type: "ok", ok: [{ type: "italic", italic: elements }] }; type: "err",
} else { err: "Bold italic span (***) is never closed",
return { type: "ok", ok: [{ type: "plain", plain: "*" }, ...elements] }; };
} }
return {
type: "ok",
ok: { type: "bold", bold: [{ type: "italic", italic: spans }] },
};
}),
);
} }
function readMarkdownSpanPlain<Slot extends string>( function readMarkdownSpanPlain<Slot extends string>(
chars: string[], chars: string[],
): Result<MarkdownSpan<Slot>[], string> { ): Result<MarkdownSpan<Slot> | undefined, string> {
let plain = ""; let plain = "";
let escaped = false; let escaped = false;
while (true) { while (true) {
@ -701,7 +797,7 @@ function readMarkdownSpanPlain<Slot extends string>(
return { return {
type: "ok", type: "ok",
ok: plain.length > 0 ? [{ type: "plain", plain }] : [], ok: plain.length === 0 ? undefined : { type: "plain", plain },
}; };
} }