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 { useRouter } from "vue-router";
import SmartLink from "./components/atoms/SmartLink.vue"; import SmartLink from "./components/atoms/SmartLink.vue";
import type { SmartDest } from "./utils/smart-dest"; import type { SmartDest } from "./utils/smart-dest";
import { useLocale, type Locale } from "./new-i18n"; import { useLocale, type Locale } from "./i18n";
const locale = useLocale(); const locale = useLocale();

View file

@ -7,7 +7,7 @@ import {
} from "vue"; } from "vue";
import MarkdownParts from "./MarkdownParts.vue"; import MarkdownParts from "./MarkdownParts.vue";
import OptionalParent from "./OptionalParent.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"; import type { CssClass } from "@/utils/css";
const props = defineProps<{ const props = defineProps<{
@ -58,7 +58,7 @@ const getComponentStack = () => {
// Validate required slots at runtime // Validate required slots at runtime
onMounted(() => { onMounted(() => {
const requiredSlots = props.markdown.slots; const requiredSlots = props.markdown.slots;
const missingSlots = requiredSlots.filter( const missingSlots = [...requiredSlots].filter(
(slot) => providedSlots[slot] === undefined, (slot) => providedSlots[slot] === undefined,
); );

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { ref } from "vue";
import { vOnClickOutside } from "@vueuse/components"; import { vOnClickOutside } from "@vueuse/components";
import DropdownItem from "../atoms/DropdownItem.vue"; import DropdownItem from "../atoms/DropdownItem.vue";

View file

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

View file

@ -1,8 +1,8 @@
import { type InferLocaleFromConfig } from "@/new-i18n-lib/config"; import { type InferLocaleFromConfig } from "@/vi18n-lib/config";
import { import {
bundleToUncompiledLocaleRecord, bundleToUncompiledLocaleRecord,
loadFluentBundle, loadFluentBundle,
} from "@/new-i18n-lib/setup"; } from "@/vi18n-lib/setup";
import { localeConfig } from "./config"; import { localeConfig } from "./config";
import type { Result } from "@/utils/types"; import type { Result } from "@/utils/types";
import { useLocalStorage } from "@vueuse/core"; 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 vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl"; import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
import type { FluentBundle } from "@fluent/bundle"; 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; export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
@ -68,6 +68,7 @@ interface SetupLocaleFallback {
} }
function setupLocale( function setupLocale(
localeId: LocaleId,
localeBundle: FluentBundle, localeBundle: FluentBundle,
fallback: SetupLocaleFallback | undefined, fallback: SetupLocaleFallback | undefined,
): Result<Locale, string> { ): Result<Locale, string> {
@ -112,7 +113,15 @@ function setupLocale(
messageIdChain: [], messageIdChain: [],
}); });
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); console.error(localeRes.errors);
}
const locale = localeRes.locale; const locale = localeRes.locale;
return { type: "ok", ok: 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_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 ( const doItAllForLocale = async (
localeId: LocaleId, localeId: LocaleId,
@ -143,10 +154,11 @@ const doItAllForLocale = async (
): Promise<DeepReadonly<Locale>> => ): Promise<DeepReadonly<Locale>> =>
deepReadonly( deepReadonly(
unwrap( unwrap(
setupLocale(unwrap(await loadLocale(localeId, localeFtlSrc)), { setupLocale(
bundle: DEFAULT_LOCALE_BUNDLE, localeId,
locale: DEFAULT_LOCALE, unwrap(await loadLocale(localeId, localeFtlSrc)),
}), { bundle: DEFAULT_LOCALE_BUNDLE, locale: DEFAULT_LOCALE },
),
), ),
); );

View file

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

View file

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

View file

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

View file

@ -2,8 +2,8 @@
import LearningResourceWrapper, { import LearningResourceWrapper, {
type ResourceButton, type ResourceButton,
} from "@/components/molecules/LearningResourceWrapper.vue"; } from "@/components/molecules/LearningResourceWrapper.vue";
import { useLocale, type Locale } from "@/new-i18n"; import { useLocale, type Locale } from "@/i18n";
import type * as i18n from "@/new-i18n/config"; import type * as i18n from "@/i18n/config";
import { ignore } from "@/utils/ignore"; import { ignore } from "@/utils/ignore";
import { computed } from "vue"; import { computed } from "vue";
import discordImg from "@/assets/discord.png"; import discordImg from "@/assets/discord.png";

View file

@ -30,7 +30,9 @@ type GenericMarkdownMessageFn = (
placeableArgs?: GenericMessageFnPlaceableArgs, placeableArgs?: GenericMessageFnPlaceableArgs,
) => Markdown; ) => Markdown;
type GenericMessageFnPlaceableArgs = Record<string, FluentVariable>; type GenericMessageFnPlaceableArgs = Record<string, PlaceableValue>;
type PlaceableValue = string | number;
export interface CompileLocaleCtx<Config extends LocaleConfig> { export interface CompileLocaleCtx<Config extends LocaleConfig> {
bundle: FluentBundle; bundle: FluentBundle;
@ -42,7 +44,7 @@ export interface CompileLocaleCtx<Config extends LocaleConfig> {
export interface CompileLocaleRes<Locale> { export interface CompileLocaleRes<Locale> {
locale: Locale; locale: Locale;
errors: string[]; errors: readonly string[];
} }
export function compileLocale<Config extends LocaleConfig>( export function compileLocale<Config extends LocaleConfig>(
@ -122,7 +124,7 @@ export function compileLocale<Config extends LocaleConfig>(
return () => return () =>
createMissingMarkdownFallback( createMissingMarkdownFallback(
valueMessageIdChain, valueMessageIdChain,
Object.keys(configValue.features.slots), new Set(Object.keys(configValue.slots)),
); );
} }
} }
@ -263,7 +265,7 @@ function compileMessage(
case configMarkdownSymbol: { case configMarkdownSymbol: {
return compileMarkdownMessage({ return compileMarkdownMessage({
bundle, bundle,
configMarkdown: configValue, slots: new Set(Object.keys(configValue.slots)),
messageIdChain, messageIdChain,
allVariants, allVariants,
pattern, pattern,
@ -309,7 +311,7 @@ function compileStringMessage(
// TODO: will need to make sure markdown/slots are escapes when inserting variable values // TODO: will need to make sure markdown/slots are escapes when inserting variable values
return { return {
type: "ok", type: "ok",
ok: (args: Record<string, FluentVariable> = {}) => { ok: (args: Record<string, PlaceableValue> = {}) => {
const stringRes = ((): Result<string, string> => { const stringRes = ((): Result<string, string> => {
const stringLiteralRes = parseMessageLiteral( const stringLiteralRes = parseMessageLiteral(
"string", "string",
@ -361,7 +363,7 @@ function compileStringMessage(
interface CompileMarkdownMessageCtx { interface CompileMarkdownMessageCtx {
bundle: FluentBundle; bundle: FluentBundle;
messageIdChain: readonly [...string[], string]; messageIdChain: readonly [...string[], string];
configMarkdown: ConfigMarkdown<object, object>; slots: ReadonlySet<string>;
allVariants: readonly PatternVariant[]; allVariants: readonly PatternVariant[];
pattern: Pattern; pattern: Pattern;
} }
@ -369,13 +371,10 @@ interface CompileMarkdownMessageCtx {
function compileMarkdownMessage( function compileMarkdownMessage(
ctx: CompileMarkdownMessageCtx, ctx: CompileMarkdownMessageCtx,
): Result<GenericMarkdownMessageFn, string> { ): Result<GenericMarkdownMessageFn, string> {
const { bundle, messageIdChain, configMarkdown, allVariants, pattern } = const { bundle, messageIdChain, slots, allVariants, pattern } = ctx;
ctx;
// typecheck markdown // typecheck markdown
const markdownSlots = Object.keys(configMarkdown.features.slots);
// check if all variants are valid markdown // check if all variants are valid markdown
for (const variant of allVariants) { for (const variant of allVariants) {
const markdownLiteralRes = parseMessageLiteral("md", variant.string); const markdownLiteralRes = parseMessageLiteral("md", variant.string);
@ -388,7 +387,7 @@ function compileMarkdownMessage(
} }
const markdownLiteral = markdownLiteralRes.ok; const markdownLiteral = markdownLiteralRes.ok;
const markdownRes = parseMarkdown(markdownLiteral, markdownSlots); const markdownRes = parseMarkdown(markdownLiteral, slots);
if (markdownRes.type === "err") { if (markdownRes.type === "err") {
return { return {
@ -401,11 +400,67 @@ function compileMarkdownMessage(
// TODO: will need to make sure markdown/slots are escapes when inserting variable values // TODO: will need to make sure markdown/slots are escapes when inserting variable values
return { return {
type: "ok", type: "ok",
ok: (args: Record<string, FluentVariable> = {}): 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 markdownRes = ((): Result<Markdown, string> => {
const markdownLiteralRes = parseMessageLiteral( const markdownLiteralRes = parseMessageLiteral(
"md", "md",
bundle.formatPattern(pattern, args), bundle.formatPattern(pattern, escapedArgs),
); );
if (markdownLiteralRes.type === "err") { if (markdownLiteralRes.type === "err") {
@ -418,7 +473,7 @@ function compileMarkdownMessage(
} }
const markdownLiteral = markdownLiteralRes.ok; const markdownLiteral = markdownLiteralRes.ok;
const res = parseMarkdown(markdownLiteral, markdownSlots); const res = parseMarkdown(markdownLiteral, slots);
if (res.type === "err") { if (res.type === "err") {
// This should hopefully never happen since we've already // This should hopefully never happen since we've already
@ -440,10 +495,7 @@ function compileMarkdownMessage(
case "err": { case "err": {
const error = markdownRes.err; const error = markdownRes.err;
console.error(error); console.error(error);
return createMissingMarkdownFallback( return createMissingMarkdownFallback(messageIdChain, slots);
messageIdChain,
markdownSlots,
);
} }
} }
}, },
@ -458,7 +510,7 @@ function createMissingStringFallback(
function createMissingMarkdownFallback<Slot extends string>( function createMissingMarkdownFallback<Slot extends string>(
messageIdChain: readonly [...string[], string], messageIdChain: readonly [...string[], string],
slots: Slot[], slots: ReadonlySet<Slot>,
): Markdown<Slot> { ): Markdown<Slot> {
return { return {
elements: [ elements: [

View file

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

View file

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