@softure-ai/core 0.1.11 → 0.2.1
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 +50 -0
- package/README.md +122 -19
- package/dist/calendar-period.d.ts +18 -0
- package/dist/calendar-period.d.ts.map +1 -0
- package/dist/calendar-period.js +44 -0
- package/dist/calendar-period.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/format.d.ts +35 -14
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +30 -16
- package/dist/format.js.map +1 -1
- package/dist/http-json.d.ts +32 -0
- package/dist/http-json.d.ts.map +1 -0
- package/dist/http-json.js +83 -0
- package/dist/http-json.js.map +1 -0
- 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 +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/local-date-time.d.ts +10 -0
- package/dist/local-date-time.d.ts.map +1 -0
- package/dist/local-date-time.js +25 -0
- package/dist/local-date-time.js.map +1 -0
- package/dist/messages/en.d.ts +5 -15
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +5 -5
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +1 -1
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/index.js +2 -3
- package/dist/messages/index.js.map +1 -1
- package/package.json +1 -1
- package/src/calendar-period.ts +54 -0
- package/src/config.ts +2 -2
- package/src/format.ts +61 -25
- package/src/http-json.ts +130 -0
- package/src/i18n.ts +53 -19
- package/src/index.ts +24 -1
- package/src/local-date-time.ts +30 -0
- package/src/messages/en.ts +5 -5
- package/src/messages/index.ts +2 -3
- package/dist/messages/pl.d.ts +0 -3
- package/dist/messages/pl.d.ts.map +0 -1
- package/dist/messages/pl.js +0 -12
- package/dist/messages/pl.js.map +0 -1
- package/src/messages/pl.ts +0 -13
package/src/format.ts
CHANGED
|
@@ -1,11 +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 { formatMessage, selectPlural, type
|
|
6
|
-
import { coreMessages } from "./messages/index.js";
|
|
7
|
+
import { formatMessage, selectPlural, type PluralForms } from "./i18n.js";
|
|
7
8
|
|
|
8
|
-
/** `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). */
|
|
9
10
|
export type CalendarDayStyle = "long" | "medium" | "numeric";
|
|
10
11
|
|
|
11
12
|
export interface FormatMoneyOptions {
|
|
@@ -17,21 +18,49 @@ export interface FormatMoneyOptions {
|
|
|
17
18
|
readonly compact?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/** `long`: October 2026
|
|
21
|
-
export type CalendarMonthStyle = "long" | "
|
|
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
|
+
}
|
|
22
37
|
|
|
23
38
|
export interface FormatPercentOptions {
|
|
24
39
|
/** A `+` before a positive value (a `-` before a negative one is always there). Zero has neither in any mode. */
|
|
25
40
|
readonly signed?: boolean;
|
|
26
41
|
/** No space between the number and `%`, whatever the locale's pattern: `12,5%`, not `12,5 %`. */
|
|
27
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;
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
const MONTHS_PER_YEAR = 12;
|
|
31
51
|
const MONTH_PATTERN = /^(\d{4})-(\d{2})$/;
|
|
32
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
|
+
|
|
33
61
|
const BASIS_POINTS_PER_UNIT = 10_000;
|
|
34
62
|
const MAX_PERCENT_FRACTION_DIGITS = 2;
|
|
63
|
+
const FRACTION_DIGITS_LIMIT = 20;
|
|
35
64
|
|
|
36
65
|
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
37
66
|
const numberFormatters = new Map<string, Intl.NumberFormat>();
|
|
@@ -43,7 +72,7 @@ const DAY_STYLE_OPTIONS: Readonly<Record<CalendarDayStyle, Intl.DateTimeFormatOp
|
|
|
43
72
|
};
|
|
44
73
|
|
|
45
74
|
/** A `YYYY-MM-DD` day as `locale` writes it. Throws a `RangeError` for a malformed day. */
|
|
46
|
-
export function formatCalendarDay(day: string, locale:
|
|
75
|
+
export function formatCalendarDay(day: string, locale: string, style: CalendarDayStyle = "long"): string {
|
|
47
76
|
if (!isCalendarDay(day)) throw new RangeError(`formatCalendarDay: "${String(day)}" is not a YYYY-MM-DD calendar day`);
|
|
48
77
|
return getDayFormatter(`${locale}:${style}`, locale, DAY_STYLE_OPTIONS[style]).format(new Date(`${day}T00:00:00Z`));
|
|
49
78
|
}
|
|
@@ -61,28 +90,26 @@ export function isCalendarMonth(value: unknown): value is string {
|
|
|
61
90
|
* The month of a `YYYY-MM` month or a `YYYY-MM-DD` day, with its year, as `locale` writes it alone. Throws a
|
|
62
91
|
* `RangeError` for anything else.
|
|
63
92
|
*/
|
|
64
|
-
export function formatCalendarMonth(month: string, locale:
|
|
93
|
+
export function formatCalendarMonth(month: string, locale: string, style: CalendarMonthStyle = "long"): string {
|
|
65
94
|
const firstDay = isCalendarMonth(month) ? `${month}-01` : month;
|
|
66
95
|
if (!isCalendarDay(firstDay)) throw new RangeError(`formatCalendarMonth: "${String(month)}" is not a YYYY-MM month or a YYYY-MM-DD day`);
|
|
67
96
|
const date = new Date(`${firstDay}T00:00:00Z`);
|
|
68
97
|
const year = firstDay.slice(0, 4);
|
|
69
98
|
if (style === "roman") return `${ROMAN_MONTHS[date.getUTCMonth()] ?? ""} ${year}`;
|
|
70
|
-
|
|
71
|
-
// The genitive is the form a full date uses, so it is read from a day-month-year date's month part.
|
|
72
|
-
const parts = getDayFormatter(`${locale}:day-month`, locale, { day: "numeric", month: "long", year: "numeric" }).formatToParts(date);
|
|
73
|
-
const name = parts.find((part) => part.type === "month")?.value ?? "";
|
|
74
|
-
return `${name} ${year}`;
|
|
99
|
+
return getDayFormatter(`${locale}:month`, locale, { month: "long", year: "numeric" }).format(date);
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
/**
|
|
78
|
-
* Whole months as years and months
|
|
79
|
-
*
|
|
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.
|
|
80
106
|
*/
|
|
81
|
-
export function formatDuration(months: number,
|
|
107
|
+
export function formatDuration(months: number, options: FormatDurationOptions = {}): string {
|
|
82
108
|
if (!Number.isSafeInteger(months) || months < 0) {
|
|
83
109
|
throw new RangeError(`formatDuration: ${String(months)} is not a whole, non-negative number of months`);
|
|
84
110
|
}
|
|
85
|
-
const
|
|
111
|
+
const locale = options.locale ?? "en";
|
|
112
|
+
const copy = options.messages ?? DEFAULT_DURATION_MESSAGES;
|
|
86
113
|
const years = Math.floor(months / MONTHS_PER_YEAR);
|
|
87
114
|
const rest = months % MONTHS_PER_YEAR;
|
|
88
115
|
const yearsText = formatMessage(selectPlural(locale, years, copy.years), { count: years });
|
|
@@ -94,12 +121,12 @@ export function formatDuration(months: number, locale: Locale): string {
|
|
|
94
121
|
|
|
95
122
|
/**
|
|
96
123
|
* An amount in the currency's minor unit (2900 PLN = PLN 29.00) in `locale`'s notation, thousands always
|
|
97
|
-
* grouped (
|
|
124
|
+
* grouped (even where a locale's default would leave four digits together), and zero is never signed (`-0`
|
|
98
125
|
* and an amount that rounds to zero show as zero). The minor unit is the pinned ISO
|
|
99
126
|
* 4217 one (`CURRENCY_MINOR_UNIT_DIGITS`); a code outside the table gets `Intl`'s digits. Throws a
|
|
100
127
|
* `RangeError` for an amount that is not a whole number of minor units, or a code `Intl` refuses.
|
|
101
128
|
*/
|
|
102
|
-
export function formatMoney(minor: number, currency: string, locale:
|
|
129
|
+
export function formatMoney(minor: number, currency: string, locale: string, options: FormatMoneyOptions = {}): string {
|
|
103
130
|
if (!Number.isSafeInteger(minor)) throw new RangeError(`formatMoney: ${String(minor)} is not a whole number of minor units`);
|
|
104
131
|
const pinned = Object.hasOwn(CURRENCY_MINOR_UNIT_DIGITS, currency) ? CURRENCY_MINOR_UNIT_DIGITS[currency] : undefined;
|
|
105
132
|
const digits = pinned ?? getNumberFormatter(locale, { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
@@ -115,14 +142,15 @@ export function formatMoney(minor: number, currency: string, locale: Locale, opt
|
|
|
115
142
|
}
|
|
116
143
|
|
|
117
144
|
/**
|
|
118
|
-
* Basis points as a percentage in `locale`: 1250 is 12.5%, at most two fraction digits
|
|
119
|
-
* and a value that rounds to zero show as `0%`). Throws a
|
|
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`.
|
|
120
148
|
*/
|
|
121
|
-
export function formatPercent(basisPoints: number, locale:
|
|
149
|
+
export function formatPercent(basisPoints: number, locale: string, options: FormatPercentOptions = {}): string {
|
|
122
150
|
if (!Number.isFinite(basisPoints)) throw new RangeError(`formatPercent: ${String(basisPoints)} is not a finite number of basis points`);
|
|
123
151
|
const format = getNumberFormatter(locale, {
|
|
124
152
|
style: "percent",
|
|
125
|
-
|
|
153
|
+
...getPercentFractionDigits(options.fractionDigits),
|
|
126
154
|
signDisplay: options.signed === true ? "exceptZero" : "negative",
|
|
127
155
|
});
|
|
128
156
|
const value = basisPoints / BASIS_POINTS_PER_UNIT;
|
|
@@ -134,11 +162,19 @@ export function formatPercent(basisPoints: number, locale: Locale, options: Form
|
|
|
134
162
|
.join("");
|
|
135
163
|
}
|
|
136
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
|
+
|
|
137
173
|
function isNextToPercentSign(parts: readonly Intl.NumberFormatPart[], index: number): boolean {
|
|
138
174
|
return parts[index - 1]?.type === "percentSign" || parts[index + 1]?.type === "percentSign";
|
|
139
175
|
}
|
|
140
176
|
|
|
141
|
-
function getDayFormatter(key: string, locale:
|
|
177
|
+
function getDayFormatter(key: string, locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
142
178
|
let format = dayFormatters.get(key);
|
|
143
179
|
if (format === undefined) {
|
|
144
180
|
format = new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" });
|
|
@@ -147,7 +183,7 @@ function getDayFormatter(key: string, locale: Locale, options: Intl.DateTimeForm
|
|
|
147
183
|
return format;
|
|
148
184
|
}
|
|
149
185
|
|
|
150
|
-
function getNumberFormatter(locale:
|
|
186
|
+
function getNumberFormatter(locale: string, options: Intl.NumberFormatOptions): Intl.NumberFormat {
|
|
151
187
|
const key = `${locale}:${JSON.stringify(options)}`;
|
|
152
188
|
let format = numberFormatters.get(key);
|
|
153
189
|
if (format === undefined) {
|
package/src/http-json.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A JSON GET to a third-party API, with the details every app's own copy has to get right: a timeout, no redirect
|
|
2
|
+
// followed (a key in a header or the URL never reaches another host), and every failure returned as a code. A caught
|
|
3
|
+
// error's message is never read, since it can quote the URL and some APIs put the key there. The body stays
|
|
4
|
+
// `unknown` unless a zod schema narrows it.
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
import { err, ok, type Err, type Ok } from "./result.js";
|
|
7
|
+
|
|
8
|
+
export type HttpJsonFailureCode =
|
|
9
|
+
| "core.http_unreachable"
|
|
10
|
+
| "core.http_refused"
|
|
11
|
+
| "core.http_rate_limited"
|
|
12
|
+
| "core.http_unavailable"
|
|
13
|
+
| "core.http_unexpected_answer";
|
|
14
|
+
|
|
15
|
+
/** A failed `requestJson`: the code and the HTTP status (`null` when no response arrived). Nothing else. */
|
|
16
|
+
export type HttpJsonFailure = Err<HttpJsonFailureCode> & { readonly status: number | null };
|
|
17
|
+
|
|
18
|
+
export type FetchFunction = (input: string | URL, init: RequestInit) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
export interface RequestJsonOptions {
|
|
21
|
+
readonly url: string | URL;
|
|
22
|
+
/** Defaults to the global `fetch`; tests pass a fake. */
|
|
23
|
+
readonly fetch?: FetchFunction;
|
|
24
|
+
readonly headers?: HeadersInit;
|
|
25
|
+
/** Milliseconds before the request (body included) gives up. Defaults to 10 000. */
|
|
26
|
+
readonly timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RequestJsonSchemaOptions<S extends z.ZodType> extends RequestJsonOptions {
|
|
30
|
+
readonly schema: S;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* GET `url` and read its JSON body. Without a schema the value is `unknown`, for the caller to narrow; with one
|
|
37
|
+
* it is the schema's output. 3xx, 401 and 403 are refused, 429 rate limited, 5xx unavailable, and any other
|
|
38
|
+
* non-2xx status or a body that is not JSON or fails the schema an unexpected answer.
|
|
39
|
+
*/
|
|
40
|
+
export function requestJson<S extends z.ZodType>(
|
|
41
|
+
options: RequestJsonSchemaOptions<S>,
|
|
42
|
+
): Promise<Ok<z.output<S>> | HttpJsonFailure>;
|
|
43
|
+
export function requestJson(options: RequestJsonOptions): Promise<Ok<unknown> | HttpJsonFailure>;
|
|
44
|
+
export async function requestJson(
|
|
45
|
+
options: RequestJsonOptions & { readonly schema?: z.ZodType },
|
|
46
|
+
): Promise<Ok<unknown> | HttpJsonFailure> {
|
|
47
|
+
const fetchFunction = options.fetch ?? globalThis.fetch;
|
|
48
|
+
|
|
49
|
+
let response: Response;
|
|
50
|
+
try {
|
|
51
|
+
// Inside the try: an invalid header value throws a message that quotes it.
|
|
52
|
+
const headers = new Headers(options.headers);
|
|
53
|
+
if (!headers.has("accept")) {
|
|
54
|
+
headers.set("accept", "application/json");
|
|
55
|
+
}
|
|
56
|
+
response = await fetchFunction(options.url, {
|
|
57
|
+
method: "GET",
|
|
58
|
+
headers,
|
|
59
|
+
redirect: "manual",
|
|
60
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// Network error or timeout. The error's text can quote the URL, so it is dropped on purpose.
|
|
64
|
+
return httpFailure("core.http_unreachable", null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const statusFailure = getStatusFailure(response);
|
|
68
|
+
if (statusFailure !== null) {
|
|
69
|
+
// Release the connection; a body that cannot be cancelled changes nothing for the caller.
|
|
70
|
+
await response.body?.cancel().catch(() => undefined);
|
|
71
|
+
return statusFailure;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let text: string;
|
|
75
|
+
try {
|
|
76
|
+
text = await response.text();
|
|
77
|
+
} catch {
|
|
78
|
+
// The body broke off, usually the timeout firing mid-body.
|
|
79
|
+
return httpFailure("core.http_unreachable", response.status);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let body: unknown;
|
|
83
|
+
try {
|
|
84
|
+
body = JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
return httpFailure("core.http_unexpected_answer", response.status);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.schema === undefined) {
|
|
90
|
+
return ok(body);
|
|
91
|
+
}
|
|
92
|
+
// The schema's issues can quote the body's values, so only the code leaves.
|
|
93
|
+
const parsed = options.schema.safeParse(body);
|
|
94
|
+
return parsed.success ? ok(parsed.data) : httpFailure("core.http_unexpected_answer", response.status);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
|
|
99
|
+
* out, so optional parameters can be passed as they are.
|
|
100
|
+
*/
|
|
101
|
+
export function toQueryString(params: Readonly<Record<string, string | number | boolean | null | undefined>>): string {
|
|
102
|
+
const search = new URLSearchParams();
|
|
103
|
+
for (const [key, value] of Object.entries(params)) {
|
|
104
|
+
if (value !== null && value !== undefined) {
|
|
105
|
+
search.append(key, String(value));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return search.toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getStatusFailure(response: Response): HttpJsonFailure | null {
|
|
112
|
+
const { status } = response;
|
|
113
|
+
if (response.type === "opaqueredirect" || (status >= 300 && status < 400) || status === 401 || status === 403) {
|
|
114
|
+
return httpFailure("core.http_refused", status);
|
|
115
|
+
}
|
|
116
|
+
if (status === 429) {
|
|
117
|
+
return httpFailure("core.http_rate_limited", status);
|
|
118
|
+
}
|
|
119
|
+
if (status >= 500) {
|
|
120
|
+
return httpFailure("core.http_unavailable", status);
|
|
121
|
+
}
|
|
122
|
+
if (status < 200 || status >= 300) {
|
|
123
|
+
return httpFailure("core.http_unexpected_answer", status);
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function httpFailure(code: HttpJsonFailureCode, status: number | null): HttpJsonFailure {
|
|
129
|
+
return { ...err(code), status };
|
|
130
|
+
}
|
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
|
@@ -11,8 +11,17 @@ export {
|
|
|
11
11
|
wholeMonthsBetween,
|
|
12
12
|
type EndOfMonth,
|
|
13
13
|
} from "./calendar-day.js";
|
|
14
|
+
export {
|
|
15
|
+
calendarPeriodSchema,
|
|
16
|
+
countPeriodDays,
|
|
17
|
+
doPeriodsOverlap,
|
|
18
|
+
isDayInPeriod,
|
|
19
|
+
type CalendarPeriod,
|
|
20
|
+
} from "./calendar-period.js";
|
|
21
|
+
export { getLocalDay, isLocalDateTime, localDateTimeSchema, type LocalDateTime } from "./local-date-time.js";
|
|
14
22
|
export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
|
|
15
23
|
export {
|
|
24
|
+
DEFAULT_DURATION_MESSAGES,
|
|
16
25
|
formatCalendarDay,
|
|
17
26
|
formatCalendarMonth,
|
|
18
27
|
formatDuration,
|
|
@@ -21,21 +30,35 @@ export {
|
|
|
21
30
|
isCalendarMonth,
|
|
22
31
|
type CalendarDayStyle,
|
|
23
32
|
type CalendarMonthStyle,
|
|
33
|
+
type DurationMessages,
|
|
34
|
+
type FormatDurationOptions,
|
|
24
35
|
type FormatMoneyOptions,
|
|
25
36
|
type FormatPercentOptions,
|
|
26
37
|
} from "./format.js";
|
|
38
|
+
export {
|
|
39
|
+
requestJson,
|
|
40
|
+
toQueryString,
|
|
41
|
+
type FetchFunction,
|
|
42
|
+
type HttpJsonFailure,
|
|
43
|
+
type HttpJsonFailureCode,
|
|
44
|
+
type RequestJsonOptions,
|
|
45
|
+
type RequestJsonSchemaOptions,
|
|
46
|
+
} from "./http-json.js";
|
|
27
47
|
export { memoizeByContent, type MemoizeByContentOptions, type MemoizedFunction } from "./memoize.js";
|
|
28
48
|
export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
|
|
29
49
|
export {
|
|
30
50
|
formatMessage,
|
|
31
51
|
getMessage,
|
|
52
|
+
DEFAULT_LOCALE,
|
|
32
53
|
isLocale,
|
|
33
|
-
LOCALES,
|
|
34
54
|
mergeMessages,
|
|
55
|
+
pickLocalizedText,
|
|
56
|
+
selectMessages,
|
|
35
57
|
selectPlural,
|
|
36
58
|
type DeepPartial,
|
|
37
59
|
type Dictionaries,
|
|
38
60
|
type Locale,
|
|
61
|
+
type LocalizedText,
|
|
39
62
|
type MessageOverrides,
|
|
40
63
|
type MessageTree,
|
|
41
64
|
type PluralForms,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A local wall-clock date-time, `YYYY-MM-DDTHH:mm` with no time zone: a time that is local at a place
|
|
2
|
+
// (a departure, a booking slot), independent of the server's zone. Zero-padded, so it sorts as text.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { isCalendarDay } from "./calendar-day.js";
|
|
5
|
+
|
|
6
|
+
const LOCAL_DATE_TIME_PATTERN = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d$/;
|
|
7
|
+
|
|
8
|
+
/** A string checked by `isLocalDateTime` or `localDateTimeSchema`; a plain string is not one. */
|
|
9
|
+
export type LocalDateTime = string & z.$brand<"LocalDateTime">;
|
|
10
|
+
|
|
11
|
+
/** Whether `value` is a `YYYY-MM-DDTHH:mm` local date-time on a real day (`2026-02-30T10:00` and `…T24:00` are not). */
|
|
12
|
+
export function isLocalDateTime(value: unknown): value is LocalDateTime {
|
|
13
|
+
if (typeof value !== "string") return false;
|
|
14
|
+
const match = LOCAL_DATE_TIME_PATTERN.exec(value);
|
|
15
|
+
return match !== null && isCalendarDay(match[1]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A `YYYY-MM-DDTHH:mm` local date-time in tool and form input, parsed into the branded `LocalDateTime`. */
|
|
19
|
+
export const localDateTimeSchema = z
|
|
20
|
+
.string()
|
|
21
|
+
.refine(isLocalDateTime, "must be a YYYY-MM-DDTHH:mm local date-time on a real day, e.g. 2026-10-04T09:30")
|
|
22
|
+
.brand<"LocalDateTime">();
|
|
23
|
+
|
|
24
|
+
/** The calendar day (`YYYY-MM-DD`) of a local date-time. Throws a `RangeError` for a malformed value. */
|
|
25
|
+
export function getLocalDay(value: LocalDateTime): string {
|
|
26
|
+
if (!isLocalDateTime(value)) {
|
|
27
|
+
throw new RangeError(`getLocalDay: "${String(value)}" is not a YYYY-MM-DDTHH:mm local date-time`);
|
|
28
|
+
}
|
|
29
|
+
return value.slice(0, "YYYY-MM-DD".length);
|
|
30
|
+
}
|
package/src/messages/en.ts
CHANGED
|
@@ -2,10 +2,10 @@ export const en = {
|
|
|
2
2
|
errors: {
|
|
3
3
|
database_failed: "Something went wrong while saving or loading data. Please try again.",
|
|
4
4
|
unexpected: "Something went wrong. Please try again.",
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
http_unreachable: "The service could not be reached. Please try again later.",
|
|
6
|
+
http_refused: "The service refused the request.",
|
|
7
|
+
http_rate_limited: "The service is receiving too many requests. Please try again later.",
|
|
8
|
+
http_unavailable: "The service is unavailable right now. Please try again later.",
|
|
9
|
+
http_unexpected_answer: "The service sent an answer that could not be read.",
|
|
10
10
|
},
|
|
11
11
|
};
|
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
|
-
/** Copy for core's own error codes (`core.<key>` → `errors.<key>`)
|
|
8
|
-
export const coreMessages: Dictionaries<CoreMessages> = { en
|
|
6
|
+
/** Copy for core's own error codes (`core.<key>` → `errors.<key>`). */
|
|
7
|
+
export const coreMessages: Dictionaries<CoreMessages> = { en };
|
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,EAUvB,CAAC"}
|
package/dist/messages/pl.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
export const pl = {
|
|
2
|
-
errors: {
|
|
3
|
-
database_failed: "Coś poszło nie tak przy zapisie lub odczycie danych. Spróbuj ponownie.",
|
|
4
|
-
unexpected: "Coś poszło nie tak. Spróbuj ponownie.",
|
|
5
|
-
},
|
|
6
|
-
duration: {
|
|
7
|
-
years: { one: "{count} rok", few: "{count} lata", many: "{count} lat", other: "{count} roku" },
|
|
8
|
-
months: { one: "{count} miesiąc", few: "{count} miesiące", many: "{count} miesięcy", other: "{count} miesiąca" },
|
|
9
|
-
both: "{years} i {months}",
|
|
10
|
-
},
|
|
11
|
-
};
|
|
12
|
-
//# sourceMappingURL=pl.js.map
|
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;IACD,QAAQ,EAAE;QACR,KAAK,EAAE,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,cAAc,EAAE;QAC9F,MAAM,EAAE,EAAE,GAAG,EAAE,iBAAiB,EAAE,GAAG,EAAE,kBAAkB,EAAE,IAAI,EAAE,kBAAkB,EAAE,KAAK,EAAE,kBAAkB,EAAE;QAChH,IAAI,EAAE,oBAAoB;KAC3B;CACF,CAAC"}
|
package/src/messages/pl.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { en } from "./en.js";
|
|
2
|
-
|
|
3
|
-
export const pl: typeof en = {
|
|
4
|
-
errors: {
|
|
5
|
-
database_failed: "Coś poszło nie tak przy zapisie lub odczycie danych. Spróbuj ponownie.",
|
|
6
|
-
unexpected: "Coś poszło nie tak. Spróbuj ponownie.",
|
|
7
|
-
},
|
|
8
|
-
duration: {
|
|
9
|
-
years: { one: "{count} rok", few: "{count} lata", many: "{count} lat", other: "{count} roku" },
|
|
10
|
-
months: { one: "{count} miesiąc", few: "{count} miesiące", many: "{count} miesięcy", other: "{count} miesiąca" },
|
|
11
|
-
both: "{years} i {months}",
|
|
12
|
-
},
|
|
13
|
-
};
|