feat(i18n): first draft of vi18n lib

This commit is contained in:
Benjamin Singleton 2026-03-23 16:52:13 -05:00
parent f7a504d5a3
commit 4295a83ee1
20 changed files with 194 additions and 132 deletions

View file

@ -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();

View file

@ -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,
);

View file

@ -1,12 +1,12 @@
<script setup lang="ts" generic="Slot extends string">
import { type DeepReadonly, type VNode } from "vue";
import SmartLink from "../atoms/SmartLink.vue";
import type { MarkdownSpan } from "@/new-i18n-lib/markdown";
import type { MarkdownSpan } from "@/vi18n-lib/markdown";
defineProps<{
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
elements: DeepReadonly<MarkdownSpan<Slot>[]>;
slots: DeepReadonly<Slot[]>;
slots: DeepReadonly<ReadonlySet<Slot>>;
}>();
const vueSlots = defineSlots<{ [K in Slot]: () => VNode[] }>();

View file

@ -1,31 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import type { RichText, RichTextPart } from "../../i18n/rich"; // relative import for vue sfc compiler
const props = defineProps<{ content: RichText | RichTextPart[] }>();
const parts = computed<RichTextPart[]>(() => {
const content = props.content;
if (Array.isArray(content)) {
return content;
}
return content.parts;
});
console.log(parts.value);
</script>
<template>
<template v-for="(part, index) in parts" :key="index">
<template v-if="typeof part === 'string'">
{{ part }}
</template>
<template v-else-if="part.type === 'bold'">
<b><RichText :content="part.bold" /></b>
</template>
<template v-else-if="part.type === 'italic'">
<i><RichText :content="part.italic" /></i>
</template>
</template>
</template>

View file

@ -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<

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Locale } from "@/new-i18n";
import type { Locale } from "@/i18n";
import type { Value } from "@/utils/types";
import type { DeepReadonly } from "vue";
import MarkdownDisplay from "../atoms/MarkdownDisplay.vue";

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/new-i18n";
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/i18n";
import { ref } from "vue";
import { vOnClickOutside } from "@vueuse/components";
import DropdownItem from "../atoms/DropdownItem.vue";

View file

