feat(i18n): first draft of vi18n lib
This commit is contained in:
parent
f7a504d5a3
commit
4295a83ee1
20 changed files with 194 additions and 132 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] }>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
@ -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 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useLocale } from "@/new-i18n";
|
||||
import { useLocale } from "@/i18n";
|
||||
|
||||
const locale = useLocale();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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(() =>
|
||||
Loading…
Add table
Add a link
Reference in a new issue