From 4295a83ee102a4841d9cbf282f6e75b00ed1283e Mon Sep 17 00:00:00 2001 From: Benjamin Singleton <19498453+tetrogem@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:52:13 -0500 Subject: [PATCH] feat(i18n): first draft of vi18n lib --- apps/vdn-static/src/App.vue | 2 +- .../src/components/atoms/MarkdownDisplay.vue | 4 +- .../src/components/atoms/MarkdownParts.vue | 4 +- .../src/components/atoms/RichText.vue | 31 ------- .../molecules/DiscordRuleOverview.vue | 4 +- .../molecules/DiscordRuleSection.vue | 2 +- .../src/components/organisms/LocalePicker.vue | 2 +- .../src/{new-i18n => i18n}/config.ts | 24 ++--- .../src/{new-i18n => i18n}/greeting.ts | 0 .../src/{new-i18n => i18n}/index.ts | 30 +++++-- .../src/{new-i18n => i18n}/vilantic.ts | 0 apps/vdn-static/src/pages/discord/rules.vue | 2 +- apps/vdn-static/src/pages/index.vue | 8 +- apps/vdn-static/src/pages/kotoba.vue | 2 +- apps/vdn-static/src/pages/resources.vue | 4 +- .../{new-i18n-lib => vi18n-lib}/compile.ts | 90 +++++++++++++++---- .../src/{new-i18n-lib => vi18n-lib}/config.ts | 29 ++---- .../{new-i18n-lib => vi18n-lib}/markdown.ts | 88 ++++++++++++------ .../src/{new-i18n-lib => vi18n-lib}/setup.ts | 0 .../src/{new-i18n-lib => vi18n-lib}/spec.md | 0 20 files changed, 194 insertions(+), 132 deletions(-) delete mode 100644 apps/vdn-static/src/components/atoms/RichText.vue rename apps/vdn-static/src/{new-i18n => i18n}/config.ts (87%) rename apps/vdn-static/src/{new-i18n => i18n}/greeting.ts (100%) rename apps/vdn-static/src/{new-i18n => i18n}/index.ts (85%) rename apps/vdn-static/src/{new-i18n => i18n}/vilantic.ts (100%) rename apps/vdn-static/src/{new-i18n-lib => vi18n-lib}/compile.ts (88%) rename apps/vdn-static/src/{new-i18n-lib => vi18n-lib}/config.ts (89%) rename apps/vdn-static/src/{new-i18n-lib => vi18n-lib}/markdown.ts (92%) rename apps/vdn-static/src/{new-i18n-lib => vi18n-lib}/setup.ts (100%) rename apps/vdn-static/src/{new-i18n-lib => vi18n-lib}/spec.md (100%) diff --git a/apps/vdn-static/src/App.vue b/apps/vdn-static/src/App.vue index 022c5de..517c29a 100644 --- a/apps/vdn-static/src/App.vue +++ b/apps/vdn-static/src/App.vue @@ -6,7 +6,7 @@ import { vOnClickOutside } from "@vueuse/components"; import { useRouter } from "vue-router"; import SmartLink from "./components/atoms/SmartLink.vue"; import type { SmartDest } from "./utils/smart-dest"; -import { useLocale, type Locale } from "./new-i18n"; +import { useLocale, type Locale } from "./i18n"; const locale = useLocale(); diff --git a/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue b/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue index daaa4f7..76601d9 100644 --- a/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue +++ b/apps/vdn-static/src/components/atoms/MarkdownDisplay.vue @@ -7,7 +7,7 @@ import { } from "vue"; import MarkdownParts from "./MarkdownParts.vue"; import OptionalParent from "./OptionalParent.vue"; -import type { Markdown } from "@/new-i18n-lib/markdown"; +import type { Markdown } from "@/vi18n-lib/markdown"; import type { CssClass } from "@/utils/css"; const props = defineProps<{ @@ -58,7 +58,7 @@ const getComponentStack = () => { // Validate required slots at runtime onMounted(() => { const requiredSlots = props.markdown.slots; - const missingSlots = requiredSlots.filter( + const missingSlots = [...requiredSlots].filter( (slot) => providedSlots[slot] === undefined, ); diff --git a/apps/vdn-static/src/components/atoms/MarkdownParts.vue b/apps/vdn-static/src/components/atoms/MarkdownParts.vue index 708be6b..ce852d5 100644 --- a/apps/vdn-static/src/components/atoms/MarkdownParts.vue +++ b/apps/vdn-static/src/components/atoms/MarkdownParts.vue @@ -1,12 +1,12 @@ - - diff --git a/apps/vdn-static/src/components/molecules/DiscordRuleOverview.vue b/apps/vdn-static/src/components/molecules/DiscordRuleOverview.vue index 76914c5..2332a57 100644 --- a/apps/vdn-static/src/components/molecules/DiscordRuleOverview.vue +++ b/apps/vdn-static/src/components/molecules/DiscordRuleOverview.vue @@ -2,9 +2,9 @@ import SmartLink from "../atoms/SmartLink.vue"; import type { Value } from "@/utils/types"; import MarkdownDisplay from "../atoms/MarkdownDisplay.vue"; -import type { Locale } from "@/new-i18n"; +import type { Locale } from "@/i18n"; import type { DeepReadonly } from "vue"; -import { isEmptyMarkdown } from "@/new-i18n-lib/markdown"; +import { isEmptyMarkdown } from "@/vi18n-lib/markdown"; const props = defineProps<{ overview: DeepReadonly< diff --git a/apps/vdn-static/src/components/molecules/DiscordRuleSection.vue b/apps/vdn-static/src/components/molecules/DiscordRuleSection.vue index a4adb7b..64c5871 100644 --- a/apps/vdn-static/src/components/molecules/DiscordRuleSection.vue +++ b/apps/vdn-static/src/components/molecules/DiscordRuleSection.vue @@ -1,5 +1,5 @@ diff --git a/apps/vdn-static/src/pages/resources.vue b/apps/vdn-static/src/pages/resources.vue index bba9089..3d3e5e3 100644 --- a/apps/vdn-static/src/pages/resources.vue +++ b/apps/vdn-static/src/pages/resources.vue @@ -2,8 +2,8 @@ import LearningResourceWrapper, { type ResourceButton, } from "@/components/molecules/LearningResourceWrapper.vue"; -import { useLocale, type Locale } from "@/new-i18n"; -import type * as i18n from "@/new-i18n/config"; +import { useLocale, type Locale } from "@/i18n"; +import type * as i18n from "@/i18n/config"; import { ignore } from "@/utils/ignore"; import { computed } from "vue"; import discordImg from "@/assets/discord.png"; diff --git a/apps/vdn-static/src/new-i18n-lib/compile.ts b/apps/vdn-static/src/vi18n-lib/compile.ts similarity index 88% rename from apps/vdn-static/src/new-i18n-lib/compile.ts rename to apps/vdn-static/src/vi18n-lib/compile.ts index 9727835..294b23c 100644 --- a/apps/vdn-static/src/new-i18n-lib/compile.ts +++ b/apps/vdn-static/src/vi18n-lib/compile.ts @@ -30,7 +30,9 @@ type GenericMarkdownMessageFn = ( placeableArgs?: GenericMessageFnPlaceableArgs, ) => Markdown; -type GenericMessageFnPlaceableArgs = Record; +type GenericMessageFnPlaceableArgs = Record; + +type PlaceableValue = string | number; export interface CompileLocaleCtx { bundle: FluentBundle; @@ -42,7 +44,7 @@ export interface CompileLocaleCtx { export interface CompileLocaleRes { locale: Locale; - errors: string[]; + errors: readonly string[]; } export function compileLocale( @@ -122,7 +124,7 @@ export function compileLocale( return () => createMissingMarkdownFallback( valueMessageIdChain, - Object.keys(configValue.features.slots), + new Set(Object.keys(configValue.slots)), ); } } @@ -263,7 +265,7 @@ function compileMessage( case configMarkdownSymbol: { return compileMarkdownMessage({ bundle, - configMarkdown: configValue, + slots: new Set(Object.keys(configValue.slots)), messageIdChain, allVariants, pattern, @@ -309,7 +311,7 @@ function compileStringMessage( // TODO: will need to make sure markdown/slots are escapes when inserting variable values return { type: "ok", - ok: (args: Record = {}) => { + ok: (args: Record = {}) => { const stringRes = ((): Result => { const stringLiteralRes = parseMessageLiteral( "string", @@ -361,7 +363,7 @@ function compileStringMessage( interface CompileMarkdownMessageCtx { bundle: FluentBundle; messageIdChain: readonly [...string[], string]; - configMarkdown: ConfigMarkdown; + slots: ReadonlySet; allVariants: readonly PatternVariant[]; pattern: Pattern; } @@ -369,13 +371,10 @@ interface CompileMarkdownMessageCtx { function compileMarkdownMessage( ctx: CompileMarkdownMessageCtx, ): Result { - const { bundle, messageIdChain, configMarkdown, allVariants, pattern } = - ctx; + const { bundle, messageIdChain, slots, allVariants, pattern } = ctx; // typecheck markdown - const markdownSlots = Object.keys(configMarkdown.features.slots); - // check if all variants are valid markdown for (const variant of allVariants) { const markdownLiteralRes = parseMessageLiteral("md", variant.string); @@ -388,7 +387,7 @@ function compileMarkdownMessage( } const markdownLiteral = markdownLiteralRes.ok; - const markdownRes = parseMarkdown(markdownLiteral, markdownSlots); + const markdownRes = parseMarkdown(markdownLiteral, slots); if (markdownRes.type === "err") { return { @@ -401,11 +400,67 @@ function compileMarkdownMessage( // TODO: will need to make sure markdown/slots are escapes when inserting variable values return { type: "ok", - ok: (args: Record = {}): Markdown => { + ok: (args: Record = {}): Markdown => { + const escapedArgs = Object.fromEntries( + Object.entries(args).map(([id, value]) => { + const escapedValue = (() => { + switch (typeof value) { + case "number": { + return value; + } + case "string": { + return value + .split("") + .map((c) => { + switch (c) { + case "\\": { + return "\\\\"; + } + case "*": { + return "\\*"; + } + case "#": { + return "\\#"; + } + case "[": { + return "\\["; + } + case "]": { + return "\\]"; + } + case "(": { + return "\\("; + } + case ")": { + return "\\)"; + } + case "-": { + return "\\-"; + } + case "<": { + return "\\<"; + } + case ">": { + return "\\>"; + } + default: { + return c; + } + } + }) + .join(""); + } + } + })(); + + return [id, escapedValue] as const; + }), + ); + const markdownRes = ((): Result => { const markdownLiteralRes = parseMessageLiteral( "md", - bundle.formatPattern(pattern, args), + bundle.formatPattern(pattern, escapedArgs), ); if (markdownLiteralRes.type === "err") { @@ -418,7 +473,7 @@ function compileMarkdownMessage( } const markdownLiteral = markdownLiteralRes.ok; - const res = parseMarkdown(markdownLiteral, markdownSlots); + const res = parseMarkdown(markdownLiteral, slots); if (res.type === "err") { // This should hopefully never happen since we've already @@ -440,10 +495,7 @@ function compileMarkdownMessage( case "err": { const error = markdownRes.err; console.error(error); - return createMissingMarkdownFallback( - messageIdChain, - markdownSlots, - ); + return createMissingMarkdownFallback(messageIdChain, slots); } } }, @@ -458,7 +510,7 @@ function createMissingStringFallback( function createMissingMarkdownFallback( messageIdChain: readonly [...string[], string], - slots: Slot[], + slots: ReadonlySet, ): Markdown { return { elements: [ diff --git a/apps/vdn-static/src/new-i18n-lib/config.ts b/apps/vdn-static/src/vi18n-lib/config.ts similarity index 89% rename from apps/vdn-static/src/new-i18n-lib/config.ts rename to apps/vdn-static/src/vi18n-lib/config.ts index 9ce6cc5..7ebc253 100644 --- a/apps/vdn-static/src/new-i18n-lib/config.ts +++ b/apps/vdn-static/src/vi18n-lib/config.ts @@ -18,7 +18,12 @@ export interface ConfigMarkdown< > { [configMessageTypeSymbol]: typeof configMarkdownSymbol; placeables: Placeables; - features: ConfigMarkdownFeatures; + slots: Slots; + bold?: boolean; + italic?: boolean; + header?: boolean; + link?: boolean; + ulist?: boolean; } export interface ConfigPlaceableInfo< @@ -30,26 +35,12 @@ export interface ConfigPlaceableInfo< // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ConfigSlotInfo {} -export interface ConfigMarkdownFeatures< - Slots extends Partial>, -> { - bold?: boolean; - italic?: boolean; - header?: boolean; - link?: boolean; - ulist?: boolean; - slots: Slots; -} - export function string< const Placeables extends Partial>, >( opt: Omit, typeof configMessageTypeSymbol>, ): ConfigString { - return { - [configMessageTypeSymbol]: configStringSymbol, - placeables: opt.placeables, - }; + return { ...opt, [configMessageTypeSymbol]: configStringSymbol }; } export function markdown< @@ -61,11 +52,7 @@ export function markdown< typeof configMessageTypeSymbol >, ): ConfigMarkdown { - return { - [configMessageTypeSymbol]: configMarkdownSymbol, - placeables: opt.placeables, - features: opt.features, - }; + return { ...opt, [configMessageTypeSymbol]: configMarkdownSymbol }; } export type LocaleConfig = { diff --git a/apps/vdn-static/src/new-i18n-lib/markdown.ts b/apps/vdn-static/src/vi18n-lib/markdown.ts similarity index 92% rename from apps/vdn-static/src/new-i18n-lib/markdown.ts rename to apps/vdn-static/src/vi18n-lib/markdown.ts index 4014dd8..c47a624 100644 --- a/apps/vdn-static/src/new-i18n-lib/markdown.ts +++ b/apps/vdn-static/src/vi18n-lib/markdown.ts @@ -8,35 +8,42 @@ import type { RouteNamedMap } from "vue-router/auto-routes"; import { routes } from "vue-router/auto-routes"; export interface Markdown { - elements: MarkdownElement[]; - slots: Slot[]; + elements: readonly MarkdownElement[]; + slots: ReadonlySet; } export type MarkdownElement = - | { type: "paragraph"; paragraph: { spans: MarkdownSpan[] } } - | { type: "header"; header: { spans: MarkdownSpan[] } } - | { type: "ulist"; ulist: { items: MarkdownSpan[][] } }; + | { type: "paragraph"; paragraph: { spans: readonly MarkdownSpan[] } } + | { type: "header"; header: { spans: readonly MarkdownSpan[] } } + | { + type: "ulist"; + ulist: { items: readonly (readonly MarkdownSpan[])[] }; + }; type MarkdownLine = { type: "paragraph" | "header" | "ulistItem"; - spans: MarkdownSpan[]; + spans: readonly MarkdownSpan[]; }; export type MarkdownFeature = "header" | "ulist" | "italic" | "bold" | "link"; export type MarkdownSpan = | { type: "plain"; plain: string } - | { type: "italic"; italic: MarkdownSpan[] } - | { type: "bold"; bold: MarkdownSpan[] } + | { type: "italic"; italic: readonly MarkdownSpan[] } + | { type: "bold"; bold: readonly MarkdownSpan[] } | { type: "link"; - link: { label: MarkdownSpan[]; to: SmartDest; newTab: boolean }; + link: { + label: readonly MarkdownSpan[]; + to: SmartDest; + newTab: boolean; + }; } | { type: "slot"; slot: Slot }; export function parseMarkdown( markdownString: string, - slots: readonly Slot[], + slots: ReadonlySet, ): Result, string> { const linesRes = parseMarkdownLines(markdownString, slots); if (linesRes.type === "err") { @@ -63,7 +70,9 @@ export function parseMarkdown( return { type: "header", header: { spans: line.spans } }; } case "ulistItem": { - const items: MarkdownSpan[][] = [line.spans]; + const items: (readonly MarkdownSpan[])[] = [ + line.spans, + ]; while (true) { const peekLine = lines[0]; if ( @@ -85,12 +94,12 @@ export function parseMarkdown( elements.push(element); } - return { type: "ok", ok: { elements, slots: [...slots] } }; + return { type: "ok", ok: { elements, slots: new Set(slots) } }; } function parseMarkdownLines( markdownString: string, - slots: readonly Slot[], + slots: ReadonlySet, ): Result[], string> { if (markdownString.trim() === "--") { return { type: "ok", ok: [] }; @@ -142,7 +151,7 @@ function parseMarkdownLines( function parseMarkdownLine( line: string, - slots: readonly Slot[], + slots: ReadonlySet, ): Result, string> { interface ResolvedLine { deprefixedLine: string; @@ -174,7 +183,7 @@ function parseMarkdownLine( function parseMarkdownSpans( line: string, - slots: readonly Slot[], + slots: ReadonlySet, ): Result[], string> { if (line.startsWith("#")) { // subheaders may be supported in the future, @@ -259,7 +268,7 @@ class ParseMarkdownSpansManager { function readMarkdownSpans( chars: string[], - slots: readonly Slot[], + slots: ReadonlySet, manager: ParseMarkdownSpansManager, ): Result[], string> { const spans: MarkdownSpan[] = []; @@ -282,7 +291,7 @@ function readMarkdownSpans( function readMarkdownSpan( chars: string[], - slots: readonly Slot[], + slots: ReadonlySet, manager: ParseMarkdownSpansManager, ): Result | undefined, string> { const [firstChar, secondChar, thirdChar] = chars; @@ -307,14 +316,43 @@ function readMarkdownSpan( } } -function isInArray(value: T, array: readonly U[]): value is U { +function iterableIsArray(iterable: Iterable): iterable is readonly T[] { + return Array.isArray(iterable); +} + +function iterableIsSet(iterable: Iterable): iterable is ReadonlySet { + return iterable instanceof Set; +} + +function iterableContains( + iterable: Iterable, + value: T, +): value is U { // SAFETY: this is just an equality check, it is safe to pass in any value - return array.includes(value as U); + const target = value as U; + + // specializations + if (iterableIsArray(iterable)) { + return iterable.includes(target); + } + + if (iterableIsSet(iterable)) { + return iterable.has(target); + } + + // generic case + for (const x of iterable) { + if (x === target) { + return true; + } + } + + return false; } function readMarkdownSpanSlot( chars: string[], - slots: readonly Slot[], + slots: ReadonlySet, ): Result, string> { const openAngleRes = expectReadChar(chars, "<"); if (openAngleRes.type === "err") { @@ -332,7 +370,7 @@ function readMarkdownSpanSlot( } const slotName = slotNameRes.ok; - if (!isInArray(slotName, slots)) { + if (!iterableContains(slots, slotName)) { return { type: "err", err: `Unexpected slot name: ${slotName}` }; } @@ -341,7 +379,7 @@ function readMarkdownSpanSlot( function readMarkdownSpanLink( chars: string[], - slots: readonly Slot[], + slots: ReadonlySet, manager: ParseMarkdownSpansManager, ): Result, string> { const openSquareRes = expectReadChar(chars, "["); @@ -614,7 +652,7 @@ function validateInternalDest(dest: string): Result { function readMarkdownSpanItalic( chars: string[], - slots: readonly Slot[], + slots: ReadonlySet, manager: ParseMarkdownSpansManager, ): Result, string> { return manager.tryUseItalic(() => { @@ -659,7 +697,7 @@ function readMarkdownSpanItalic( function readMarkdownSpanBold( chars: string[], - slots: readonly Slot[], + slots: ReadonlySet, manager: ParseMarkdownSpansManager, ): Result, string> { return manager.tryUseBold(() => { @@ -704,7 +742,7 @@ function readMarkdownSpanBold( function readMarkdownSpanBoldItalic( chars: string[], - slots: readonly Slot[], + slots: ReadonlySet, manager: ParseMarkdownSpansManager, ): Result, string> { return manager.tryUseBold(() => diff --git a/apps/vdn-static/src/new-i18n-lib/setup.ts b/apps/vdn-static/src/vi18n-lib/setup.ts similarity index 100% rename from apps/vdn-static/src/new-i18n-lib/setup.ts rename to apps/vdn-static/src/vi18n-lib/setup.ts diff --git a/apps/vdn-static/src/new-i18n-lib/spec.md b/apps/vdn-static/src/vi18n-lib/spec.md similarity index 100% rename from apps/vdn-static/src/new-i18n-lib/spec.md rename to apps/vdn-static/src/vi18n-lib/spec.md