@softure-ai/core 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +77 -12
- package/dist/calendar-day.d.ts +3 -0
- package/dist/calendar-day.d.ts.map +1 -1
- package/dist/calendar-day.js +5 -0
- package/dist/calendar-day.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -2
- package/dist/config.js.map +1 -1
- package/dist/format.d.ts +51 -7
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +98 -12
- package/dist/format.js.map +1 -1
- package/dist/i18n.d.ts +34 -11
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +40 -10
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/memoize.d.ts +16 -0
- package/dist/memoize.d.ts.map +1 -0
- package/dist/memoize.js +81 -0
- package/dist/memoize.js.map +1 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/index.js +1 -2
- package/dist/messages/index.js.map +1 -1
- package/dist/og/index.d.ts +2 -0
- package/dist/og/index.d.ts.map +1 -0
- package/dist/og/index.js +4 -0
- package/dist/og/index.js.map +1 -0
- package/dist/og/og-fonts.d.ts +37 -0
- package/dist/og/og-fonts.d.ts.map +1 -0
- package/dist/og/og-fonts.js +95 -0
- package/dist/og/og-fonts.js.map +1 -0
- package/package.json +6 -1
- package/src/calendar-day.ts +4 -0
- package/src/config.ts +11 -2
- package/src/format.ts +131 -17
- package/src/i18n.ts +53 -19
- package/src/index.ts +21 -2
- package/src/memoize.ts +89 -0
- package/src/messages/index.ts +1 -2
- package/src/og/index.ts +12 -0
- package/src/og/og-fonts.ts +127 -0
- package/dist/messages/pl.d.ts +0 -3
- package/dist/messages/pl.d.ts.map +0 -1
- package/dist/messages/pl.js +0 -7
- package/dist/messages/pl.js.map +0 -1
- package/src/messages/pl.ts +0 -8
package/src/format.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Days, money and percentages as people read them, one way for every module and app. A calendar day
|
|
2
|
-
// is formatted at UTC midnight in UTC, so neither the server's nor the reader's zone moves it.
|
|
2
|
+
// is formatted at UTC midnight in UTC, so neither the server's nor the reader's zone moves it. Every
|
|
3
|
+
// formatter takes any BCP 47 locale tag (an invalid one throws Intl's RangeError); the only built-in
|
|
4
|
+
// copy is English, and an app passes its own words where a formatter writes any.
|
|
3
5
|
import { isCalendarDay } from "./calendar-day.js";
|
|
4
6
|
import { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
|
|
5
|
-
import type
|
|
7
|
+
import { formatMessage, selectPlural, type PluralForms } from "./i18n.js";
|
|
6
8
|
|
|
7
|
-
/** `long`: October 4, 2026
|
|
9
|
+
/** `long`: October 4, 2026; `medium`: Oct 4, 2026; `numeric`: 10/04/2026 (each locale writes its own order and separators). */
|
|
8
10
|
export type CalendarDayStyle = "long" | "medium" | "numeric";
|
|
9
11
|
|
|
10
12
|
export interface FormatMoneyOptions {
|
|
@@ -16,8 +18,49 @@ export interface FormatMoneyOptions {
|
|
|
16
18
|
readonly compact?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/** `long`: October 2026; `roman`: X 2026. */
|
|
22
|
+
export type CalendarMonthStyle = "long" | "roman";
|
|
23
|
+
|
|
24
|
+
/** The words `formatDuration` writes: plural forms with `{count}`, and `both` joining `{years}` and `{months}`. */
|
|
25
|
+
export interface DurationMessages {
|
|
26
|
+
readonly years: PluralForms;
|
|
27
|
+
readonly months: PluralForms;
|
|
28
|
+
readonly both: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FormatDurationOptions {
|
|
32
|
+
/** The BCP 47 tag whose plural rules pick a form (default `"en"`). */
|
|
33
|
+
readonly locale?: string;
|
|
34
|
+
/** The app's own words (default `DEFAULT_DURATION_MESSAGES`, English). */
|
|
35
|
+
readonly messages?: DurationMessages;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FormatPercentOptions {
|
|
39
|
+
/** A `+` before a positive value (a `-` before a negative one is always there). Zero has neither in any mode. */
|
|
40
|
+
readonly signed?: boolean;
|
|
41
|
+
/** No space between the number and `%`, whatever the locale's pattern: `12,5%`, not `12,5 %`. */
|
|
42
|
+
readonly tight?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Exactly this many fraction digits, an integer from 0 to 20: `{ fractionDigits: 2 }` writes 1900 as `19.00%`.
|
|
45
|
+
* Without it a value has at most two fraction digits and no trailing zeros.
|
|
46
|
+
*/
|
|
47
|
+
readonly fractionDigits?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MONTHS_PER_YEAR = 12;
|
|
51
|
+
const MONTH_PATTERN = /^(\d{4})-(\d{2})$/;
|
|
52
|
+
const ROMAN_MONTHS = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"] as const;
|
|
53
|
+
|
|
54
|
+
/** The built-in, English words of `formatDuration`; any other category falls back to `other`. */
|
|
55
|
+
export const DEFAULT_DURATION_MESSAGES: DurationMessages = {
|
|
56
|
+
years: { one: "{count} year", other: "{count} years" },
|
|
57
|
+
months: { one: "{count} month", other: "{count} months" },
|
|
58
|
+
both: "{years} and {months}",
|
|
59
|
+
};
|
|
60
|
+
|
|
19
61
|
const BASIS_POINTS_PER_UNIT = 10_000;
|
|
20
62
|
const MAX_PERCENT_FRACTION_DIGITS = 2;
|
|
63
|
+
const FRACTION_DIGITS_LIMIT = 20;
|
|
21
64
|
|
|
22
65
|
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
23
66
|
const numberFormatters = new Map<string, Intl.NumberFormat>();
|
|
@@ -29,25 +72,61 @@ const DAY_STYLE_OPTIONS: Readonly<Record<CalendarDayStyle, Intl.DateTimeFormatOp
|
|
|
29
72
|
};
|
|
30
73
|
|
|
31
74
|
/** A `YYYY-MM-DD` day as `locale` writes it. Throws a `RangeError` for a malformed day. */
|
|
32
|
-
export function formatCalendarDay(day: string, locale:
|
|
75
|
+
export function formatCalendarDay(day: string, locale: string, style: CalendarDayStyle = "long"): string {
|
|
33
76
|
if (!isCalendarDay(day)) throw new RangeError(`formatCalendarDay: "${String(day)}" is not a YYYY-MM-DD calendar day`);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
77
|
+
return getDayFormatter(`${locale}:${style}`, locale, DAY_STYLE_OPTIONS[style]).format(new Date(`${day}T00:00:00Z`));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether `value` is a real calendar month written `YYYY-MM` (`2026-13` and `2026-1` are not). */
|
|
81
|
+
export function isCalendarMonth(value: unknown): value is string {
|
|
82
|
+
if (typeof value !== "string") return false;
|
|
83
|
+
const match = MONTH_PATTERN.exec(value);
|
|
84
|
+
if (match === null) return false;
|
|
85
|
+
const month = Number(match[2]);
|
|
86
|
+
return month >= 1 && month <= MONTHS_PER_YEAR;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The month of a `YYYY-MM` month or a `YYYY-MM-DD` day, with its year, as `locale` writes it alone. Throws a
|
|
91
|
+
* `RangeError` for anything else.
|
|
92
|
+
*/
|
|
93
|
+
export function formatCalendarMonth(month: string, locale: string, style: CalendarMonthStyle = "long"): string {
|
|
94
|
+
const firstDay = isCalendarMonth(month) ? `${month}-01` : month;
|
|
95
|
+
if (!isCalendarDay(firstDay)) throw new RangeError(`formatCalendarMonth: "${String(month)}" is not a YYYY-MM month or a YYYY-MM-DD day`);
|
|
96
|
+
const date = new Date(`${firstDay}T00:00:00Z`);
|
|
97
|
+
const year = firstDay.slice(0, 4);
|
|
98
|
+
if (style === "roman") return `${ROMAN_MONTHS[date.getUTCMonth()] ?? ""} ${year}`;
|
|
99
|
+
return getDayFormatter(`${locale}:month`, locale, { month: "long", year: "numeric" }).format(date);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whole months as years and months: 38 is "3 years and 2 months"; a part that is zero is left out, and zero itself is
|
|
104
|
+
* "0 months". English by default; an app passes its own `messages` and the `locale` whose plural rules pick among
|
|
105
|
+
* them. Throws a `RangeError` for a negative or fractional count, or an invalid locale tag.
|
|
106
|
+
*/
|
|
107
|
+
export function formatDuration(months: number, options: FormatDurationOptions = {}): string {
|
|
108
|
+
if (!Number.isSafeInteger(months) || months < 0) {
|
|
109
|
+
throw new RangeError(`formatDuration: ${String(months)} is not a whole, non-negative number of months`);
|
|
39
110
|
}
|
|
40
|
-
|
|
111
|
+
const locale = options.locale ?? "en";
|
|
112
|
+
const copy = options.messages ?? DEFAULT_DURATION_MESSAGES;
|
|
113
|
+
const years = Math.floor(months / MONTHS_PER_YEAR);
|
|
114
|
+
const rest = months % MONTHS_PER_YEAR;
|
|
115
|
+
const yearsText = formatMessage(selectPlural(locale, years, copy.years), { count: years });
|
|
116
|
+
const monthsText = formatMessage(selectPlural(locale, rest, copy.months), { count: rest });
|
|
117
|
+
if (years === 0) return monthsText;
|
|
118
|
+
if (rest === 0) return yearsText;
|
|
119
|
+
return formatMessage(copy.both, { years: yearsText, months: monthsText });
|
|
41
120
|
}
|
|
42
121
|
|
|
43
122
|
/**
|
|
44
123
|
* An amount in the currency's minor unit (2900 PLN = PLN 29.00) in `locale`'s notation, thousands always
|
|
45
|
-
* grouped (
|
|
124
|
+
* grouped (even where a locale's default would leave four digits together), and zero is never signed (`-0`
|
|
46
125
|
* and an amount that rounds to zero show as zero). The minor unit is the pinned ISO
|
|
47
126
|
* 4217 one (`CURRENCY_MINOR_UNIT_DIGITS`); a code outside the table gets `Intl`'s digits. Throws a
|
|
48
127
|
* `RangeError` for an amount that is not a whole number of minor units, or a code `Intl` refuses.
|
|
49
128
|
*/
|
|
50
|
-
export function formatMoney(minor: number, currency: string, locale:
|
|
129
|
+
export function formatMoney(minor: number, currency: string, locale: string, options: FormatMoneyOptions = {}): string {
|
|
51
130
|
if (!Number.isSafeInteger(minor)) throw new RangeError(`formatMoney: ${String(minor)} is not a whole number of minor units`);
|
|
52
131
|
const pinned = Object.hasOwn(CURRENCY_MINOR_UNIT_DIGITS, currency) ? CURRENCY_MINOR_UNIT_DIGITS[currency] : undefined;
|
|
53
132
|
const digits = pinned ?? getNumberFormatter(locale, { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
@@ -62,14 +141,49 @@ export function formatMoney(minor: number, currency: string, locale: Locale, opt
|
|
|
62
141
|
return format.format(minor / 10 ** digits);
|
|
63
142
|
}
|
|
64
143
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Basis points as a percentage in `locale`: 1250 is 12.5%, at most two fraction digits unless `fractionDigits` fixes
|
|
146
|
+
* them, and zero is never signed (`-0` and a value that rounds to zero show as `0%`, or `0.0%` at one digit). Throws a
|
|
147
|
+
* `RangeError` for a non-finite value or an invalid `fractionDigits`.
|
|
148
|
+
*/
|
|
149
|
+
export function formatPercent(basisPoints: number, locale: string, options: FormatPercentOptions = {}): string {
|
|
67
150
|
if (!Number.isFinite(basisPoints)) throw new RangeError(`formatPercent: ${String(basisPoints)} is not a finite number of basis points`);
|
|
68
|
-
const format = getNumberFormatter(locale, {
|
|
69
|
-
|
|
151
|
+
const format = getNumberFormatter(locale, {
|
|
152
|
+
style: "percent",
|
|
153
|
+
...getPercentFractionDigits(options.fractionDigits),
|
|
154
|
+
signDisplay: options.signed === true ? "exceptZero" : "negative",
|
|
155
|
+
});
|
|
156
|
+
const value = basisPoints / BASIS_POINTS_PER_UNIT;
|
|
157
|
+
if (options.tight !== true) return format.format(value);
|
|
158
|
+
const parts = format.formatToParts(value);
|
|
159
|
+
return parts
|
|
160
|
+
.filter((part, index) => !(part.type === "literal" && part.value.trim() === "" && isNextToPercentSign(parts, index)))
|
|
161
|
+
.map((part) => part.value)
|
|
162
|
+
.join("");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getPercentFractionDigits(fractionDigits: number | undefined): Intl.NumberFormatOptions {
|
|
166
|
+
if (fractionDigits === undefined) return { maximumFractionDigits: MAX_PERCENT_FRACTION_DIGITS };
|
|
167
|
+
if (!Number.isInteger(fractionDigits) || fractionDigits < 0 || fractionDigits > FRACTION_DIGITS_LIMIT) {
|
|
168
|
+
throw new RangeError(`formatPercent: fractionDigits ${String(fractionDigits)} is not an integer from 0 to ${String(FRACTION_DIGITS_LIMIT)}`);
|
|
169
|
+
}
|
|
170
|
+
return { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isNextToPercentSign(parts: readonly Intl.NumberFormatPart[], index: number): boolean {
|
|
174
|
+
return parts[index - 1]?.type === "percentSign" || parts[index + 1]?.type === "percentSign";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getDayFormatter(key: string, locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
178
|
+
let format = dayFormatters.get(key);
|
|
179
|
+
if (format === undefined) {
|
|
180
|
+
format = new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" });
|
|
181
|
+
dayFormatters.set(key, format);
|
|
182
|
+
}
|
|
183
|
+
return format;
|
|
70
184
|
}
|
|
71
185
|
|
|
72
|
-
function getNumberFormatter(locale:
|
|
186
|
+
function getNumberFormatter(locale: string, options: Intl.NumberFormatOptions): Intl.NumberFormat {
|
|
73
187
|
const key = `${locale}:${JSON.stringify(options)}`;
|
|
74
188
|
let format = numberFormatters.get(key);
|
|
75
189
|
if (format === undefined) {
|
package/src/i18n.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// Copy and localization (docs/02-module-standard.md §6): every module ships complete `en`
|
|
2
|
-
//
|
|
1
|
+
// Copy and localization (docs/02-module-standard.md §6): every module ships a complete `en`
|
|
2
|
+
// dictionary, the only built-in language. An app that shows another language passes its own
|
|
3
|
+
// translation as overrides for its locale; a key it leaves out reads English.
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/** A BCP 47 language tag (`en`, `de-AT`). */
|
|
6
|
+
export type Locale = string;
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
/** The language of every built-in dictionary and the fallback for any other locale. */
|
|
9
|
+
export const DEFAULT_LOCALE = "en";
|
|
7
10
|
|
|
8
11
|
/** A dictionary: nested groups of message strings. */
|
|
9
12
|
export interface MessageTree {
|
|
@@ -13,11 +16,14 @@ export interface MessageTree {
|
|
|
13
16
|
/** The same shape with every key optional, at every depth. */
|
|
14
17
|
export type DeepPartial<T> = { [K in keyof T]?: T[K] extends string ? string : DeepPartial<T[K]> };
|
|
15
18
|
|
|
16
|
-
/**
|
|
17
|
-
export type Dictionaries<T extends MessageTree> =
|
|
19
|
+
/** A complete `en` dictionary, plus complete dictionaries for any other locales. */
|
|
20
|
+
export type Dictionaries<T extends MessageTree> = { readonly en: T } & { readonly [locale: string]: T | undefined };
|
|
18
21
|
|
|
19
|
-
/** Partial overrides an app passes for
|
|
20
|
-
export type MessageOverrides<T extends MessageTree> =
|
|
22
|
+
/** Partial overrides an app passes per locale: a translation for its own locale, or changed English copy. */
|
|
23
|
+
export type MessageOverrides<T extends MessageTree> = { readonly [locale: string]: DeepPartial<T> | undefined };
|
|
24
|
+
|
|
25
|
+
/** A text an app writes per locale in its options (a plan name, a label); `en` is the fallback. */
|
|
26
|
+
export type LocalizedText = { readonly [locale: string]: string | undefined };
|
|
21
27
|
|
|
22
28
|
/** Plural forms named after the `Intl.PluralRules` categories; `other` is the fallback. */
|
|
23
29
|
export interface PluralForms {
|
|
@@ -29,24 +35,49 @@ export interface PluralForms {
|
|
|
29
35
|
readonly other: string;
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/** Whether `value` is a well-formed BCP 47 language tag. */
|
|
32
39
|
export function isLocale(value: unknown): value is Locale {
|
|
33
|
-
|
|
40
|
+
if (typeof value !== "string" || value === "") return false;
|
|
41
|
+
try {
|
|
42
|
+
return Intl.getCanonicalLocales(value).length === 1;
|
|
43
|
+
} catch {
|
|
44
|
+
// RangeError: not a well-formed tag.
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
/**
|
|
37
|
-
* The defaults with the overrides applied, per locale
|
|
38
|
-
*
|
|
39
|
-
*
|
|
50
|
+
* The defaults with the overrides applied, per locale: `en` and every locale the defaults or the
|
|
51
|
+
* overrides name. A locale without its own defaults is built on `en`, so a key its override leaves
|
|
52
|
+
* out reads English. Only keys the defaults have are taken, and a group is never replaced by a
|
|
53
|
+
* string, so an override cannot break the dictionary shape. Inputs are not mutated.
|
|
40
54
|
*/
|
|
41
55
|
export function mergeMessages<T extends MessageTree>(
|
|
42
56
|
defaults: Dictionaries<T>,
|
|
43
57
|
overrides: MessageOverrides<T> = {},
|
|
44
58
|
): Dictionaries<T> {
|
|
45
|
-
const merged
|
|
46
|
-
for (const locale of
|
|
47
|
-
merged[locale] = mergeTree(defaults[locale], overrides[locale]) as T;
|
|
59
|
+
const merged: Record<string, T> = {};
|
|
60
|
+
for (const locale of new Set([DEFAULT_LOCALE, ...Object.keys(defaults), ...Object.keys(overrides)])) {
|
|
61
|
+
merged[locale] = mergeTree(defaults[locale] ?? defaults.en, overrides[locale]) as T;
|
|
48
62
|
}
|
|
49
|
-
return merged
|
|
63
|
+
return merged as Dictionaries<T>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The dictionary for `locale`: its own, else its language's (`pt-BR` reads `pt`), else `en`.
|
|
68
|
+
*/
|
|
69
|
+
export function selectMessages<T extends MessageTree>(dictionaries: Dictionaries<T>, locale: Locale): T {
|
|
70
|
+
return dictionaries[locale] ?? dictionaries[getLanguage(locale)] ?? dictionaries.en;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The text for `locale` with `selectMessages`' fallback order, or `fallback` when no locale has one. */
|
|
74
|
+
export function pickLocalizedText(text: LocalizedText | undefined, locale: Locale, fallback = ""): string {
|
|
75
|
+
if (text === undefined) return fallback;
|
|
76
|
+
return text[locale] ?? text[getLanguage(locale)] ?? text.en ?? fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getLanguage(locale: Locale): string {
|
|
80
|
+
return locale.split("-")[0] ?? locale;
|
|
50
81
|
}
|
|
51
82
|
|
|
52
83
|
/** Fills `{name}` placeholders. A placeholder without a value stays as written. */
|
|
@@ -57,10 +88,13 @@ export function formatMessage(template: string, params: Readonly<Record<string,
|
|
|
57
88
|
});
|
|
58
89
|
}
|
|
59
90
|
|
|
60
|
-
const pluralRulesByLocale = new Map<
|
|
91
|
+
const pluralRulesByLocale = new Map<string, Intl.PluralRules>();
|
|
61
92
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
93
|
+
/**
|
|
94
|
+
* The form for `count` under the plural rules of `locale`, any BCP 47 tag ("1 file", "2 files"). A category the
|
|
95
|
+
* forms leave out falls back to `other`.
|
|
96
|
+
*/
|
|
97
|
+
export function selectPlural(locale: string, count: number, forms: PluralForms): string {
|
|
64
98
|
let rules = pluralRulesByLocale.get(locale);
|
|
65
99
|
if (rules === undefined) {
|
|
66
100
|
rules = new Intl.PluralRules(locale);
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export {
|
|
|
4
4
|
addCalendarDays,
|
|
5
5
|
addCalendarMonths,
|
|
6
6
|
calendarDaysBetween,
|
|
7
|
+
calendarDaySchema,
|
|
7
8
|
getCalendarDay,
|
|
8
9
|
isCalendarDay,
|
|
9
10
|
toCalendarDay,
|
|
@@ -11,18 +12,36 @@ export {
|
|
|
11
12
|
type EndOfMonth,
|
|
12
13
|
} from "./calendar-day.js";
|
|
13
14
|
export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
|
|
14
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
DEFAULT_DURATION_MESSAGES,
|
|
17
|
+
formatCalendarDay,
|
|
18
|
+
formatCalendarMonth,
|
|
19
|
+
formatDuration,
|
|
20
|
+
formatMoney,
|
|
21
|
+
formatPercent,
|
|
22
|
+
isCalendarMonth,
|
|
23
|
+
type CalendarDayStyle,
|
|
24
|
+
type CalendarMonthStyle,
|
|
25
|
+
type DurationMessages,
|
|
26
|
+
type FormatDurationOptions,
|
|
27
|
+
type FormatMoneyOptions,
|
|
28
|
+
type FormatPercentOptions,
|
|
29
|
+
} from "./format.js";
|
|
30
|
+
export { memoizeByContent, type MemoizeByContentOptions, type MemoizedFunction } from "./memoize.js";
|
|
15
31
|
export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
|
|
16
32
|
export {
|
|
17
33
|
formatMessage,
|
|
18
34
|
getMessage,
|
|
35
|
+
DEFAULT_LOCALE,
|
|
19
36
|
isLocale,
|
|
20
|
-
LOCALES,
|
|
21
37
|
mergeMessages,
|
|
38
|
+
pickLocalizedText,
|
|
39
|
+
selectMessages,
|
|
22
40
|
selectPlural,
|
|
23
41
|
type DeepPartial,
|
|
24
42
|
type Dictionaries,
|
|
25
43
|
type Locale,
|
|
44
|
+
type LocalizedText,
|
|
26
45
|
type MessageOverrides,
|
|
27
46
|
type MessageTree,
|
|
28
47
|
type PluralForms,
|
package/src/memoize.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// A cache for pure functions of plain data: the arguments' content is the key, so two equal objects built apart hit
|
|
2
|
+
// the same entry. Least recently used entries go beyond `max`; nothing expires by time.
|
|
3
|
+
|
|
4
|
+
export interface MemoizeByContentOptions {
|
|
5
|
+
/** The most entries kept; the least recently used one goes first. */
|
|
6
|
+
readonly max: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** `fn` with a cache, plus `clear()` to empty it. */
|
|
10
|
+
export type MemoizedFunction<Args extends readonly unknown[], Value> = ((...args: Args) => Value) & { readonly clear: () => void };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `fn` cached by the content of its arguments: primitives, arrays, `Date`s and plain objects (key order does not
|
|
14
|
+
* matter). An argument holding anything else (a function, a `Map`, a class instance, a cycle) throws a `TypeError`.
|
|
15
|
+
* A promise that rejects is dropped, so the next call tries again. Throws a `RangeError` for a `max` that is not a
|
|
16
|
+
* positive whole number.
|
|
17
|
+
*/
|
|
18
|
+
export function memoizeByContent<Args extends readonly unknown[], Value>(
|
|
19
|
+
fn: (...args: Args) => Value,
|
|
20
|
+
options: MemoizeByContentOptions,
|
|
21
|
+
): MemoizedFunction<Args, Value> {
|
|
22
|
+
const { max } = options;
|
|
23
|
+
if (!Number.isSafeInteger(max) || max < 1) throw new RangeError(`memoizeByContent: max must be a positive whole number, got ${String(max)}`);
|
|
24
|
+
const cache = new Map<string, Value>();
|
|
25
|
+
|
|
26
|
+
const memoized = (...args: Args): Value => {
|
|
27
|
+
const key = toContentKey(args, new Set());
|
|
28
|
+
if (cache.has(key)) {
|
|
29
|
+
// Not `?? fn(...)`: a cached `undefined` is a hit too.
|
|
30
|
+
const hit = cache.get(key) as Value;
|
|
31
|
+
cache.delete(key);
|
|
32
|
+
cache.set(key, hit);
|
|
33
|
+
return hit;
|
|
34
|
+
}
|
|
35
|
+
const value = fn(...args);
|
|
36
|
+
cache.set(key, value);
|
|
37
|
+
if (cache.size > max) {
|
|
38
|
+
const oldest = cache.keys().next();
|
|
39
|
+
if (oldest.done !== true) cache.delete(oldest.value);
|
|
40
|
+
}
|
|
41
|
+
if (value instanceof Promise) {
|
|
42
|
+
value.catch(() => {
|
|
43
|
+
if (cache.get(key) === value) cache.delete(key);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
return Object.assign(memoized, { clear: () => cache.clear() });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toContentKey(value: unknown, path: Set<object>): string {
|
|
52
|
+
switch (typeof value) {
|
|
53
|
+
case "undefined":
|
|
54
|
+
return "u";
|
|
55
|
+
case "boolean":
|
|
56
|
+
return value ? "t" : "f";
|
|
57
|
+
case "number":
|
|
58
|
+
return `n${Object.is(value, -0) ? "-0" : String(value)}`;
|
|
59
|
+
case "bigint":
|
|
60
|
+
return `b${String(value)}`;
|
|
61
|
+
case "string":
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
case "object":
|
|
64
|
+
return value === null ? "null" : toObjectKey(value, path);
|
|
65
|
+
default:
|
|
66
|
+
throw new TypeError(`memoizeByContent: an argument holds a ${typeof value}, which has no content key`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toObjectKey(value: object, path: Set<object>): string {
|
|
71
|
+
if (value instanceof Date) return `d${String(value.getTime())}`;
|
|
72
|
+
if (path.has(value)) throw new TypeError("memoizeByContent: an argument holds a cycle");
|
|
73
|
+
path.add(value);
|
|
74
|
+
let key: string;
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
key = `[${value.map((item: unknown) => toContentKey(item, path)).join(",")}]`;
|
|
77
|
+
} else {
|
|
78
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
79
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
80
|
+
throw new TypeError(`memoizeByContent: an argument holds a ${value.constructor.name}, which has no content key`);
|
|
81
|
+
}
|
|
82
|
+
const entries = Object.keys(value)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((name) => `${JSON.stringify(name)}:${toContentKey((value as Record<string, unknown>)[name], path)}`);
|
|
85
|
+
key = `{${entries.join(",")}}`;
|
|
86
|
+
}
|
|
87
|
+
path.delete(value);
|
|
88
|
+
return key;
|
|
89
|
+
}
|
package/src/messages/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Dictionaries } from "../i18n.js";
|
|
2
2
|
import { en } from "./en.js";
|
|
3
|
-
import { pl } from "./pl.js";
|
|
4
3
|
|
|
5
4
|
export type CoreMessages = typeof en;
|
|
6
5
|
|
|
7
6
|
/** Copy for core's own error codes (`core.<key>` → `errors.<key>`). */
|
|
8
|
-
export const coreMessages: Dictionaries<CoreMessages> = { en
|
|
7
|
+
export const coreMessages: Dictionaries<CoreMessages> = { en };
|
package/src/og/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The OG card font loader: server only (it reads files), no `next/*` imports. `@softure-ai/seo/server` re-exports it
|
|
2
|
+
// for an app's own `opengraph-image` routes, the blog for its article card.
|
|
3
|
+
export {
|
|
4
|
+
createOgFontLoader,
|
|
5
|
+
loadOgFonts,
|
|
6
|
+
OG_FONT_WEIGHTS,
|
|
7
|
+
type OgFont,
|
|
8
|
+
type OgFontLoaderOptions,
|
|
9
|
+
type OgFontSource,
|
|
10
|
+
type OgFontsResult,
|
|
11
|
+
type OgFontWeight,
|
|
12
|
+
} from "./og-fonts.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The loader of an OG card's fonts: each source is read once, checked to be a font Satori draws (.ttf,
|
|
2
|
+
// .otf, .woff; not .woff2), and kept for the life of the loader. A failed read is not kept, so a fixed
|
|
3
|
+
// file is picked up by the next call. It imports no Next code, so a command-line check can run it too.
|
|
4
|
+
// `@softure-ai/seo/server` and the blog re-export it.
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** The weights Satori (`next/og`) draws: static fonts only, in steps of 100. */
|
|
9
|
+
export const OG_FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
|
|
10
|
+
|
|
11
|
+
export type OgFontWeight = (typeof OG_FONT_WEIGHTS)[number];
|
|
12
|
+
|
|
13
|
+
/** A font to read: a path (from the loader's root, or absolute) or an https URL, and how the card names it. */
|
|
14
|
+
export interface OgFontSource {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly src: string;
|
|
17
|
+
readonly weight?: OgFontWeight;
|
|
18
|
+
readonly style?: "normal" | "italic";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A font for the card, as `ImageResponse` takes it. */
|
|
22
|
+
export interface OgFont {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly data: ArrayBuffer;
|
|
25
|
+
readonly weight?: OgFontWeight;
|
|
26
|
+
readonly style?: "normal" | "italic";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type OgFontsResult = { readonly ok: true; readonly fonts: readonly OgFont[] } | { readonly ok: false; readonly error: string };
|
|
30
|
+
|
|
31
|
+
type BytesResult = { readonly ok: true; readonly data: ArrayBuffer } | { readonly ok: false; readonly reason: string };
|
|
32
|
+
|
|
33
|
+
export interface OgFontLoaderOptions {
|
|
34
|
+
/** Where a relative path starts: the app's root. */
|
|
35
|
+
readonly root: string;
|
|
36
|
+
readonly readFile?: (path: string) => Promise<Uint8Array>;
|
|
37
|
+
readonly fetchImpl?: typeof fetch;
|
|
38
|
+
/** What a message names the list by, before `[index]`. Default: `OG card: fonts`. */
|
|
39
|
+
readonly label?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_LABEL = "OG card: fonts";
|
|
43
|
+
|
|
44
|
+
/** The first four bytes of each font format Satori parses. */
|
|
45
|
+
const FONT_SIGNATURES = ["wOFF", "OTTO", "\u0000\u0001\u0000\u0000", "true"];
|
|
46
|
+
const WOFF2_SIGNATURE = "wOF2";
|
|
47
|
+
|
|
48
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
49
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
50
|
+
copy.set(bytes);
|
|
51
|
+
return copy.buffer;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function checkFontBytes(bytes: Uint8Array): BytesResult {
|
|
55
|
+
const signature = String.fromCharCode(...bytes.subarray(0, 4));
|
|
56
|
+
if (signature === WOFF2_SIGNATURE) return { ok: false, reason: "a .woff2 font, which the card cannot read; use the .woff or .ttf file" };
|
|
57
|
+
if (!FONT_SIGNATURES.includes(signature)) return { ok: false, reason: "not a .ttf, .otf or .woff font" };
|
|
58
|
+
return { ok: true, data: toArrayBuffer(bytes) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getErrorCode(error: unknown): string {
|
|
62
|
+
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code;
|
|
63
|
+
return error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A loader with its own cache, keyed by the resolved path or URL. */
|
|
67
|
+
export function createOgFontLoader(options: OgFontLoaderOptions): (fonts: readonly OgFontSource[]) => Promise<OgFontsResult> {
|
|
68
|
+
const read = options.readFile ?? readFile;
|
|
69
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
70
|
+
const label = options.label ?? DEFAULT_LABEL;
|
|
71
|
+
const cache = new Map<string, Promise<BytesResult>>();
|
|
72
|
+
|
|
73
|
+
async function readSource(location: string, isUrl: boolean): Promise<BytesResult> {
|
|
74
|
+
if (!isUrl) {
|
|
75
|
+
try {
|
|
76
|
+
return checkFontBytes(await read(location));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ok: false, reason: `the file cannot be read (${getErrorCode(error)})` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let response: Response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetchImpl(location);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { ok: false, reason: `the request failed (${getErrorCode(error)})` };
|
|
86
|
+
}
|
|
87
|
+
if (!response.ok) return { ok: false, reason: `the server answered ${response.status}` };
|
|
88
|
+
try {
|
|
89
|
+
return checkFontBytes(new Uint8Array(await response.arrayBuffer()));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { ok: false, reason: `the answer could not be read (${getErrorCode(error)})` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getBytes(location: string, isUrl: boolean): Promise<BytesResult> {
|
|
96
|
+
const cached = cache.get(location);
|
|
97
|
+
if (cached !== undefined) return cached;
|
|
98
|
+
const pending = readSource(location, isUrl).then((result) => {
|
|
99
|
+
if (!result.ok) cache.delete(location);
|
|
100
|
+
return result;
|
|
101
|
+
});
|
|
102
|
+
cache.set(location, pending);
|
|
103
|
+
return pending;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return async (fonts) => {
|
|
107
|
+
const located = fonts.map((font) => {
|
|
108
|
+
const isUrl = font.src.startsWith("https://");
|
|
109
|
+
return { font, isUrl, location: isUrl || isAbsolute(font.src) ? font.src : resolve(options.root, font.src) };
|
|
110
|
+
});
|
|
111
|
+
const results = await Promise.all(located.map(async (entry) => ({ ...entry, result: await getBytes(entry.location, entry.isUrl) })));
|
|
112
|
+
const loaded: OgFont[] = [];
|
|
113
|
+
for (const [index, { font, location, result }] of results.entries()) {
|
|
114
|
+
if (!result.ok) return { ok: false, error: `${label}[${index}] "${font.src}": ${result.reason} (${location}).` };
|
|
115
|
+
loaded.push({ name: font.name, data: result.data, weight: font.weight, style: font.style });
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, fonts: loaded };
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let loadFromWorkingDirectory: ((fonts: readonly OgFontSource[]) => Promise<OgFontsResult>) | undefined;
|
|
122
|
+
|
|
123
|
+
/** The fonts for an app's own OG card, read from the working directory (the app's root, where Next runs) and cached for the process. */
|
|
124
|
+
export function loadOgFonts(fonts: readonly OgFontSource[]): Promise<OgFontsResult> {
|
|
125
|
+
loadFromWorkingDirectory ??= createOgFontLoader({ root: process.cwd() });
|
|
126
|
+
return loadFromWorkingDirectory(fonts);
|
|
127
|
+
}
|
package/dist/messages/pl.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"pl.d.ts","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,eAAO,MAAM,EAAE,EAAE,OAAO,EAKvB,CAAC"}
|
package/dist/messages/pl.js
DELETED
package/dist/messages/pl.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"pl.js","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,EAAE,GAAc;IAC3B,MAAM,EAAE;QACN,eAAe,EAAE,wEAAwE;QACzF,UAAU,EAAE,uCAAuC;KACpD;CACF,CAAC"}
|
package/src/messages/pl.ts
DELETED