361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import type { WordingKey } from "src/i18n/wordings-keys";
|
|
import type { ChronologyEvent, EndpointSource } from "src/shared/payload/payload-sdk";
|
|
import { cache } from "src/utils/payload";
|
|
import { capitalize, formatInlineTitle } from "src/utils/format";
|
|
|
|
export const defaultLocale = "en";
|
|
|
|
export const getI18n = async (locale: string) => {
|
|
const formatWithValues = (
|
|
templateName: string,
|
|
template: string,
|
|
values: Record<string, any>
|
|
): string => {
|
|
const limitMatchToBalanceCurlyBraces = (matchArray: RegExpMatchArray): string => {
|
|
// Cut match as soon as curly braces are balanced.
|
|
const match = matchArray[0];
|
|
let curlyCount = 2;
|
|
let index = 2;
|
|
while (index < match.length && curlyCount > 0) {
|
|
if (match[index] === "{") {
|
|
curlyCount++;
|
|
}
|
|
if (match[index] === "}") {
|
|
curlyCount--;
|
|
}
|
|
index++;
|
|
}
|
|
return match.substring(0, index);
|
|
};
|
|
|
|
Object.entries(values).forEach(([key, value]) => {
|
|
if (!template.match(new RegExp(`{{ ${key}\\+|{{ ${key}\\?|{{ ${key} }}`))) {
|
|
console.warn(
|
|
"Value",
|
|
key,
|
|
"has been provided but is not present in template",
|
|
templateName
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
// Find "plural" tokens
|
|
const matches = [
|
|
...template.matchAll(new RegExp(`{{ ${key}\\+,[\\w\\s=>{},]+ }}`, "g")),
|
|
].map(limitMatchToBalanceCurlyBraces);
|
|
|
|
const handlePlural = (match: string): string => {
|
|
match = match.substring(3, match.length - 3);
|
|
const options = match.split(",").splice(1);
|
|
for (const option of options) {
|
|
const optionCondition = option.split("{")[0];
|
|
if (!optionCondition) continue;
|
|
let optionValue = option.substring(optionCondition.length + 1);
|
|
if (!optionValue) continue;
|
|
optionValue = optionValue.substring(0, optionValue.length - 1);
|
|
if (option.startsWith("=")) {
|
|
const optionConditionValue = Number.parseInt(optionCondition.substring(1));
|
|
if (value === optionConditionValue) {
|
|
return optionValue;
|
|
}
|
|
} else if (option.startsWith(">")) {
|
|
const optionConditionValue = Number.parseInt(optionCondition.substring(1));
|
|
if (value > optionConditionValue) {
|
|
return optionValue;
|
|
}
|
|
} else if (option.startsWith("<")) {
|
|
const optionConditionValue = Number.parseInt(optionCondition.substring(1));
|
|
if (value < optionConditionValue) {
|
|
return optionValue;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
matches.forEach((match) => {
|
|
template = template.replace(match, handlePlural(match));
|
|
});
|
|
}
|
|
|
|
// Find "conditional" tokens
|
|
const matches = [...template.matchAll(new RegExp(`{{ ${key}\\?,[\\w\\s{},]+ }}`, "g"))].map(
|
|
limitMatchToBalanceCurlyBraces
|
|
);
|
|
|
|
const handleConditional = (match: string): string => {
|
|
match = match.substring(3, match.length - 3);
|
|
const options = match.split(",").splice(1);
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
return options[0] ?? "";
|
|
}
|
|
return options[1] ?? "";
|
|
};
|
|
|
|
matches.forEach((match) => {
|
|
template = template.replace(match, handleConditional(match));
|
|
});
|
|
|
|
// Find "variable" tokens
|
|
let prettyValue = value;
|
|
if (typeof prettyValue === "number") {
|
|
prettyValue = prettyValue.toLocaleString(locale);
|
|
}
|
|
template = template.replaceAll(`{{ ${key} }}`, prettyValue);
|
|
});
|
|
return template;
|
|
};
|
|
|
|
const getLocalizedMatch = <T extends { language: string }>(options: T[]): T =>
|
|
options.find(({ language }) => language === locale) ??
|
|
options.find(({ language }) => language === defaultLocale) ??
|
|
options[0]!; // We will consider that there will always be at least one option.
|
|
|
|
const t = (key: WordingKey, values: Record<string, any> = {}): string => {
|
|
const wording = cache.wordings.find(({ name }) => name === key);
|
|
const fallbackString = `«${key}»`;
|
|
|
|
if (!wording) {
|
|
return fallbackString;
|
|
}
|
|
|
|
const matchingWording = getLocalizedMatch(wording.translations).name;
|
|
return formatWithValues(key, matchingWording, values);
|
|
};
|
|
|
|
const getLocalizedUrl = (url: string): string => `/${locale}${url}`;
|
|
|
|
const formatPrice = (price: { amount: number; currency: string }): string =>
|
|
price.amount.toLocaleString(locale, { style: "currency", currency: price.currency });
|
|
|
|
const formatDate = (
|
|
date: Date,
|
|
options: Intl.DateTimeFormatOptions | undefined = { dateStyle: "medium" }
|
|
): string => date.toLocaleDateString(locale, options);
|
|
|
|
const formatDuration = (durationInSec: number) => {
|
|
const hours = Math.floor(durationInSec / 3600);
|
|
durationInSec -= hours * 3600;
|
|
const minutes = Math.floor(durationInSec / 60);
|
|
durationInSec -= minutes * 60;
|
|
const seconds = Math.floor(durationInSec);
|
|
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
const formatFilesize = (sizeInBytes: number): string => {
|
|
if (sizeInBytes < 1_000) return `${formatNumber(sizeInBytes, { maximumFractionDigits: 2 })} B`;
|
|
sizeInBytes = sizeInBytes / 1000;
|
|
if (sizeInBytes < 1_000) return `${formatNumber(sizeInBytes, { maximumFractionDigits: 2 })} KB`;
|
|
sizeInBytes = sizeInBytes / 1000;
|
|
if (sizeInBytes < 1_000) return `${formatNumber(sizeInBytes, { maximumFractionDigits: 2 })} MB`;
|
|
sizeInBytes = sizeInBytes / 1000;
|
|
return `${formatNumber(sizeInBytes, { maximumFractionDigits: 2 })} GB`;
|
|
};
|
|
|
|
const formatInches = (sizeInMm: number): string => {
|
|
return (
|
|
(sizeInMm * 0.039370078740157).toLocaleString(locale, { maximumFractionDigits: 2 }) + " in"
|
|
);
|
|
};
|
|
|
|
const formatMillimeters = (sizeInMm: number): string => {
|
|
return sizeInMm.toLocaleString(locale, { maximumFractionDigits: 0 }) + " mm";
|
|
};
|
|
|
|
const formatPounds = (weightInGrams: number): string => {
|
|
return (
|
|
(weightInGrams * 0.002204623).toLocaleString(locale, { maximumFractionDigits: 2 }) + " lb"
|
|
);
|
|
};
|
|
|
|
const formatGrams = (weightInGrams: number): string => {
|
|
return weightInGrams.toLocaleString(locale, { maximumFractionDigits: 0 }) + " g";
|
|
};
|
|
|
|
const formatNumber = (number: number, options?: Intl.NumberFormatOptions): string => {
|
|
return number.toLocaleString(locale, options);
|
|
};
|
|
|
|
const formatTimelineDate = ({ year, month, day }: ChronologyEvent["date"]): string => {
|
|
const date = new Date(0);
|
|
date.setFullYear(year);
|
|
if (month) date.setMonth(month - 1);
|
|
if (day) date.setDate(day);
|
|
|
|
return capitalize(
|
|
formatDate(date, {
|
|
year: "numeric",
|
|
month: month ? "long" : undefined,
|
|
day: day ? "numeric" : undefined,
|
|
})
|
|
);
|
|
};
|
|
|
|
const formatScanIndexShort = (index: string) => {
|
|
switch (index) {
|
|
case "cover-flap-front":
|
|
case "dustjacket-flap-front":
|
|
case "dustjacket-inside-flap-front":
|
|
case "obi-flap-front":
|
|
case "obi-inside-flap-front":
|
|
return t("collectibles.scans.shortIndex.flapFront");
|
|
|
|
case "cover-front":
|
|
case "cover-inside-front":
|
|
case "dustjacket-front":
|
|
case "dustjacket-inside-front":
|
|
case "obi-front":
|
|
case "obi-inside-front":
|
|
return t("collectibles.scans.shortIndex.front");
|
|
|
|
case "cover-spine":
|
|
case "dustjacket-spine":
|
|
case "dustjacket-inside-spine":
|
|
case "obi-spine":
|
|
case "obi-inside-spine":
|
|
return t("collectibles.scans.shortIndex.spine");
|
|
|
|
case "cover-back":
|
|
case "cover-inside-back":
|
|
case "dustjacket-back":
|
|
case "dustjacket-inside-back":
|
|
case "obi-back":
|
|
case "obi-inside-back":
|
|
return t("collectibles.scans.shortIndex.back");
|
|
|
|
case "cover-flap-back":
|
|
case "dustjacket-flap-back":
|
|
case "dustjacket-inside-flap-back":
|
|
case "obi-flap-back":
|
|
case "obi-inside-flap-back":
|
|
return t("collectibles.scans.shortIndex.flapBack");
|
|
|
|
default:
|
|
return index;
|
|
}
|
|
};
|
|
|
|
const formatEndpointSource = (
|
|
source: EndpointSource
|
|
): {
|
|
href: string;
|
|
typeLabel: string;
|
|
label: string;
|
|
lang?: string;
|
|
target?: string;
|
|
rel?: string;
|
|
} => {
|
|
switch (source.type) {
|
|
case "url": {
|
|
return {
|
|
href: source.url,
|
|
typeLabel: t("global.sources.typeLabel.url"),
|
|
label: source.label,
|
|
target: "_blank",
|
|
rel: "noopener noreferrer",
|
|
};
|
|
}
|
|
|
|
case "collectible": {
|
|
const rangeLabel = (() => {
|
|
switch (source.range?.type) {
|
|
case "timestamp":
|
|
return t("global.sources.typeLabel.collectible.range.timestamp", {
|
|
page: source.range.timestamp,
|
|
});
|
|
|
|
case "page":
|
|
return t("global.sources.typeLabel.collectible.range.page", {
|
|
page: source.range.page,
|
|
});
|
|
|
|
case "custom":
|
|
return t("global.sources.typeLabel.collectible.range.custom", {
|
|
note: getLocalizedMatch(source.range.translations).note,
|
|
});
|
|
|
|
case undefined:
|
|
default:
|
|
return "";
|
|
}
|
|
})();
|
|
|
|
const translation = getLocalizedMatch(source.collectible.translations);
|
|
return {
|
|
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}`),
|
|
typeLabel: t("global.sources.typeLabel.collectible"),
|
|
label: formatInlineTitle(translation) + rangeLabel,
|
|
lang: translation.language,
|
|
};
|
|
}
|
|
|
|
case "page": {
|
|
const translation = getLocalizedMatch(source.page.translations);
|
|
return {
|
|
href: getLocalizedUrl(`/pages/${source.page.slug}`),
|
|
typeLabel: t("global.sources.typeLabel.page"),
|
|
label: formatInlineTitle(translation),
|
|
lang: translation.language,
|
|
};
|
|
}
|
|
|
|
case "folder": {
|
|
const translation = getLocalizedMatch(source.folder.translations);
|
|
return {
|
|
href: getLocalizedUrl(`/folders/${source.folder.slug}`),
|
|
typeLabel: t("global.sources.typeLabel.folder"),
|
|
label: formatInlineTitle(translation),
|
|
lang: translation.language,
|
|
};
|
|
}
|
|
|
|
case "scans": {
|
|
const translation = getLocalizedMatch(source.collectible.translations);
|
|
return {
|
|
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}/scans`),
|
|
typeLabel: t("global.sources.typeLabel.scans"),
|
|
label: formatInlineTitle(translation),
|
|
lang: translation.language,
|
|
};
|
|
}
|
|
|
|
case "gallery": {
|
|
const translation = getLocalizedMatch(source.collectible.translations);
|
|
return {
|
|
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}/gallery`),
|
|
typeLabel: t("global.sources.typeLabel.gallery"),
|
|
label: formatInlineTitle(translation),
|
|
lang: translation.language,
|
|
};
|
|
}
|
|
|
|
default: {
|
|
return {
|
|
href: "/404",
|
|
label: `Invalid type ${source["type"]}`,
|
|
typeLabel: "Error",
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
t,
|
|
getLocalizedMatch,
|
|
getLocalizedUrl,
|
|
formatPrice,
|
|
formatDate,
|
|
formatDuration,
|
|
formatInches,
|
|
formatPounds,
|
|
formatGrams,
|
|
formatMillimeters,
|
|
formatNumber,
|
|
formatTimelineDate,
|
|
formatEndpointSource,
|
|
formatScanIndexShort,
|
|
formatFilesize,
|
|
};
|
|
};
|