@ -5,7 +5,7 @@ import {
type InferLocaleFromConfig,
type LocaleConfig,
type ConfigString,
} from "@/new-i18n-lib/config";
} from "@/vi18n-lib/config";
function plainString(): ConfigString<object> {
return string({ placeables: {} });
@ -29,24 +29,28 @@ const discordRuleConfig = {
overview: {
text: markdown({
placeables: {},
features: { bold: true, italic: true, link: true, slots: {} },
slots: {},
bold: true,
italic: true,
link: true,
}),
subtext: markdown({
placeables: {},
features: { bold: true, italic: true, link: true, slots: {} },
slots: {},
bold: true,
italic: true,
link: true,
}),
},
section: {
header: string({ placeables: { ruleNumber: { type: "number" } } }),
body: markdown({
placeables: {},
features: {
bold: true,
header: true,
italic: true,
link: true,
slots: {},
},
slots: {},
bold: true,
header: true,
italic: true,
link: true,
}),
},
};

View file

@ -1,8 +1,8 @@
import { type InferLocaleFromConfig } from "@/new-i18n-lib/config";
import { type InferLocaleFromConfig } from "@/vi18n-lib/config";
import {
bundleToUncompiledLocaleRecord,
loadFluentBundle,
} from "@/new-i18n-lib/setup";
} from "@/vi18n-lib/setup";
import { localeConfig } from "./config";
import type { Result } from "@/utils/types";
import { useLocalStorage } from "@vueuse/core";
@ -12,7 +12,7 @@ import enUsFtlSrc from "@/assets/locale/en_US.ftl";
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
import type { FluentBundle } from "@fluent/bundle";
import { compileLocale } from "@/new-i18n-lib/compile";
import { compileLocale } from "@/vi18n-lib/compile";
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
@ -68,6 +68,7 @@ interface SetupLocaleFallback {
}
function setupLocale(
localeId: LocaleId,
localeBundle: FluentBundle,
fallback: SetupLocaleFallback | undefined,
): Result<Locale, string> {
@ -112,7 +113,15 @@ function setupLocale(
messageIdChain: [],
});
console.error(localeRes.errors);
const compilationErrorCount = localeRes.errors.length;
if (compilationErrorCount === 0) {
console.log(`[vi18n] Set up locale \`${localeId}\` with no errors!`);
} else {
console.error(
`[vi18n] Set up locale \`${localeId}\` with ${compilationErrorCount} following errors:`,
);
console.error(localeRes.errors);
}
const locale = localeRes.locale;
return { type: "ok", ok: locale };
@ -135,7 +144,9 @@ function deepReadonly<T>(value: T): DeepReadonly<T> {
}
const DEFAULT_LOCALE_BUNDLE = unwrap(await loadLocale("en-US", enUsFtlSrc));
const DEFAULT_LOCALE = unwrap(setupLocale(DEFAULT_LOCALE_BUNDLE, undefined));
const DEFAULT_LOCALE = unwrap(
setupLocale(DEFAULT_LOCALE_ID, DEFAULT_LOCALE_BUNDLE, undefined),
);
const doItAllForLocale = async (
localeId: LocaleId,
@ -143,10 +154,11 @@ const doItAllForLocale = async (
): Promise<DeepReadonly<Locale>> =>
deepReadonly(
unwrap(
setupLocale(unwrap(await loadLocale(localeId, localeFtlSrc)), {
bundle: DEFAULT_LOCALE_BUNDLE,
locale: DEFAULT_LOCALE,
}),
setupLocale(
localeId,
unwrap(await loadLocale(localeId, localeFtlSrc)),
{ bundle: DEFAULT_LOCALE_BUNDLE, locale: DEFAULT_LOCALE },
),
),
);

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
import DiscordRuleSection from "@/components/molecules/DiscordRuleSection.vue";
import { useLocale } from "@/new-i18n";
import { useLocale } from "@/i18n";
import { computed } from "vue";
const locale = useLocale();

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
import { GREETINGS, type Greeting } from "@/new-i18n/greeting";
import { VILANTIC_ID_TO_FLAG } from "@/new-i18n/vilantic";
import { GREETINGS, type Greeting } from "@/i18n/greeting";
import { VILANTIC_ID_TO_FLAG } from "@/i18n/vilantic";
import { randomElement } from "@/utils/random";
import { computed } from "vue";
import flakkaImg from "@/assets/flakka.png";
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";
interface SectionConfig {
id: keyof Locale["home"]["sections"];

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useLocale } from "@/new-i18n";
import { useLocale } from "@/i18n";
const locale = useLocale();
</script>

View file

@ -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";

View file

@ -30,7 +30,9 @@ type GenericMarkdownMessageFn = (
placeableArgs?: GenericMessageFnPlaceableArgs,
) => Markdown;
type GenericMessageFnPlaceableArgs = Record<string, FluentVariable>;
type GenericMessageFnPlaceableArgs = Record<string, PlaceableValue>;
type PlaceableValue = string | number;
export interface CompileLocaleCtx<Config extends LocaleConfig> {
bundle: FluentBundle;
@ -42,7 +44,7 @@ export interface CompileLocaleCtx<Config extends LocaleConfig> {
export interface CompileLocaleRes<Locale> {
locale: Locale;
errors: string[];
errors: readonly string[];
}
export function compileLocale<Config extends LocaleConfig>(
@ -122,7 +124,7 @@ export function compileLocale<Config extends LocaleConfig>(
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<string, FluentVariable> = {}) => {
ok: (args: Record<string, PlaceableValue> = {}) => {
const stringRes = ((): Result<string, string> => {
const stringLiteralRes = parseMessageLiteral(
"string",
@ -361,7 +363,7 @@ function compileStringMessage(
interface CompileMarkdownMessageCtx {
bundle: FluentBundle;
messageIdChain: readonly [...string[], string];
configMarkdown: ConfigMarkdown<object, object>;
slots: ReadonlySet<string>;
allVariants: readonly PatternVariant[];
pattern: Pattern;
}
@ -369,13 +371,10 @@ interface CompileMarkdownMessageCtx {
function compileMarkdownMessage(
ctx: CompileMarkdownMessageCtx,
): Result<GenericMarkdownMessageFn, string> {
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<string, FluentVariable> = {}): Markdown => {
ok: (args: Record<string, PlaceableValue> = {}): 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<Markdown, string> => {
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<Slot extends string>(
messageIdChain: readonly [...string[], string],
slots: Slot[],
slots: ReadonlySet<Slot>,
): Markdown<Slot> {
return {
elements: [

View file

@ -18,7 +18,12 @@ export interface ConfigMarkdown<
> {
[configMessageTypeSymbol]: typeof configMarkdownSymbol;
placeables: Placeables;
features: ConfigMarkdownFeatures<Slots>;
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<Record<string, ConfigSlotInfo>>,
> {
bold?: boolean;
italic?: boolean;
header?: boolean;
link?: boolean;
ulist?: boolean;
slots: Slots;
}
export function string<
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
>(
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
): ConfigString<Placeables> {
return {
[configMessageTypeSymbol]: configStringSymbol,
placeables: opt.placeables,
};
return { ...opt, [configMessageTypeSymbol]: configStringSymbol };
}
export function markdown<
@ -61,11 +52,7 @@ export function markdown<
typeof configMessageTypeSymbol
>,
): ConfigMarkdown<Placeables, Slots> {
return {
[configMessageTypeSymbol]: configMarkdownSymbol,
placeables: opt.placeables,
features: opt.features,
};
return { ...opt, [configMessageTypeSymbol]: configMarkdownSymbol };
}
export type LocaleConfig = {

View file

@ -8,35 +8,42 @@ import type { RouteNamedMap } from "vue-router/auto-routes";
import { routes } from "vue-router/auto-routes";
export interface Markdown<Slot extends string = string> {
elements: MarkdownElement<Slot>[];
slots: Slot[];
elements: readonly MarkdownElement<Slot>[];
slots: ReadonlySet<Slot>;
}
export type MarkdownElement<Slot extends string = string> =
| { type: "paragraph"; paragraph: { spans: MarkdownSpan<Slot>[] } }
| { type: "header"; header: { spans: MarkdownSpan<Slot>[] } }
| { type: "ulist"; ulist: { items: MarkdownSpan<Slot>[][] } };
| { type: "paragraph"; paragraph: { spans: readonly MarkdownSpan<Slot>[] } }
| { type: "header"; header: { spans: readonly MarkdownSpan<Slot>[] } }
| {
type: "ulist";
ulist: { items: readonly (readonly MarkdownSpan<Slot>[])[] };
};
type MarkdownLine<Slot extends string = string> = {
type: "paragraph" | "header" | "ulistItem";
spans: MarkdownSpan<Slot>[];
spans: readonly MarkdownSpan<Slot>[];
};
export type MarkdownFeature = "header" | "ulist" | "italic" | "bold" | "link";
export type MarkdownSpan<Slot extends string = string> =
| { 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<Slot extends string>(
markdownString: string,
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
): Result<Markdown<Slot>, string> {
const linesRes = parseMarkdownLines(markdownString, slots);
if (linesRes.type === "err") {
@ -63,7 +70,9 @@ export function parseMarkdown<Slot extends string>(
return { type: "header", header: { spans: line.spans } };
}
case "ulistItem": {
const items: MarkdownSpan<Slot>[][] = [line.spans];
const items: (readonly MarkdownSpan<Slot>[])[] = [
line.spans,
];
while (true) {
const peekLine = lines[0];
if (
@ -85,12 +94,12 @@ export function parseMarkdown<Slot extends string>(
elements.push(element);
}
return { type: "ok", ok: { elements, slots: [...slots] } };
return { type: "ok", ok: { elements, slots: new Set(slots) } };
}
function parseMarkdownLines<Slot extends string>(
markdownString: string,
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
): Result<MarkdownLine<Slot>[], string> {
if (markdownString.trim() === "--") {
return { type: "ok", ok: [] };
@ -142,7 +151,7 @@ function parseMarkdownLines<Slot extends string>(
function parseMarkdownLine<Slot extends string>(
line: string,
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
): Result<MarkdownLine<Slot>, string> {
interface ResolvedLine {
deprefixedLine: string;
@ -174,7 +183,7 @@ function parseMarkdownLine<Slot extends string>(
function parseMarkdownSpans<Slot extends string>(
line: string,
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
): Result<MarkdownSpan<Slot>[], string> {
if (line.startsWith("#")) {
// subheaders may be supported in the future,
@ -259,7 +268,7 @@ class ParseMarkdownSpansManager {
function readMarkdownSpans<Slot extends string>(
chars: string[],
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>[], string> {
const spans: MarkdownSpan<Slot>[] = [];
@ -282,7 +291,7 @@ function readMarkdownSpans<Slot extends string>(
function readMarkdownSpan<Slot extends string>(
chars: string[],
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot> | undefined, string> {
const [firstChar, secondChar, thirdChar] = chars;
@ -307,14 +316,43 @@ function readMarkdownSpan<Slot extends string>(
}
}
function isInArray<T, U extends T>(value: T, array: readonly U[]): value is U {
function iterableIsArray<T>(iterable: Iterable<T>): iterable is readonly T[] {
return Array.isArray(iterable);
}
function iterableIsSet<T>(iterable: Iterable<T>): iterable is ReadonlySet<T> {
return iterable instanceof Set;
}
function iterableContains<T, U extends T>(
iterable: Iterable<U>,
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<Slot extends string>(
chars: string[],
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
): Result<MarkdownSpan<Slot>, string> {
const openAngleRes = expectReadChar(chars, "<");
if (openAngleRes.type === "err") {
@ -332,7 +370,7 @@ function readMarkdownSpanSlot<Slot extends string>(
}
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<Slot extends string>(
function readMarkdownSpanLink<Slot extends string>(
chars: string[],
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
const openSquareRes = expectReadChar(chars, "[");
@ -614,7 +652,7 @@ function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
function readMarkdownSpanItalic<Slot extends string>(
chars: string[],
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
return manager.tryUseItalic(() => {
@ -659,7 +697,7 @@ function readMarkdownSpanItalic<Slot extends string>(
function readMarkdownSpanBold<Slot extends string>(
chars: string[],
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
return manager.tryUseBold(() => {
@ -704,7 +742,7 @@ function readMarkdownSpanBold<Slot extends string>(
function readMarkdownSpanBoldItalic<Slot extends string>(
chars: string[],
slots: readonly Slot[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
return manager.tryUseBold(() =>