Auto Router, Discord Rules Page, & Vi18n lib (#52)
feat: Auto Router, Discord Rules Page, & Vi18n lib (#52)
This commit is contained in:
parent
262b8c578f
commit
8c59485898
51 changed files with 3479 additions and 782 deletions
636
apps/vdn-static/src/vi18n-lib/compile.ts
Normal file
636
apps/vdn-static/src/vi18n-lib/compile.ts
Normal file
|
|
@ -0,0 +1,636 @@
|
|||
import type { Result, Value } from "@/utils/types";
|
||||
import {
|
||||
computeAllVariants,
|
||||
selectionChainToString,
|
||||
type PatternVariant,
|
||||
type UncompiledLocale,
|
||||
} from "./setup";
|
||||
import type { FluentBundle, FluentVariable, Message } from "@fluent/bundle";
|
||||
import { parseMarkdown, type Markdown } from "./markdown";
|
||||
import {
|
||||
configMarkdownSymbol,
|
||||
configMessageTypeSymbol,
|
||||
configStringSymbol,
|
||||
type ConfigMarkdown,
|
||||
type ConfigString,
|
||||
type InferLocaleFromConfig,
|
||||
type LocaleConfig,
|
||||
} from "./config";
|
||||
import type { Pattern } from "@fluent/bundle/esm/ast";
|
||||
|
||||
type GenericLocale = { [id: string]: GenericLocale | GenericMessageFn };
|
||||
|
||||
type GenericMessageFn = GenericStringMessageFn | GenericMarkdownMessageFn;
|
||||
|
||||
type GenericStringMessageFn = (
|
||||
placeableArgs?: GenericMessageFnPlaceableArgs,
|
||||
) => string;
|
||||
|
||||
type GenericMarkdownMessageFn = (
|
||||
placeableArgs?: GenericMessageFnPlaceableArgs,
|
||||
) => Markdown;
|
||||
|
||||
type GenericMessageFnPlaceableArgs = Record<string, PlaceableValue>;
|
||||
|
||||
type PlaceableValue = string | number;
|
||||
|
||||
export interface CompileLocaleCtx<Config extends LocaleConfig> {
|
||||
bundle: FluentBundle;
|
||||
uncompiled: UncompiledLocale;
|
||||
config: Config;
|
||||
fallback: InferLocaleFromConfig<Config> | undefined;
|
||||
messageIdChain: readonly string[];
|
||||
}
|
||||
|
||||
export interface CompileLocaleRes<Locale> {
|
||||
locale: Locale;
|
||||
errors: readonly string[];
|
||||
}
|
||||
|
||||
export function compileLocale<Config extends LocaleConfig>(
|
||||
ctx: CompileLocaleCtx<Config>,
|
||||
): CompileLocaleRes<InferLocaleFromConfig<Config>> {
|
||||
const {
|
||||
bundle,
|
||||
uncompiled,
|
||||
config,
|
||||
fallback,
|
||||
messageIdChain: localeMessageIdChain = [],
|
||||
} = ctx;
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
const uncompiledKeys = new Set(Object.keys(uncompiled ?? {}));
|
||||
const configKeys = new Set(Object.keys(config));
|
||||
const excessKeys = uncompiledKeys.difference(configKeys);
|
||||
if (excessKeys.size > 0) {
|
||||
errors.push(`Excess keys in record: ${[...excessKeys].join(", ")}`);
|
||||
}
|
||||
|
||||
const locale: GenericLocale = {};
|
||||
for (const [messageId, configValue] of Object.entries(config)) {
|
||||
const uncompiledValue = uncompiled?.[messageId];
|
||||
const fallbackValue = fallback?.[messageId];
|
||||
const valueMessageIdChain = [
|
||||
...localeMessageIdChain,
|
||||
messageId,
|
||||
] as const;
|
||||
|
||||
const compiledValue = ((): Value<GenericLocale> => {
|
||||
if (configMessageTypeSymbol in configValue) {
|
||||
const compiledMessage = (() => {
|
||||
if (uncompiledValue?.type !== "message") {
|
||||
errors.push(
|
||||
`Expected message for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uncompiledMessage = uncompiledValue.message;
|
||||
const compiledMessageRes = compileMessage({
|
||||
bundle,
|
||||
messageIdChain: valueMessageIdChain,
|
||||
configValue,
|
||||
uncompiledMessage,
|
||||
});
|
||||
|
||||
if (compiledMessageRes.type === "err") {
|
||||
errors.push(compiledMessageRes.err);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const compiledMessage = compiledMessageRes.ok;
|
||||
return compiledMessage;
|
||||
})();
|
||||
|
||||
if (compiledMessage !== undefined) {
|
||||
return compiledMessage;
|
||||
}
|
||||
|
||||
if (
|
||||
fallbackValue !== undefined
|
||||
&& typeof fallbackValue === "function"
|
||||
) {
|
||||
return fallbackValue as GenericMessageFn;
|
||||
}
|
||||
|
||||
switch (configValue[configMessageTypeSymbol]) {
|
||||
case configStringSymbol: {
|
||||
return () =>
|
||||
createMissingStringFallback(valueMessageIdChain);
|
||||
}
|
||||
case configMarkdownSymbol: {
|
||||
return () =>
|
||||
createMissingMarkdownFallback(
|
||||
valueMessageIdChain,
|
||||
new Set(Object.keys(configValue.slots)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const uncompiledSubrecord = (() => {
|
||||
if (uncompiledValue?.type !== "subrecord") {
|
||||
errors.push(
|
||||
`Expected subrecord for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return uncompiledValue.subrecord;
|
||||
})();
|
||||
|
||||
return compileSublocale({
|
||||
subconfig: configValue,
|
||||
uncompiledSublocale: uncompiledSubrecord,
|
||||
fallbackSublocale:
|
||||
typeof fallbackValue === "function" ? undefined : (
|
||||
fallbackValue
|
||||
),
|
||||
errors,
|
||||
bundle,
|
||||
messageIdChain: valueMessageIdChain,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
locale[messageId] = compiledValue;
|
||||
}
|
||||
|
||||
// SAFETY: validated above that all keys exist and are the correct type
|
||||
return { locale: locale as InferLocaleFromConfig<Config>, errors };
|
||||
}
|
||||
|
||||
function fmtMessageIdChain(
|
||||
messageIdChain: readonly [...string[], string],
|
||||
): string {
|
||||
return messageIdChain.join("-");
|
||||
}
|
||||
|
||||
interface CompileMessageCtx {
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
configValue: ConfigString<object> | ConfigMarkdown<object, object>;
|
||||
uncompiledMessage: Message;
|
||||
}
|
||||
|
||||
function compileMessage(
|
||||
ctx: CompileMessageCtx,
|
||||
): Result<GenericMessageFn, string> {
|
||||
const { bundle, messageIdChain, configValue, uncompiledMessage } = ctx;
|
||||
|
||||
const pattern = uncompiledMessage.value;
|
||||
if (pattern === null) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Pattern is null for message with ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// validate placeables
|
||||
if (typeof pattern !== "string") {
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "select": {
|
||||
const { selector } = element;
|
||||
if (selector.type !== "var") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected selector to be a var expression for ID: ${fmtMessageIdChain(messageIdChain)}; Found: ${selector.type}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
selector.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${selector.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "var": {
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
element.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${element.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "term":
|
||||
case "mesg":
|
||||
case "func":
|
||||
case "str":
|
||||
case "num": {
|
||||
break; // ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allVariantsRes = computeAllVariants(pattern);
|
||||
if (allVariantsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to compute variants for ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${allVariantsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allVariants = allVariantsRes.ok;
|
||||
|
||||
switch (configValue[configMessageTypeSymbol]) {
|
||||
case configStringSymbol: {
|
||||
return compileStringMessage({
|
||||
bundle,
|
||||
messageIdChain,
|
||||
allVariants,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
case configMarkdownSymbol: {
|
||||
return compileMarkdownMessage({
|
||||
bundle,
|
||||
slots: new Set(Object.keys(configValue.slots)),
|
||||
messageIdChain,
|
||||
allVariants,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CompileStringMessageCtx {
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
allVariants: readonly PatternVariant[];
|
||||
pattern: Pattern;
|
||||
}
|
||||
|
||||
function compileStringMessage(
|
||||
ctx: CompileStringMessageCtx,
|
||||
): Result<GenericStringMessageFn, string> {
|
||||
const { bundle, messageIdChain, allVariants, pattern } = ctx;
|
||||
|
||||
// typecheck string
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const stringLiteralRes = parseMessageLiteral("string", variant.string);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
const stringRes = parseString(stringLiteral);
|
||||
if (stringRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, PlaceableValue> = {}) => {
|
||||
const stringRes = ((): Result<string, string> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface CompileMarkdownMessageCtx {
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
slots: ReadonlySet<string>;
|
||||
allVariants: readonly PatternVariant[];
|
||||
pattern: Pattern;
|
||||
}
|
||||
|
||||
function compileMarkdownMessage(
|
||||
ctx: CompileMarkdownMessageCtx,
|
||||
): Result<GenericMarkdownMessageFn, string> {
|
||||
const { bundle, messageIdChain, slots, allVariants, pattern } = ctx;
|
||||
|
||||
// typecheck markdown
|
||||
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const markdownLiteralRes = parseMessageLiteral("md", variant.string);
|
||||
|
||||
if (markdownLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const markdownRes = parseMarkdown(markdownLiteral, slots);
|
||||
|
||||
if (markdownRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
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, escapedArgs),
|
||||
);
|
||||
|
||||
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, slots);
|
||||
|
||||
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, slots);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMissingStringFallback(
|
||||
messageIdChain: readonly [...string[], string],
|
||||
): string {
|
||||
return `[#${fmtMessageIdChain(messageIdChain)}#]`;
|
||||
}
|
||||
|
||||
function createMissingMarkdownFallback<Slot extends string>(
|
||||
messageIdChain: readonly [...string[], string],
|
||||
slots: ReadonlySet<Slot>,
|
||||
): Markdown<Slot> {
|
||||
return {
|
||||
elements: [
|
||||
{
|
||||
type: "paragraph",
|
||||
paragraph: {
|
||||
spans: [
|
||||
{
|
||||
type: "plain",
|
||||
plain: createMissingStringFallback(messageIdChain),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
interface CompileSublocaleCtx<Subconfig extends LocaleConfig> {
|
||||
subconfig: Subconfig;
|
||||
uncompiledSublocale: UncompiledLocale | undefined;
|
||||
fallbackSublocale: InferLocaleFromConfig<Subconfig> | undefined;
|
||||
errors: string[];
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
}
|
||||
|
||||
function compileSublocale<Subconfig extends LocaleConfig>(
|
||||
ctx: CompileSublocaleCtx<Subconfig>,
|
||||
): InferLocaleFromConfig<Subconfig> {
|
||||
const {
|
||||
subconfig: configValue,
|
||||
uncompiledSublocale: recordValue,
|
||||
fallbackSublocale: fallbackValue,
|
||||
errors,
|
||||
bundle,
|
||||
messageIdChain,
|
||||
} = ctx;
|
||||
|
||||
const subrecord = recordValue;
|
||||
const compiledSubrecordRes = compileLocale({
|
||||
bundle,
|
||||
uncompiled: subrecord ?? {},
|
||||
config: configValue,
|
||||
fallback: fallbackValue,
|
||||
messageIdChain,
|
||||
});
|
||||
|
||||
errors.push(
|
||||
...compiledSubrecordRes.errors.map(
|
||||
(err) =>
|
||||
`Error when compiling subrecord with ID: \`${fmtMessageIdChain(messageIdChain)}\`:\n${err}`,
|
||||
),
|
||||
);
|
||||
|
||||
return compiledSubrecordRes.locale;
|
||||
}
|
||||
|
||||
function parseMessageLiteral(
|
||||
type: "string" | "md",
|
||||
message: string,
|
||||
): Result<string, string> {
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
const maybeStartIndexes: number[] = [];
|
||||
const firstQuoteIndex = trimmedMessage.indexOf('"');
|
||||
if (firstQuoteIndex !== -1) {
|
||||
maybeStartIndexes.push(firstQuoteIndex);
|
||||
}
|
||||
|
||||
const firstDashIndex = trimmedMessage.indexOf("-");
|
||||
if (firstDashIndex !== -1) {
|
||||
maybeStartIndexes.push(firstDashIndex);
|
||||
}
|
||||
|
||||
const stringStartIndex = Math.min(...maybeStartIndexes);
|
||||
|
||||
const actualPrefix = trimmedMessage.substring(0, stringStartIndex).trim();
|
||||
const expectedPrefix = (() => {
|
||||
switch (type) {
|
||||
case "string": {
|
||||
return "";
|
||||
}
|
||||
case "md": {
|
||||
return "md";
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (actualPrefix !== expectedPrefix) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected prefix "${expectedPrefix}" for message with type \`${type}\`; Found: "${actualPrefix}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: trimmedMessage.substring(actualPrefix.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseString(message: string): Result<string, string> {
|
||||
const AFFIX = '"';
|
||||
if (!message.startsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to start with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
if (!message.endsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to end with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
const deprefixed = message.substring(AFFIX.length);
|
||||
const dequoted = deprefixed.substring(0, deprefixed.length - AFFIX.length);
|
||||
return { type: "ok", ok: dequoted };
|
||||
}
|
||||
106
apps/vdn-static/src/vi18n-lib/config.ts
Normal file
106
apps/vdn-static/src/vi18n-lib/config.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { type Markdown } from "./markdown";
|
||||
|
||||
export const configMessageTypeSymbol: unique symbol =
|
||||
Symbol("configMessageType");
|
||||
|
||||
export const configStringSymbol: unique symbol = Symbol("configString");
|
||||
export interface ConfigString<
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
> {
|
||||
[configMessageTypeSymbol]: typeof configStringSymbol;
|
||||
placeables: Placeables;
|
||||
}
|
||||
|
||||
export const configMarkdownSymbol: unique symbol = Symbol("configMarkdown");
|
||||
export interface ConfigMarkdown<
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||
> {
|
||||
[configMessageTypeSymbol]: typeof configMarkdownSymbol;
|
||||
placeables: Placeables;
|
||||
slots: Slots;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
header?: boolean;
|
||||
link?: boolean;
|
||||
ulist?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigPlaceableInfo<
|
||||
Type extends "string" | "number" = "string" | "number",
|
||||
> {
|
||||
type: Type;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface ConfigSlotInfo {}
|
||||
|
||||
export function string<
|
||||
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
>(
|
||||
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
|
||||
): ConfigString<Placeables> {
|
||||
return { ...opt, [configMessageTypeSymbol]: configStringSymbol };
|
||||
}
|
||||
|
||||
export function markdown<
|
||||
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
const Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||
>(
|
||||
opt: Omit<
|
||||
ConfigMarkdown<Placeables, Slots>,
|
||||
typeof configMessageTypeSymbol
|
||||
>,
|
||||
): ConfigMarkdown<Placeables, Slots> {
|
||||
return { ...opt, [configMessageTypeSymbol]: configMarkdownSymbol };
|
||||
}
|
||||
|
||||
export type LocaleConfig = {
|
||||
[id: string]:
|
||||
| ConfigString<object>
|
||||
| ConfigMarkdown<object, object>
|
||||
| LocaleConfig;
|
||||
};
|
||||
|
||||
type MessageCtx<
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
> = {
|
||||
[K in keyof Placeables]: ResolvedPlaceableType<
|
||||
PlaceableType<Exclude<Placeables[K], undefined>>
|
||||
>;
|
||||
};
|
||||
|
||||
type PlaceableType<Var extends ConfigPlaceableInfo> =
|
||||
Var extends ConfigPlaceableInfo<infer Type> ? Type : never;
|
||||
|
||||
type ResolvedPlaceableType<Type extends "string" | "number"> =
|
||||
Type extends "string" ? string
|
||||
: Type extends "number" ? number
|
||||
: never;
|
||||
|
||||
export type InferLocaleFromConfig<Config extends LocaleConfig> = {
|
||||
[K in keyof Config]: Config[K] extends LocaleConfig ?
|
||||
InferLocaleFromConfig<Config[K]>
|
||||
: Config[K] extends ConfigString<infer Placeables> ?
|
||||
object extends MessageCtx<Placeables> ?
|
||||
() => string
|
||||
: (ctx: MessageCtx<Placeables>) => string
|
||||
: Config[K] extends ConfigMarkdown<infer Placeables, object> ?
|
||||
object extends MessageCtx<Placeables> ?
|
||||
() => Markdown
|
||||
: (ctx: MessageCtx<Placeables>) => Markdown
|
||||
: never;
|
||||
};
|
||||
|
||||
export function record<const Key extends PropertyKey, T>(
|
||||
keys: readonly Key[],
|
||||
initializer: () => T,
|
||||
): Record<Key, T> {
|
||||
const obj: Partial<Record<Key, T>> = {};
|
||||
for (const key of keys) {
|
||||
obj[key] = initializer();
|
||||
}
|
||||
|
||||
// SAFETY: we set all properties from keys array above
|
||||
return obj as Record<Key, T>;
|
||||
}
|
||||
944
apps/vdn-static/src/vi18n-lib/markdown.ts
Normal file
944
apps/vdn-static/src/vi18n-lib/markdown.ts
Normal file
|
|
@ -0,0 +1,944 @@
|
|||
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 interface Markdown<Slot extends string = string> {
|
||||
elements: readonly MarkdownElement<Slot>[];
|
||||
slots: ReadonlySet<Slot>;
|
||||
}
|
||||
|
||||
export type MarkdownElement<Slot extends string = string> =
|
||||
| { 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: 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: readonly MarkdownSpan[] }
|
||||
| { type: "bold"; bold: readonly MarkdownSpan[] }
|
||||
| {
|
||||
type: "link";
|
||||
link: {
|
||||
label: readonly MarkdownSpan[];
|
||||
to: SmartDest;
|
||||
newTab: boolean;
|
||||
};
|
||||
}
|
||||
| { type: "slot"; slot: Slot };
|
||||
|
||||
export function parseMarkdown<Slot extends string>(
|
||||
markdownString: string,
|
||||
slots: ReadonlySet<Slot>,
|
||||
): Result<Markdown<Slot>, string> {
|
||||
const linesRes = parseMarkdownLines(markdownString, slots);
|
||||
if (linesRes.type === "err") {
|
||||
return linesRes;
|
||||
}
|
||||
|
||||
const lines = linesRes.ok;
|
||||
const elements: MarkdownElement<Slot>[] = [];
|
||||
while (true) {
|
||||
const line = lines.shift();
|
||||
if (line === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const element = ((): MarkdownElement<Slot> => {
|
||||
switch (line.type) {
|
||||
case "paragraph": {
|
||||
return {
|
||||
type: "paragraph",
|
||||
paragraph: { spans: line.spans },
|
||||
};
|
||||
}
|
||||
case "header": {
|
||||
return { type: "header", header: { spans: line.spans } };
|
||||
}
|
||||
case "ulistItem": {
|
||||
const items: (readonly MarkdownSpan<Slot>[])[] = [
|
||||
line.spans,
|
||||
];
|
||||
while (true) {
|
||||
const peekLine = lines[0];
|
||||
if (
|
||||
peekLine === undefined
|
||||
|| peekLine.type !== "ulistItem"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
items.push(peekLine.spans);
|
||||
lines.shift();
|
||||
}
|
||||
|
||||
return { type: "ulist", ulist: { items } };
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
elements.push(element);
|
||||
}
|
||||
|
||||
return { type: "ok", ok: { elements, slots: new Set(slots) } };
|
||||
}
|
||||
|
||||
function parseMarkdownLines<Slot extends string>(
|
||||
markdownString: string,
|
||||
slots: ReadonlySet<Slot>,
|
||||
): Result<MarkdownLine<Slot>[], string> {
|
||||
if (markdownString.trim() === "--") {
|
||||
return { type: "ok", ok: [] };
|
||||
}
|
||||
|
||||
const lines = markdownString.split("\n");
|
||||
const dequotedLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
const MARKDOWN_LINE_AFFIX = '"';
|
||||
if (!line.startsWith(MARKDOWN_LINE_AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Line ${String(dequotedLines.length + 1)} of markdown must start with ${MARKDOWN_LINE_AFFIX}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!line.endsWith(MARKDOWN_LINE_AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Line ${String(dequotedLines.length + 1)} of markdown must end with ${MARKDOWN_LINE_AFFIX}`,
|
||||
};
|
||||
}
|
||||
|
||||
const deprefixed = line.substring(MARKDOWN_LINE_AFFIX.length);
|
||||
const dequoted = deprefixed.substring(
|
||||
0,
|
||||
deprefixed.length - MARKDOWN_LINE_AFFIX.length,
|
||||
);
|
||||
|
||||
dequotedLines.push(dequoted);
|
||||
}
|
||||
|
||||
const markdownLines: MarkdownLine<Slot>[] = [];
|
||||
for (const line of dequotedLines) {
|
||||
const markdownLineRes = parseMarkdownLine(line, slots);
|
||||
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<Slot extends string>(
|
||||
line: string,
|
||||
slots: ReadonlySet<Slot>,
|
||||
): Result<MarkdownLine<Slot>, string> {
|
||||
interface ResolvedLine {
|
||||
deprefixedLine: string;
|
||||
type: MarkdownLine["type"];
|
||||
}
|
||||
|
||||
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 spansRes = parseMarkdownSpans(deprefixedLine, slots);
|
||||
|
||||
if (spansRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `While parsing ${type} spans:\n${spansRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const spans = spansRes.ok;
|
||||
return { type: "ok", ok: { type, spans } };
|
||||
}
|
||||
|
||||
function parseMarkdownSpans<Slot extends string>(
|
||||
line: string,
|
||||
slots: ReadonlySet<Slot>,
|
||||
): Result<MarkdownSpan<Slot>[], 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 chars = line.split("");
|
||||
const spansRes = readMarkdownSpans(
|
||||
chars,
|
||||
slots,
|
||||
ParseMarkdownSpansManager.new(),
|
||||
);
|
||||
|
||||
if (spansRes.type === "err") {
|
||||
return spansRes;
|
||||
}
|
||||
|
||||
const spans = spansRes.ok;
|
||||
|
||||
return { type: "ok", ok: spans };
|
||||
}
|
||||
|
||||
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<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>(
|
||||
chars: string[],
|
||||
slots: ReadonlySet<Slot>,
|
||||
manager: ParseMarkdownSpansManager,
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
const spans: MarkdownSpan<Slot>[] = [];
|
||||
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);
|
||||
}
|
||||
|
||||
return { type: "ok", ok: spans };
|
||||
}
|
||||
|
||||
function readMarkdownSpan<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: ReadonlySet<Slot>,
|
||||
manager: ParseMarkdownSpansManager,
|
||||
): Result<MarkdownSpan<Slot> | undefined, string> {
|
||||
const [firstChar, secondChar, thirdChar] = chars;
|
||||
if (firstChar === undefined) {
|
||||
return { type: "ok", ok: undefined };
|
||||
} else if (firstChar === "<") {
|
||||
return readMarkdownSpanSlot(chars, slots);
|
||||
} else if (firstChar === "[") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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: ReadonlySet<Slot>,
|
||||
): Result<MarkdownSpan<Slot>, 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;
|
||||
if (!iterableContains(slots, slotName)) {
|
||||
return { type: "err", err: `Unexpected slot name: ${slotName}` };
|
||||
}
|
||||
|
||||
return { type: "ok", ok: { type: "slot", slot: slotName } };
|
||||
}
|
||||
|
||||
function readMarkdownSpanLink<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: ReadonlySet<Slot>,
|
||||
manager: ParseMarkdownSpansManager,
|
||||
): Result<MarkdownSpan<Slot>, string> {
|
||||
const openSquareRes = expectReadChar(chars, "[");
|
||||
if (openSquareRes.type === "err") {
|
||||
return openSquareRes;
|
||||
}
|
||||
|
||||
const labelElementsRes = readMarkdownSpans(chars, slots, manager);
|
||||
if (labelElementsRes.type === "err") {
|
||||
return labelElementsRes;
|
||||
}
|
||||
|
||||
const closeSquareRes = expectReadChar(chars, "]");
|
||||
if (closeSquareRes.type === "err") {
|
||||
return closeSquareRes;
|
||||
}
|
||||
|
||||
const labelElements = labelElementsRes.ok;
|
||||
const openParenRes = expectReadChar(chars, "(");
|
||||
if (openParenRes.type === "err") {
|
||||
return openParenRes;
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
dest: SmartDest;
|
||||
newTab: boolean;
|
||||
}
|
||||
|
||||
const linkPropsRes = ((): Result<LinkProps, string> => {
|
||||
if (peekStringEq(chars, "external")) {
|
||||
const externalRes = expectReadString(chars, "external");
|
||||
if (externalRes.type === "err") {
|
||||
return externalRes;
|
||||
}
|
||||
|
||||
const dotRes = expectReadChar(chars, ".");
|
||||
if (dotRes.type === "err") {
|
||||
return dotRes;
|
||||
}
|
||||
|
||||
const tabStringRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link tab",
|
||||
closingChar: ":",
|
||||
});
|
||||
|
||||
if (tabStringRes.type === "err") {
|
||||
return tabStringRes;
|
||||
}
|
||||
|
||||
const tabString = tabStringRes.ok;
|
||||
const newTabRes = ((): Result<boolean, string> => {
|
||||
switch (tabString) {
|
||||
case "new": {
|
||||
return { type: "ok", ok: true };
|
||||
}
|
||||
case "replace": {
|
||||
return { type: "ok", ok: false };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected \`replace\` or \`new\`; Found: ${tabString}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (newTabRes.type === "err") {
|
||||
return newTabRes;
|
||||
}
|
||||
|
||||
const newTab = newTabRes.ok;
|
||||
|
||||
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: {
|
||||
dest: { type: "external", external: externalDest },
|
||||
newTab,
|
||||
},
|
||||
};
|
||||
} else if (peekStringEq(chars, "internal")) {
|
||||
const internalRes = expectReadString(chars, "internal");
|
||||
if (internalRes.type === "err") {
|
||||
return internalRes;
|
||||
}
|
||||
|
||||
const dotRes = expectReadChar(chars, ".");
|
||||
if (dotRes.type === "err") {
|
||||
return dotRes;
|
||||
}
|
||||
|
||||
const tabStringRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link tab",
|
||||
closingChar: ":",
|
||||
});
|
||||
|
||||
if (tabStringRes.type === "err") {
|
||||
return tabStringRes;
|
||||
}
|
||||
|
||||
const tabString = tabStringRes.ok;
|
||||
const newTabRes = ((): Result<boolean, string> => {
|
||||
switch (tabString) {
|
||||
case "new": {
|
||||
return { type: "ok", ok: true };
|
||||
}
|
||||
case "replace": {
|
||||
return { type: "ok", ok: false };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected \`replace\` or \`new\`; Found: ${tabString}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (newTabRes.type === "err") {
|
||||
return newTabRes;
|
||||
}
|
||||
|
||||
const newTab = newTabRes.ok;
|
||||
|
||||
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: {
|
||||
dest: { type: "internal", internal: internalDest },
|
||||
newTab,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected external or internal link prefix; Found: "${chars.slice(0, 10).join("")}..."`,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
if (linkPropsRes.type === "err") {
|
||||
return linkPropsRes;
|
||||
}
|
||||
|
||||
const { dest, newTab } = linkPropsRes.ok;
|
||||
|
||||
const resolvedLabel: MarkdownSpan[] =
|
||||
labelElements.length === 0 ?
|
||||
[
|
||||
{
|
||||
type: "plain",
|
||||
plain:
|
||||
dest.type === "external" ?
|
||||
dest.external
|
||||
: `${window.location.protocol}${window.location.hostname}${dest.internal.route ?? window.location.pathname}${dest.internal.id === undefined ? "" : `#${dest.internal.id}`}`,
|
||||
},
|
||||
]
|
||||
: labelElements;
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: { type: "link", link: { label: resolvedLabel, to: dest, newTab } },
|
||||
};
|
||||
}
|
||||
|
||||
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 readMarkdownSpanItalic<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: ReadonlySet<Slot>,
|
||||
manager: ParseMarkdownSpansManager,
|
||||
): Result<MarkdownSpan<Slot>, string> {
|
||||
return manager.tryUseItalic(() => {
|
||||
const singleStarRes = expectReadString(chars, "*");
|
||||
if (singleStarRes.type === "err") {
|
||||
return singleStarRes;
|
||||
}
|
||||
|
||||
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] = 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: ReadonlySet<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;
|
||||
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<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: ReadonlySet<Slot>,
|
||||
manager: ParseMarkdownSpansManager,
|
||||
): Result<MarkdownSpan<Slot>, string> {
|
||||
return manager.tryUseBold(() =>
|
||||
manager.tryUseItalic(() => {
|
||||
const tripleStarRes = expectReadString(chars, "***");
|
||||
if (tripleStarRes.type === "err") {
|
||||
return tripleStarRes;
|
||||
}
|
||||
|
||||
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, 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<Slot extends string>(
|
||||
chars: string[],
|
||||
): Result<MarkdownSpan<Slot> | undefined, 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 === "<"
|
||||
|| peek === ">"
|
||||
|| peek === "["
|
||||
|| peek === "]"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
plain += peek;
|
||||
chars.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: plain.length === 0 ? undefined : { 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 };
|
||||
}
|
||||
|
||||
export function isEmptyMarkdown(markdown: Markdown): boolean {
|
||||
// const [firstLine] = markdown.lines;
|
||||
// if (firstLine === undefined) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// const [firstElement] = firstLine.elements;
|
||||
// if (firstElement === undefined) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// if (firstElement.type === "plain" && firstElement.plain.length === 0) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// return false;
|
||||
|
||||
return markdown.elements.length === 0;
|
||||
}
|
||||
201
apps/vdn-static/src/vi18n-lib/setup.ts
Normal file
201
apps/vdn-static/src/vi18n-lib/setup.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import type { Result } from "@/utils/types";
|
||||
import { FluentBundle, FluentResource } from "@fluent/bundle";
|
||||
import { unsafeAsync } from "@/utils/unsafe";
|
||||
import type { Literal, Message, Pattern } from "@fluent/bundle/esm/ast";
|
||||
|
||||
export async function loadFluentBundle(
|
||||
localeId: string,
|
||||
src: string,
|
||||
): Promise<Result<FluentBundle, string>> {
|
||||
const ftlFileResponseRes = await unsafeAsync(() => fetch(src));
|
||||
if (ftlFileResponseRes.type === "err") {
|
||||
return { type: "err", err: `Failed to fetch locale from src: ${src}` };
|
||||
}
|
||||
|
||||
const ftlFileResponse = ftlFileResponseRes.ok;
|
||||
const ftlFileTextRes = await unsafeAsync(() => ftlFileResponse.text());
|
||||
if (ftlFileTextRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to fetch text content of FTL file from src: ${src}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ftlFileText = ftlFileTextRes.ok;
|
||||
const resource = new FluentResource(ftlFileText);
|
||||
|
||||
const bundle = new FluentBundle(localeId);
|
||||
const errors = bundle.addResource(resource);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to add Fluent resource to bundle:\n${errors.join("\n")}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "ok", ok: bundle };
|
||||
}
|
||||
|
||||
export type UncompiledLocale = {
|
||||
[id: string]:
|
||||
| { type: "message"; message: Message }
|
||||
| { type: "subrecord"; subrecord: UncompiledLocale };
|
||||
};
|
||||
|
||||
export function bundleToUncompiledLocaleRecord(
|
||||
bundle: FluentBundle,
|
||||
): Result<UncompiledLocale, string> {
|
||||
const record: UncompiledLocale = {};
|
||||
for (const [id, message] of bundle._messages) {
|
||||
const idChain = id.split("-");
|
||||
let subrecord = record;
|
||||
|
||||
while (true) {
|
||||
const subId = idChain.shift();
|
||||
if (subId === undefined) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Reached end of message ID chain before terminating for message ID: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (idChain.length === 0) {
|
||||
subrecord[subId] = { type: "message", message };
|
||||
break;
|
||||
} else {
|
||||
const maybeSubrecord = (subrecord[subId] ??= {
|
||||
type: "subrecord",
|
||||
subrecord: {},
|
||||
});
|
||||
|
||||
if (maybeSubrecord.type === "subrecord") {
|
||||
subrecord = maybeSubrecord.subrecord;
|
||||
} else {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found message when expected subrecord for message ID: ${id} @ subId: ${subId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "ok", ok: record };
|
||||
}
|
||||
|
||||
export type SelectionChain = (Literal | SelectionChain)[];
|
||||
|
||||
export function selectionChainToString(chain: SelectionChain): string {
|
||||
return chain
|
||||
.map((part) => {
|
||||
if ("type" in part) {
|
||||
switch (part.type) {
|
||||
case "str": {
|
||||
return `[${part.value}]`;
|
||||
}
|
||||
case "num": {
|
||||
return `[${String(part.value)};${String(part.precision)}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `(${selectionChainToString(part)})`;
|
||||
})
|
||||
.join("+");
|
||||
}
|
||||
|
||||
export interface PatternVariant {
|
||||
selectionChain: SelectionChain;
|
||||
string: string;
|
||||
}
|
||||
|
||||
export function computeAllVariants(
|
||||
pattern: Pattern,
|
||||
): Result<PatternVariant[], string> {
|
||||
if (typeof pattern === "string") {
|
||||
return { type: "ok", ok: [{ selectionChain: [], string: pattern }] };
|
||||
}
|
||||
|
||||
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
variants = variants.map((variant) => ({
|
||||
selectionChain: variant.selectionChain,
|
||||
string: variant.string + element,
|
||||
}));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "select": {
|
||||
const selectVariants: PatternVariant[] = [];
|
||||
for (const selectVariant of element.variants) {
|
||||
const variantComputedRes = computeAllVariants(
|
||||
selectVariant.value,
|
||||
);
|
||||
|
||||
if (variantComputedRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to compute select variants:\n${variantComputedRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const variantComputed = variantComputedRes.ok;
|
||||
selectVariants.push(
|
||||
...variantComputed.map(
|
||||
(v): PatternVariant => ({
|
||||
selectionChain: [
|
||||
selectVariant.key,
|
||||
...v.selectionChain,
|
||||
],
|
||||
string: v.string,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
variants = variants.flatMap((variant) =>
|
||||
selectVariants.map(
|
||||
(selectVariant): PatternVariant => ({
|
||||
selectionChain: [
|
||||
...variant.selectionChain,
|
||||
selectVariant.selectionChain,
|
||||
],
|
||||
string: variant.string + selectVariant.string,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case "var": {
|
||||
// used as a stand-in for runtime-provided values
|
||||
const DUMMY_STRING = "$$$";
|
||||
|
||||
variants = variants.map((variant) => ({
|
||||
selectionChain: variant.selectionChain,
|
||||
string: variant.string + DUMMY_STRING,
|
||||
}));
|
||||
|
||||
break;
|
||||
}
|
||||
case "str": {
|
||||
variants = variants.map((variant) => ({
|
||||
selectionChain: variant.selectionChain,
|
||||
string: variant.string + element.value,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Unhandled PatternElement type: ${element.type}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "ok", ok: variants };
|
||||
}
|
||||
79
apps/vdn-static/src/vi18n-lib/spec.md
Normal file
79
apps/vdn-static/src/vi18n-lib/spec.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Viossa I18n Message Spec
|
||||
|
||||
## Types & Literals
|
||||
There are two types of messages, `string` & `markdown`. Each type is specified by a prefix:
|
||||
- `string` literal (no prefix): `"Hello world!"`
|
||||
- `markdown` literal (`md` prefix): `md "Hello world!"`
|
||||
|
||||
Message literals are made up of lines. Each line is surrounded by quotes.
|
||||
|
||||
## String Literals
|
||||
String literals take exactly one line. They have no special formatting or behavior, exactly what is in the string will be what is displayed:
|
||||
- `"Hello world!"` => Hello world!
|
||||
- `"123 *456* **789**"` => 123 \*456\* \*\*789\*\*
|
||||
|
||||
## Markdown Literals
|
||||
Markdown literals can take any number of lines. Lines are separated by newline characters.
|
||||
```
|
||||
example-markdownMessage = md
|
||||
"Line 1"
|
||||
"Line 2"
|
||||
"Line 3"
|
||||
```
|
||||
|
||||
They can also consist of a single line:
|
||||
```
|
||||
example-markdownMessage = md "Line 1"
|
||||
```
|
||||
|
||||
A special sigil exists for denoting that no lines exist:
|
||||
```
|
||||
example-markdownMessage = md --
|
||||
```
|
||||
|
||||
### Line Types
|
||||
- Paragraph: `Example`
|
||||
- Header: `# Example` (subheaders are not supported)
|
||||
- Unordered List Item: `- Example`
|
||||
|
||||
### Line Features
|
||||
- Italic: `*Example*` => *Example*
|
||||
- Bold: `**Example**` => **Example**
|
||||
- Bold + Italic: `***Example***` => ***Example***
|
||||
- Links: `[Example](external.new:https://example.com/)` => [Example](https://example.com/)
|
||||
- Slots: `<example>`
|
||||
|
||||
Characters used for line feature syntax can be escaped to remove their effect and place the raw character in the string: `\*Example\*` => \*Example\*
|
||||
|
||||
### Links
|
||||
Links are made up of 4 components:
|
||||
```
|
||||
[Example](external.new:https://example.com/)
|
||||
^^^^^^^ ^^^^^^^^ ^^^ ^^^^^^^^^^^^^^^^^^^^
|
||||
name type tab destination
|
||||
```
|
||||
|
||||
`name` is the text displayed to the user on the webpage. It is optional; If blank, it will display the destination directly to the user.
|
||||
|
||||
`type` can be either `internal` or `external`, and changes what is deemed a valid `destination`.
|
||||
|
||||
If `tab` is `new`, the link will open the `destination` in a new tab. If `tab` is `replace`, it will open in the current tab.
|
||||
|
||||
`destination` is where the link will take the user when clicked. Its value depends on the value of `type` as follows:
|
||||
- If `type` is `external`: `destination` is any link starting with `http://` or `https://`
|
||||
- `http://example.com/`
|
||||
- `https://google.com/`
|
||||
- `https://viossa.net/`
|
||||
- If `type` is `internal`: `destination` consists of a `route` and `id`, in any of the following patterns:
|
||||
- `route`-only: brings the user to another route on the website
|
||||
- `/`
|
||||
- `/resources`
|
||||
- `/kotoba`
|
||||
- `/discord/rules`
|
||||
- `id`-only: jumps the user to a specific element ID on the current route
|
||||
- `#top`
|
||||
- `#header`
|
||||
- `#rule-1`
|
||||
- `route` with `id`: brings the user to another route and jumps to an element ID on that page
|
||||
- `/discord/rules#rule-1`
|
||||
- `/#top`
|
||||
Loading…
Add table
Add a link
Reference in a new issue