@mccaskie-labs/workbench-strings 0.0.0-stage → 0.1.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/dist/detect.js ADDED
@@ -0,0 +1,108 @@
1
+ // Choosing the language for a request (internationalization standard §7).
2
+ //
3
+ // URL prefix -> cookie -> account -> country -> Accept-Language -> default
4
+ //
5
+ // Country outranks Accept-Language on purpose: in the Philippines and most
6
+ // markets like it, a phone reports English whatever its owner reads, so the
7
+ // header is evidence about the phone and the country is evidence about the
8
+ // person (§7.2). The header stays, below country, for someone abroad who set
9
+ // their home language deliberately.
10
+ //
11
+ // The review gate (§10.4): the prefix, the cookie and the account are choices
12
+ // the person made, and are always honoured. Everything below them is inferred,
13
+ // and an inferred language that no named reviewer has signed off resolves to
14
+ // the base instead. Accept-Language counts as inferred: a browser language set
15
+ // years ago is not a choice made about this app. To change that, move "header"
16
+ // out of INFERRED below; it is the one line.
17
+ const INFERRED = new Set(["country", "header", "default"]);
18
+ /** The edge's "country unknown" answers: XX (unknown), T1 (Tor). They fall through to the header. */
19
+ const UNKNOWN_COUNTRIES = new Set(["XX", "T1"]);
20
+ function live(rules, value) {
21
+ return value != null && rules.locales.includes(value) ? value : undefined;
22
+ }
23
+ /** Accept-Language tags, best first by q; q=0 and `*` dropped, ties keep their order. */
24
+ export function parseAcceptLanguage(header) {
25
+ if (!header)
26
+ return [];
27
+ return header
28
+ .split(",")
29
+ .map((part, i) => {
30
+ const [tag = "", ...params] = part.trim().split(";");
31
+ const q = params.map((p) => /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p)).find(Boolean);
32
+ const weight = q ? Math.min(1, Number(q[1])) : 1;
33
+ return { tag: tag.trim(), weight: Number.isFinite(weight) ? weight : 0, i };
34
+ })
35
+ .filter((t) => t.tag && t.tag !== "*" && t.weight > 0)
36
+ .sort((a, b) => b.weight - a.weight || a.i - b.i)
37
+ .map((t) => t.tag);
38
+ }
39
+ /** The best live locale for an Accept-Language header: exact tag, then alias, then a language only one locale has. */
40
+ export function matchAcceptLanguage(rules, header) {
41
+ const aliases = new Map(Object.entries(rules.aliases).map(([k, v]) => [k.toLowerCase(), v]));
42
+ for (const tag of parseAcceptLanguage(header)) {
43
+ const lower = tag.toLowerCase();
44
+ const exact = rules.locales.find((l) => l.toLowerCase() === lower);
45
+ if (exact)
46
+ return exact;
47
+ const language = lower.split("-")[0];
48
+ const alias = aliases.get(lower) ?? aliases.get(language);
49
+ if (alias && rules.locales.includes(alias))
50
+ return alias;
51
+ const same = rules.locales.filter((l) => l.toLowerCase().split("-")[0] === language);
52
+ if (same.length === 1)
53
+ return same[0];
54
+ }
55
+ return undefined;
56
+ }
57
+ /** Resolves the locale for one request. */
58
+ export function resolveLocale(signals, rules) {
59
+ const chosen = (live(rules, signals.prefix) && { locale: signals.prefix, source: "prefix" }) ||
60
+ (live(rules, signals.cookie) && { locale: signals.cookie, source: "cookie" }) ||
61
+ (live(rules, signals.account) && { locale: signals.account, source: "account" });
62
+ if (chosen)
63
+ return { ...chosen, gated: false };
64
+ const inferred = infer(signals, rules);
65
+ if (INFERRED.has(inferred.source) && inferred.locale !== rules.base && !rules.reviewed(inferred.locale)) {
66
+ return { locale: rules.base, source: inferred.source, gated: true };
67
+ }
68
+ return { ...inferred, gated: false };
69
+ }
70
+ function infer(signals, rules) {
71
+ const country = signals.country?.trim().toUpperCase();
72
+ const known = country !== undefined && /^[A-Z]{2}$/.test(country) && !UNKNOWN_COUNTRIES.has(country);
73
+ if (known) {
74
+ const local = rules.countries[country];
75
+ if (local && rules.locales.includes(local))
76
+ return { locale: local, source: "country" };
77
+ }
78
+ const fromHeader = matchAcceptLanguage(rules, signals.acceptLanguage);
79
+ if (fromHeader)
80
+ return { locale: fromHeader, source: "header" };
81
+ // Someone we know is elsewhere gets the base; the default is for the truly
82
+ // unknown visitor, including local development where there is no country.
83
+ return { locale: known ? rules.base : rules.defaultLocale, source: "default" };
84
+ }
85
+ /** The cookie that remembers a choice. `__Host-` makes the browser refuse it with a Domain or without Secure. */
86
+ export const LOCALE_COOKIE = "__Host-locale";
87
+ const YEAR = 60 * 60 * 24 * 365;
88
+ /** A Set-Cookie value remembering `locale` for a year. Lax, so a visitor arriving from a link keeps their language. */
89
+ export function localeCookie(locale) {
90
+ return `${LOCALE_COOKIE}=${encodeURIComponent(locale)}; Path=/; Max-Age=${YEAR}; Secure; SameSite=Lax`;
91
+ }
92
+ /** The locale cookie's value from a Cookie header, if any. */
93
+ export function readLocaleCookie(header) {
94
+ if (!header)
95
+ return undefined;
96
+ for (const part of header.split(";")) {
97
+ const eq = part.indexOf("=");
98
+ if (eq === -1 || part.slice(0, eq).trim() !== LOCALE_COOKIE)
99
+ continue;
100
+ try {
101
+ return decodeURIComponent(part.slice(eq + 1).trim());
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
@@ -0,0 +1,5 @@
1
+ export type StringsErrorCode = "invalid_options" | "invalid_catalogue" | "invalid_string" | "missing_string" | "missing_value" | "no_reviewer";
2
+ export declare class WorkbenchStringsError extends Error {
3
+ readonly code: StringsErrorCode;
4
+ constructor(code: StringsErrorCode, message: string);
5
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,10 @@
1
+ // Every failure the package raises is a WorkbenchStringsError with a stable
2
+ // code. Callers branch on `code`, never on the message.
3
+ export class WorkbenchStringsError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.name = "WorkbenchStringsError";
8
+ this.code = code;
9
+ }
10
+ }
@@ -0,0 +1,23 @@
1
+ export type Values = Readonly<Record<string, string | number | null | undefined>>;
2
+ type Inner = string | {
3
+ v: string;
4
+ } | {
5
+ count: true;
6
+ };
7
+ type Part = string | {
8
+ v: string;
9
+ } | {
10
+ plural: string;
11
+ arms: ReadonlyMap<string, readonly Inner[]>;
12
+ };
13
+ export type Message = readonly Part[];
14
+ /** Parses one string. Throws invalid_string with the reason. */
15
+ export declare function parseMessage(src: string): Message;
16
+ /**
17
+ * The placeholders a string uses, sorted, with a plural marked: "count#plural".
18
+ * Every language must use the same set as the base (§9.1).
19
+ */
20
+ export declare function placeholdersOf(message: Message): string[];
21
+ /** Fills a parsed string for one locale. */
22
+ export declare function formatMessage(message: Message, locale: string, values?: Values): string;
23
+ export {};
package/dist/format.js ADDED
@@ -0,0 +1,215 @@
1
+ // The message format: a deliberately small subset of ICU (internationalization
2
+ // standard §9). Named placeholders, `{name}`, and one level of plural:
3
+ //
4
+ // "{count, plural, =0 {No rooms} one {# room} other {# rooms}}"
5
+ //
6
+ // No select, no selectordinal, no nesting, no escaping. Widen it when a string
7
+ // needs it, not in advance. It lives in this one module so that if it is ever
8
+ // replaced, the keys, the files, the chain and every call site survive.
9
+ //
10
+ // - A missing value leaves `{name}` visible: a visible placeholder is a bug
11
+ // report, a silently empty sentence is a bug that ships.
12
+ // - The plural arm is chosen by `Intl.PluralRules` for the real locale, never
13
+ // `n === 1`, with exact arms (`=0`) checked first; `other` is required.
14
+ // - `#` renders through `Intl.NumberFormat` (2,000). A `{name}` value renders
15
+ // as given, so a year or an order number is never grouped: format amounts
16
+ // and dates before passing them (workbench-country does it per country).
17
+ import { WorkbenchStringsError } from "./errors.js";
18
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
19
+ const SELECTOR = /^(=\d+|zero|one|two|few|many|other)$/;
20
+ function fail(src, why) {
21
+ throw new WorkbenchStringsError("invalid_string", `${why} in "${src}"`);
22
+ }
23
+ /** Parses one string. Throws invalid_string with the reason. */
24
+ export function parseMessage(src) {
25
+ const parts = [];
26
+ let text = "";
27
+ let i = 0;
28
+ while (i < src.length) {
29
+ const c = src[i];
30
+ if (c === "}")
31
+ fail(src, "an unmatched }");
32
+ if (c !== "{") {
33
+ text += c;
34
+ i++;
35
+ continue;
36
+ }
37
+ if (text)
38
+ parts.push(text);
39
+ text = "";
40
+ const close = src.indexOf("}", i);
41
+ const comma = src.indexOf(",", i);
42
+ if (close === -1)
43
+ fail(src, "an unclosed {");
44
+ if (comma === -1 || comma > close) {
45
+ const name = src.slice(i + 1, close).trim();
46
+ if (!NAME.test(name))
47
+ fail(src, `a bad placeholder name "{${name}}"`);
48
+ parts.push({ v: name });
49
+ i = close + 1;
50
+ continue;
51
+ }
52
+ const name = src.slice(i + 1, comma).trim();
53
+ if (!NAME.test(name))
54
+ fail(src, `a bad placeholder name "${name}"`);
55
+ const kindEnd = src.indexOf(",", comma + 1);
56
+ if (kindEnd === -1 || src.slice(comma + 1, kindEnd).trim() !== "plural") {
57
+ fail(src, `"{${name}, ...}" that is not a plural (only plural is supported)`);
58
+ }
59
+ i = kindEnd + 1;
60
+ const arms = new Map();
61
+ for (;;) {
62
+ while (i < src.length && /\s/.test(src[i]))
63
+ i++;
64
+ if (i >= src.length)
65
+ fail(src, "an unclosed plural");
66
+ if (src[i] === "}") {
67
+ i++;
68
+ break;
69
+ }
70
+ const open = src.indexOf("{", i);
71
+ if (open === -1)
72
+ fail(src, "a plural arm with no text");
73
+ const selector = src.slice(i, open).trim();
74
+ if (!SELECTOR.test(selector))
75
+ fail(src, `a bad plural arm "${selector}"`);
76
+ if (arms.has(selector))
77
+ fail(src, `the plural arm "${selector}" twice`);
78
+ const end = src.indexOf("}", open + 1);
79
+ if (end === -1)
80
+ fail(src, "an unclosed plural arm");
81
+ // An arm may hold {name} placeholders: walk to the brace that closes it.
82
+ const inner = [];
83
+ let j = open + 1;
84
+ let armText = "";
85
+ for (;;) {
86
+ if (j >= src.length)
87
+ fail(src, "an unclosed plural arm");
88
+ const d = src[j];
89
+ if (d === "}")
90
+ break;
91
+ if (d === "{") {
92
+ const k = src.indexOf("}", j);
93
+ if (k === -1)
94
+ fail(src, "an unclosed {");
95
+ const v = src.slice(j + 1, k).trim();
96
+ if (!NAME.test(v))
97
+ fail(src, `a plural inside a plural, or a bad placeholder "{${v}}"`);
98
+ if (armText)
99
+ inner.push(armText);
100
+ armText = "";
101
+ inner.push({ v });
102
+ j = k + 1;
103
+ continue;
104
+ }
105
+ if (d === "#") {
106
+ if (armText)
107
+ inner.push(armText);
108
+ armText = "";
109
+ inner.push({ count: true });
110
+ }
111
+ else {
112
+ armText += d;
113
+ }
114
+ j++;
115
+ }
116
+ if (armText)
117
+ inner.push(armText);
118
+ arms.set(selector, inner);
119
+ i = j + 1;
120
+ }
121
+ if (!arms.has("other"))
122
+ fail(src, `the plural on "${name}" with no "other" arm`);
123
+ parts.push({ plural: name, arms });
124
+ }
125
+ if (text)
126
+ parts.push(text);
127
+ return parts;
128
+ }
129
+ /**
130
+ * The placeholders a string uses, sorted, with a plural marked: "count#plural".
131
+ * Every language must use the same set as the base (§9.1).
132
+ */
133
+ export function placeholdersOf(message) {
134
+ const out = new Set();
135
+ for (const p of message) {
136
+ if (typeof p === "string")
137
+ continue;
138
+ if ("v" in p) {
139
+ out.add(p.v);
140
+ continue;
141
+ }
142
+ out.add(`${p.plural}#plural`);
143
+ for (const arm of p.arms.values())
144
+ for (const q of arm)
145
+ if (typeof q !== "string" && "v" in q)
146
+ out.add(q.v);
147
+ }
148
+ return [...out].sort();
149
+ }
150
+ const numberFormats = new Map();
151
+ const pluralRules = new Map();
152
+ /**
153
+ * A tag Intl will take: the locale itself, else its language, else the
154
+ * runtime default. A tag Intl refuses never throws here (§11, "unknown locale
155
+ * survives"). Intl only picks plural arms and formats numbers; it never
156
+ * supplies words (§9.4).
157
+ */
158
+ function intlLocale(locale) {
159
+ const tries = [locale, locale.split("-")[0]];
160
+ for (const t of tries) {
161
+ try {
162
+ if (Intl.PluralRules.supportedLocalesOf([t]).length)
163
+ return t;
164
+ }
165
+ catch {
166
+ // RangeError for a malformed tag: try the next.
167
+ }
168
+ }
169
+ return undefined;
170
+ }
171
+ function numberFormat(locale) {
172
+ let f = numberFormats.get(locale);
173
+ if (!f)
174
+ numberFormats.set(locale, (f = new Intl.NumberFormat(intlLocale(locale))));
175
+ return f;
176
+ }
177
+ function pluralRule(locale) {
178
+ let r = pluralRules.get(locale);
179
+ if (!r)
180
+ pluralRules.set(locale, (r = new Intl.PluralRules(intlLocale(locale))));
181
+ return r;
182
+ }
183
+ function value(name, values) {
184
+ const v = values[name];
185
+ if (v === undefined || v === null)
186
+ return `{${name}}`;
187
+ return String(v);
188
+ }
189
+ /** Fills a parsed string for one locale. */
190
+ export function formatMessage(message, locale, values = {}) {
191
+ let out = "";
192
+ for (const p of message) {
193
+ if (typeof p === "string")
194
+ out += p;
195
+ else if ("v" in p)
196
+ out += value(p.v, values);
197
+ else {
198
+ const n = values[p.plural];
199
+ if (typeof n !== "number" || !Number.isFinite(n)) {
200
+ out += `{${p.plural}}`;
201
+ continue;
202
+ }
203
+ const arm = p.arms.get(`=${n}`) ?? p.arms.get(pluralRule(locale).select(n)) ?? p.arms.get("other");
204
+ for (const q of arm) {
205
+ if (typeof q === "string")
206
+ out += q;
207
+ else if ("v" in q)
208
+ out += value(q.v, values);
209
+ else
210
+ out += numberFormat(locale).format(n);
211
+ }
212
+ }
213
+ }
214
+ return out;
215
+ }
@@ -0,0 +1,6 @@
1
+ export { defineStrings, NAMESPACES, type EmailName, type EmailPart, type MissingString, type StringKey, type Strings, type StringsOptions } from "./strings.ts";
2
+ export { LOCALE_COOKIE, localeCookie, matchAcceptLanguage, parseAcceptLanguage, readLocaleCookie, resolveLocale, type Detection, type DetectionSource, type LocaleRules, type LocaleSignals, } from "./detect.ts";
3
+ export { readCatalogue, readReview, stripGlosses, type Catalogue, type Review } from "./catalogue.ts";
4
+ export { formatMessage, parseMessage, placeholdersOf, type Message, type Values } from "./format.ts";
5
+ export { WORKBENCH_LANGUAGES, type WorkbenchLanguage } from "./languages.ts";
6
+ export { WorkbenchStringsError, type StringsErrorCode } from "./errors.ts";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @mccaskie-labs/workbench-strings: the words an app's own screens and emails
2
+ // say. SERVER ONLY: the catalogue holds every language, so importing it into
3
+ // browser code would ship them all to every visitor. Look words up on the
4
+ // server and pass the finished words to the page.
5
+ export { defineStrings, NAMESPACES } from "./strings.js";
6
+ export { LOCALE_COOKIE, localeCookie, matchAcceptLanguage, parseAcceptLanguage, readLocaleCookie, resolveLocale, } from "./detect.js";
7
+ export { readCatalogue, readReview, stripGlosses } from "./catalogue.js";
8
+ export { formatMessage, parseMessage, placeholdersOf } from "./format.js";
9
+ export { WORKBENCH_LANGUAGES } from "./languages.js";
10
+ export { WorkbenchStringsError } from "./errors.js";
@@ -0,0 +1,4 @@
1
+ import { capabilities } from "@mccaskie-labs/workbench-config";
2
+ export type WorkbenchLanguage = (typeof capabilities.ai.allowed.spellings)[number];
3
+ /** Every language an app may serve, as BCP 47 tags. */
4
+ export declare const WORKBENCH_LANGUAGES: readonly WorkbenchLanguage[];
@@ -0,0 +1,7 @@
1
+ // The one Workbench language list. It is the spelling list the Gateway's
2
+ // voices already use (docs/spec.md §2.8, capabilities/ai.json `spellings`), so
3
+ // an app's screens and its AI writing name languages the same way. A new
4
+ // language is added to that manifest first.
5
+ import { capabilities } from "@mccaskie-labs/workbench-config";
6
+ /** Every language an app may serve, as BCP 47 tags. */
7
+ export const WORKBENCH_LANGUAGES = [...capabilities.ai.allowed.spellings];
@@ -0,0 +1,96 @@
1
+ import type { WorkbenchEnv } from "@mccaskie-labs/workbench-config";
2
+ import { type Catalogue, type Review } from "./catalogue.ts";
3
+ import { type Detection, type LocaleRules } from "./detect.ts";
4
+ import { type Values } from "./format.ts";
5
+ /** The name groups a key must start with (§5.3). A new class of string adds a group here, by PR. */
6
+ export declare const NAMESPACES: readonly ["ui", "enum", "question", "validation", "email", "notification", "page", "error", "meta"];
7
+ /** A key of the base catalogue that holds words (not a `_` marker). */
8
+ export type StringKey<B> = Exclude<Extract<keyof B, string>, `_${string}`>;
9
+ /** The email names among a catalogue's keys: `email.<name>.<part>`. */
10
+ export type EmailName<K extends string> = string extends K ? string : K extends `email.${infer N}.${string}` ? N : never;
11
+ /** The parts of one email. */
12
+ export type EmailPart<K extends string, N extends string> = string extends K ? string : K extends `email.${N}.${infer P}` ? P : never;
13
+ /** A missing word: logged with the locale asked for and the one it came from. */
14
+ export interface MissingString {
15
+ key: string;
16
+ requested: string;
17
+ /** Where the word was found instead, or null when it is missing everywhere. */
18
+ resolved: string | null;
19
+ }
20
+ export interface StringsOptions<B extends Catalogue> {
21
+ /** WORKBENCH_ENV. In production a translated language needs a named reviewer. */
22
+ env: WorkbenchEnv;
23
+ /** The base locale and its catalogue: the language the words are written in, complete, with no glosses. */
24
+ base: {
25
+ locale: string;
26
+ strings: B;
27
+ };
28
+ /** Every other live locale's GENERATED catalogue (strings/runtime/), never the authored one. */
29
+ translations?: Readonly<Record<string, Catalogue>>;
30
+ /** The live locales in the order the switcher shows them, the base included. Each must be a Workbench language. */
31
+ locales: readonly string[];
32
+ /** What an unknown visitor sees. Defaults to the base. Often not the base: it is the audience's language. */
33
+ defaultLocale?: string;
34
+ /** Locales planned but not live. Declared here, absent from `locales`, with no catalogue. */
35
+ planned?: readonly string[];
36
+ /** A locale's parent, e.g. a register's parent language. Every chain ends at the base. */
37
+ parents?: Readonly<Record<string, string>>;
38
+ /** Country code to locale. Defaults to each live locale's region when only one locale has it (en-PH: PH). */
39
+ countries?: Readonly<Record<string, string>>;
40
+ /** Extra Accept-Language tags to locale, such as an old tag some phones still send. */
41
+ aliases?: Readonly<Record<string, string>>;
42
+ /** URL prefixes. Defaults to the lower-case tag (en-ph). Every locale has one; none owns the bare tree. */
43
+ prefixes?: Readonly<Record<string, string>>;
44
+ /** Where missing words are reported. Defaults to one JSON line on console.warn per key and locale. */
45
+ onMissing?: (missing: MissingString) => void;
46
+ }
47
+ export interface Strings<K extends string = string> {
48
+ readonly base: string;
49
+ readonly locales: readonly string[];
50
+ readonly defaultLocale: string;
51
+ readonly planned: readonly string[];
52
+ /** Every key with words in the base. */
53
+ readonly keys: readonly K[];
54
+ /** The rules detect() uses, the review gate included. */
55
+ readonly rules: LocaleRules;
56
+ /** Each live locale's URL prefix. */
57
+ readonly prefixes: Readonly<Record<string, string>>;
58
+ /** The parents passed in, for the test kit. */
59
+ readonly parents: Readonly<Record<string, string>>;
60
+ /** A live locale's catalogue as defineStrings was given it, for the test kit. */
61
+ catalogue(locale: string): Catalogue;
62
+ /** The fallback chain for a locale: itself, its parents, then the base. */
63
+ chain(locale: string): readonly string[];
64
+ /** A live locale's review markers. */
65
+ review(locale: string): Review;
66
+ /** True for the base, or a locale whose catalogue, and every translated one on its chain, has `_review.signed_off`. */
67
+ reviewed(locale: string): boolean;
68
+ /** True when the base has words for `key`. For keys built at run time, such as workbench-forms' validation codes. */
69
+ has(key: string): key is K;
70
+ /** The words for a key. A word missing in `locale` comes from the next locale on its chain and is logged. */
71
+ t(locale: string, key: K, values?: Values): string;
72
+ /** Several keys at once, to pass to a page as finished words. Use t() for a string with placeholders. */
73
+ pick<P extends K>(locale: string, keys: readonly P[]): Record<P, string>;
74
+ /**
75
+ * An email's words, `email.<name>.<part>`, in the recipient's saved
76
+ * language (pass it through recipientLocale). Missing in that language and
77
+ * in the base, it throws missing_string, and a value it needs that is not
78
+ * passed throws missing_value: an email has nobody watching it fail.
79
+ */
80
+ email<N extends EmailName<K>>(locale: string, name: N, part: EmailPart<K, N>, values?: Values): string;
81
+ /** A saved language as a live locale; anything else is the base. For emails sent with nobody present. */
82
+ recipientLocale(saved: string | null | undefined): string;
83
+ /** The language for a request, from its URL prefix, cookie, the account's saved language, country and header. */
84
+ detect(request: Request, options?: {
85
+ account?: string | null;
86
+ }): Detection;
87
+ /** The locale a path's prefix names, and the rest of the path. Null when the path has no prefix. */
88
+ localeFromPath(pathname: string): {
89
+ locale: string;
90
+ rest: string;
91
+ } | null;
92
+ /** A Set-Cookie value that remembers a chosen locale for a year. */
93
+ cookie(locale: string): string;
94
+ }
95
+ /** Sets up an app's strings. Throws WorkbenchStringsError at start when a rule is broken. */
96
+ export declare function defineStrings<B extends Catalogue>(options: StringsOptions<B>): Strings<StringKey<B>>;