@pulsetechnologies/i18n 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/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @pulsetechnologies/i18n
2
+
3
+ The shared language layer for every Pulse product (PulseWork epic **ENG-1**, this is **ENG-2**).
4
+
5
+ - **18 languages**: `en es zh-Hans zh-Hant hi ar fr pt-BR ru bn ja de id ur vi ko fil ht` (Arabic and Urdu are right-to-left).
6
+ - **Choosing the language**: `pickLocale({ user, cookie, workspace, browser })` goes in that order, then falls back to English. The `pulse_lang` cookie covers all of `.pulsetechnologies.ai`, so a choice made in one product carries over to the others.
7
+ - **Matching**: `matchLocale` maps real-world tags to the languages we offer: `zh-TW` → `zh-Hant`, `pt-PT` → `pt-BR`, `es-MX` → `es`, `tl` → `fil`.
8
+ - **Formatting**: `createFormatters(locale, { timeZone })` covers numbers, percent, `money(minor, currency)` for Pulse's minor-unit amounts, dates, times, relative times and lists. Haitian Creole borrows French formats because CLDR has no data for it.
9
+ - **i18next**: `createI18n({ resources, locale })` sets up English fallback, no catalog bleed between `zh-Hans` and `zh-Hant`, React-safe interpolation, and an optional `en-XA` pseudo-locale for catching text that was never extracted.
10
+ - **Catalog check**: `missingKeys(en, target, locale)` is plural-aware per language. Arabic needs six plural forms, Japanese one, and Spanish needs `_many`. Without it, Spanish users see English for counts in the millions. ENG-4 runs it in CI.
11
+ - **React**: `<LanguagePicker persist={saveToProfile} />` is an accessible native `<select>` that shows each language in its own script. It keeps `<html lang dir>` in step, writes the cookie and calls `persist` (the ENG-3 profile save). `<LanguageSelect>` and `useLanguage()` are the lower-level pieces.
12
+
13
+ ```tsx
14
+ import { createI18n, pickLocale } from "@pulsetechnologies/i18n";
15
+ import { LanguagePicker } from "@pulsetechnologies/i18n/react";
16
+ import { I18nextProvider, initReactI18next } from "react-i18next";
17
+
18
+ const { locale } = pickLocale({ user: me?.language, cookie: document.cookie, workspace: org.language, browser: navigator.languages });
19
+ const i18n = await createI18n({ resources, locale, use: [initReactI18next], pseudo: import.meta.env.DEV });
20
+ // <I18nextProvider i18n={i18n}> … <LanguagePicker persist={(c) => api.patch("/me", { language: c })} />
21
+ ```
22
+
23
+ The core entry has no DOM or React dependency, so it runs on servers (for emails and invoices) and in React Native. The React entry is for the web; React Native gets its own picker in ENG-19.
24
+
25
+ ## Develop
26
+
27
+ ```bash
28
+ npm ci
29
+ npm test # vitest (the picker tests use jsdom)
30
+ npm run demo # http://localhost:5480, a sample invoice in 5 languages + pseudo
31
+ ```
32
+
33
+ ## Release
34
+
35
+ The package is published publicly to npm as `@pulsetechnologies/i18n`. To release:
36
+
37
+ 1. Bump `version` in `package.json` and merge.
38
+ 2. Publish a GitHub release tagged `v<version>`.
39
+
40
+ The Publish workflow checks that the tag matches `package.json`, runs typecheck, tests and build (`prepublishOnly`), and publishes through **npm trusted publishing (OIDC)**, so no npm token is stored anywhere. npm is retiring token publishing (2FA-bypass tokens stop publishing directly around January 2027).
41
+
42
+ The very first version (0.1.0) is published by hand, because npm only lets a trusted publisher be configured on a package that already exists. After that, set up npmjs.com → the package → Settings → Trusted publishing → GitHub Actions: `pulsetechnologies-ai` / `pulse-i18n` / `publish.yml`. Then set Publishing access to "Require two-factor authentication and disallow tokens".
@@ -0,0 +1,20 @@
1
+ /** Nested catalog → flat "a.b.c" keys holding strings. */
2
+ export declare function flattenCatalog(catalog: unknown, prefix?: string): Map<string, string>;
3
+ /**
4
+ * The plural forms a language needs: Arabic has six, Japanese one, English
5
+ * two, Spanish three ("many" is for millions). Built the way i18next builds
6
+ * its own rules, from the bare code, so the check asks for exactly the keys
7
+ * i18next will look up.
8
+ */
9
+ export declare const pluralCategoriesOf: (locale: string) => Intl.LDMLPluralRule[];
10
+ /**
11
+ * Keys the English catalog has that a translation lacks — the check CI runs
12
+ * so nothing ships half-translated. Plurals are compared by what each language
13
+ * needs: English "item_one"/"item_other" requires all six forms in Arabic and
14
+ * only "item_other" in Japanese. Also lists keys the translation has that
15
+ * English no longer does (stale).
16
+ */
17
+ export declare function missingKeys(source: unknown, target: unknown, targetLocale: string): {
18
+ missing: string[];
19
+ stale: string[];
20
+ };
@@ -0,0 +1,50 @@
1
+ const PLURAL = /_(zero|one|two|few|many|other)$/;
2
+ /** Nested catalog → flat "a.b.c" keys holding strings. */
3
+ export function flattenCatalog(catalog, prefix = "") {
4
+ const out = new Map();
5
+ if (catalog && typeof catalog === "object" && !Array.isArray(catalog)) {
6
+ for (const [k, v] of Object.entries(catalog)) {
7
+ const key = prefix ? `${prefix}.${k}` : k;
8
+ if (typeof v === "string")
9
+ out.set(key, v);
10
+ else
11
+ for (const [kk, vv] of flattenCatalog(v, key))
12
+ out.set(kk, vv);
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+ /**
18
+ * The plural forms a language needs: Arabic has six, Japanese one, English
19
+ * two, Spanish three ("many" is for millions). Built the way i18next builds
20
+ * its own rules, from the bare code, so the check asks for exactly the keys
21
+ * i18next will look up.
22
+ */
23
+ export const pluralCategoriesOf = (locale) => new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
24
+ /**
25
+ * Keys the English catalog has that a translation lacks — the check CI runs
26
+ * so nothing ships half-translated. Plurals are compared by what each language
27
+ * needs: English "item_one"/"item_other" requires all six forms in Arabic and
28
+ * only "item_other" in Japanese. Also lists keys the translation has that
29
+ * English no longer does (stale).
30
+ */
31
+ export function missingKeys(source, target, targetLocale) {
32
+ const src = flattenCatalog(source);
33
+ const tgt = flattenCatalog(target);
34
+ const need = pluralCategoriesOf(targetLocale);
35
+ const expected = new Set();
36
+ for (const key of src.keys()) {
37
+ const m = PLURAL.exec(key);
38
+ if (!m)
39
+ expected.add(key);
40
+ else
41
+ for (const cat of need)
42
+ expected.add(key.slice(0, m.index) + "_" + cat);
43
+ }
44
+ // A translation may carry an optional "_zero" (i18next uses it for count 0) even where CLDR has none.
45
+ const pluralBases = new Set([...src.keys()].filter((k) => PLURAL.test(k)).map((k) => k.replace(PLURAL, "")));
46
+ const missing = [...expected].filter((k) => !tgt.get(k)?.trim());
47
+ const stale = [...tgt.keys()].filter((k) => !expected.has(k) && !(k.endsWith("_zero") && pluralBases.has(k.slice(0, -5))));
48
+ return { missing: missing.sort(), stale: stale.sort() };
49
+ }
50
+ //# sourceMappingURL=catalog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.js","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ export interface FormatterOptions {
2
+ /** IANA zone to show times in — the viewer's own, never a hard-coded one. */
3
+ timeZone?: string;
4
+ }
5
+ /**
6
+ * Locale-aware formatting built on Intl. Formatter objects are expensive to
7
+ * construct, so each shape is built once per `createFormatters` call.
8
+ */
9
+ export declare function createFormatters(locale: string, opts?: FormatterOptions): {
10
+ locale: string;
11
+ number: (n: number, o?: Intl.NumberFormatOptions) => string;
12
+ percent: (ratio: number, maximumFractionDigits?: number) => string;
13
+ /** An amount in major units (12.5 → "$12.50"). */
14
+ currency: (amount: number, currency: string) => string;
15
+ /**
16
+ * An amount in minor units, as Pulse stores money (1250 USD → "$12.50";
17
+ * 1250 JPY → "¥1,250" because yen has no minor unit).
18
+ */
19
+ money: (minor: number, currency: string) => string;
20
+ date: (d: Date | string | number, style?: "short" | "medium" | "long" | "full") => string;
21
+ time: (d: Date | string | number) => string;
22
+ dateTime: (d: Date | string | number) => string;
23
+ /** "3 days ago", "in 2 hours" — `value` is negative for the past. */
24
+ relative: (value: number, unit: Intl.RelativeTimeFormatUnit) => string;
25
+ /** "A, B and C" with the language's own conjunction. */
26
+ list: (items: readonly string[], type?: "conjunction" | "disjunction") => string;
27
+ };
28
+ export type Formatters = ReturnType<typeof createFormatters>;
package/dist/format.js ADDED
@@ -0,0 +1,41 @@
1
+ import { intlLocalesOf } from "./locales.js";
2
+ /**
3
+ * Locale-aware formatting built on Intl. Formatter objects are expensive to
4
+ * construct, so each shape is built once per `createFormatters` call.
5
+ */
6
+ export function createFormatters(locale, opts = {}) {
7
+ const locales = intlLocalesOf(locale);
8
+ const cache = new Map();
9
+ const memo = (key, make) => {
10
+ let f = cache.get(key);
11
+ if (!f)
12
+ cache.set(key, (f = make()));
13
+ return f;
14
+ };
15
+ const tz = opts.timeZone ? { timeZone: opts.timeZone } : {};
16
+ const toDate = (d) => (d instanceof Date ? d : new Date(d));
17
+ const currencyFormat = (currency) => memo(`c:${currency}`, () => new Intl.NumberFormat(locales, { style: "currency", currency }));
18
+ return {
19
+ locale,
20
+ number: (n, o) => memo(`n:${JSON.stringify(o ?? {})}`, () => new Intl.NumberFormat(locales, o)).format(n),
21
+ percent: (ratio, maximumFractionDigits = 0) => memo(`p:${maximumFractionDigits}`, () => new Intl.NumberFormat(locales, { style: "percent", maximumFractionDigits })).format(ratio),
22
+ /** An amount in major units (12.5 → "$12.50"). */
23
+ currency: (amount, currency) => currencyFormat(currency).format(amount),
24
+ /**
25
+ * An amount in minor units, as Pulse stores money (1250 USD → "$12.50";
26
+ * 1250 JPY → "¥1,250" because yen has no minor unit).
27
+ */
28
+ money: (minor, currency) => {
29
+ const f = currencyFormat(currency);
30
+ return f.format(minor / 10 ** (f.resolvedOptions().maximumFractionDigits ?? 2));
31
+ },
32
+ date: (d, style = "medium") => memo(`d:${style}`, () => new Intl.DateTimeFormat(locales, { dateStyle: style, ...tz })).format(toDate(d)),
33
+ time: (d) => memo("t", () => new Intl.DateTimeFormat(locales, { timeStyle: "short", ...tz })).format(toDate(d)),
34
+ dateTime: (d) => memo("dt", () => new Intl.DateTimeFormat(locales, { dateStyle: "medium", timeStyle: "short", ...tz })).format(toDate(d)),
35
+ /** "3 days ago", "in 2 hours" — `value` is negative for the past. */
36
+ relative: (value, unit) => memo("r", () => new Intl.RelativeTimeFormat(locales, { numeric: "auto" })).format(value, unit),
37
+ /** "A, B and C" with the language's own conjunction. */
38
+ list: (items, type = "conjunction") => memo(`l:${type}`, () => new Intl.ListFormat(locales, { type })).format(items),
39
+ };
40
+ }
41
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"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"}
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type i18n as I18n, type InitOptions, type Resource } from "i18next";
2
+ import { PSEUDO_LOCALE, type LocaleCode } from "./locales.js";
3
+ export interface CreateI18nOptions {
4
+ /** Catalogs by locale then namespace: `{ en: { common: {...} }, es: {...} }`. English is required. */
5
+ resources: Resource;
6
+ /** Starting language, usually from `pickLocale`. */
7
+ locale: LocaleCode | typeof PSEUDO_LOCALE;
8
+ defaultNS?: string;
9
+ /** Offer the en-XA pseudo-locale (dev / staging / CI only). */
10
+ pseudo?: boolean;
11
+ /**
12
+ * Called for a key found in no catalog at all, English included. A key that
13
+ * only lacks a translation falls back to English silently; `missingKeys` is
14
+ * the check for those (CI).
15
+ */
16
+ onMissingKey?: (locale: string, namespace: string, key: string) => void;
17
+ /** Extra i18next options — plugins such as `initReactI18next` go through `use`. */
18
+ init?: Partial<InitOptions>;
19
+ use?: Parameters<I18n["use"]>[0][];
20
+ }
21
+ /**
22
+ * A ready i18next instance with the Pulse defaults: our 18 locales, English
23
+ * fallback, no HTML escaping (React escapes), and the pseudo-locale when asked.
24
+ * Each call returns a fresh instance, so tests and SSR requests don't share one.
25
+ */
26
+ export declare function createI18n(opts: CreateI18nOptions): Promise<I18n>;
27
+ /** Set `<html lang dir>` so screen readers, fonts, hyphenation and RTL layout follow the language. */
28
+ export declare function applyDocumentLocale(locale: string, doc?: Document | undefined): void;
package/dist/i18n.js ADDED
@@ -0,0 +1,48 @@
1
+ import i18next from "i18next";
2
+ import { dirOf, LOCALE_CODES, PSEUDO_LOCALE, SOURCE_LOCALE } from "./locales.js";
3
+ import { pseudoizeCatalog } from "./pseudo.js";
4
+ /**
5
+ * A ready i18next instance with the Pulse defaults: our 18 locales, English
6
+ * fallback, no HTML escaping (React escapes), and the pseudo-locale when asked.
7
+ * Each call returns a fresh instance, so tests and SSR requests don't share one.
8
+ */
9
+ export async function createI18n(opts) {
10
+ if (!opts.resources[SOURCE_LOCALE])
11
+ throw new Error("createI18n: English (en) catalogs are required");
12
+ const resources = { ...opts.resources };
13
+ const supported = [...LOCALE_CODES];
14
+ if (opts.pseudo) {
15
+ resources[PSEUDO_LOCALE] = pseudoizeCatalog(opts.resources[SOURCE_LOCALE]);
16
+ supported.push(PSEUDO_LOCALE);
17
+ }
18
+ const instance = i18next.createInstance();
19
+ for (const plugin of opts.use ?? [])
20
+ instance.use(plugin);
21
+ await instance.init({
22
+ resources,
23
+ lng: supported.includes(opts.locale) ? opts.locale : SOURCE_LOCALE,
24
+ fallbackLng: SOURCE_LOCALE,
25
+ supportedLngs: supported,
26
+ // "zh-Hans" must not quietly load "zh", and "pt-BR" must not load "pt".
27
+ load: "currentOnly",
28
+ nonExplicitSupportedLngs: false,
29
+ defaultNS: opts.defaultNS ?? "common",
30
+ ns: Object.keys(opts.resources[SOURCE_LOCALE]),
31
+ interpolation: { escapeValue: false },
32
+ returnNull: false,
33
+ saveMissing: Boolean(opts.onMissingKey),
34
+ missingKeyHandler: opts.onMissingKey
35
+ ? (_saveTo, ns, key) => opts.onMissingKey(instance.language, ns, key)
36
+ : undefined,
37
+ ...opts.init,
38
+ });
39
+ return instance;
40
+ }
41
+ /** Set `<html lang dir>` so screen readers, fonts, hyphenation and RTL layout follow the language. */
42
+ export function applyDocumentLocale(locale, doc = globalThis.document) {
43
+ if (!doc?.documentElement)
44
+ return;
45
+ doc.documentElement.lang = locale;
46
+ doc.documentElement.dir = dirOf(locale);
47
+ }
48
+ //# sourceMappingURL=i18n.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA,OAAO,OAA+D,MAAM,SAAS,CAAC;AACtF,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,aAAa,EAAmB,MAAM,cAAc,CAAC;AAClG,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAqB/C;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAuB;IACtD,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,gDAAgD,CAAC,CAAC;IACtG,MAAM,SAAS,GAAa,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;IAClD,MAAM,SAAS,GAAa,CAAC,GAAG,YAAY,CAAC,CAAC;IAC9C,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,SAAS,CAAC,aAAa,CAAC,GAAG,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC;QAC3E,SAAS,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAChC,CAAC;IACD,MAAM,QAAQ,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAC1C,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,GAAG,IAAI,EAAE;QAAE,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1D,MAAM,QAAQ,CAAC,IAAI,CAAC;QAClB,SAAS;QACT,GAAG,EAAE,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,aAAa;QAClE,WAAW,EAAE,aAAa;QAC1B,aAAa,EAAE,SAAS;QACxB,wEAAwE;QACxE,IAAI,EAAE,aAAa;QACnB,wBAAwB,EAAE,KAAK;QAC/B,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,QAAQ;QACrC,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,aAAa,CAAE,CAAC;QAC/C,aAAa,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE;QACrC,UAAU,EAAE,KAAK;QACjB,WAAW,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC;QACvC,iBAAiB,EAAE,IAAI,CAAC,YAAY;YAClC,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,YAAa,CAAC,QAAQ,CAAC,QAAQ,EAAE,EAAE,EAAE,GAAG,CAAC;YACtE,CAAC,CAAC,SAAS;QACb,GAAG,IAAI,CAAC,IAAI;KACb,CAAC,CAAC;IACH,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,mBAAmB,CAAC,MAAc,EAAE,MAA4B,UAAU,CAAC,QAAQ;IACjG,IAAI,CAAC,GAAG,EAAE,eAAe;QAAE,OAAO;IAClC,GAAG,CAAC,eAAe,CAAC,IAAI,GAAG,MAAM,CAAC;IAClC,GAAG,CAAC,eAAe,CAAC,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC;AAC1C,CAAC"}
@@ -0,0 +1,7 @@
1
+ export * from "./locales.js";
2
+ export * from "./match.js";
3
+ export * from "./preference.js";
4
+ export * from "./format.js";
5
+ export * from "./pseudo.js";
6
+ export * from "./i18n.js";
7
+ export * from "./catalog.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./locales.js";
2
+ export * from "./match.js";
3
+ export * from "./preference.js";
4
+ export * from "./format.js";
5
+ export * from "./pseudo.js";
6
+ export * from "./i18n.js";
7
+ export * from "./catalog.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC"}
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The languages every Pulse product offers (ENG-1). English is the source
3
+ * language; every other catalog is translated from it.
4
+ *
5
+ * `code` is the BCP 47 tag used for catalogs, the cookie and the `lang`
6
+ * attribute. `intl` lists the locales handed to the Intl formatters: a
7
+ * language CLDR has no data for (Haitian Creole) names a close neighbour so
8
+ * dates and numbers don't silently come out in US English.
9
+ */
10
+ export declare const LOCALES: readonly [{
11
+ readonly code: "en";
12
+ readonly english: "English";
13
+ readonly native: "English";
14
+ readonly dir: "ltr";
15
+ readonly intl: readonly ["en"];
16
+ }, {
17
+ readonly code: "es";
18
+ readonly english: "Spanish";
19
+ readonly native: "Español";
20
+ readonly dir: "ltr";
21
+ readonly intl: readonly ["es"];
22
+ }, {
23
+ readonly code: "zh-Hans";
24
+ readonly english: "Chinese (Simplified)";
25
+ readonly native: "简体中文";
26
+ readonly dir: "ltr";
27
+ readonly intl: readonly ["zh-Hans"];
28
+ }, {
29
+ readonly code: "zh-Hant";
30
+ readonly english: "Chinese (Traditional)";
31
+ readonly native: "繁體中文";
32
+ readonly dir: "ltr";
33
+ readonly intl: readonly ["zh-Hant"];
34
+ }, {
35
+ readonly code: "hi";
36
+ readonly english: "Hindi";
37
+ readonly native: "हिन्दी";
38
+ readonly dir: "ltr";
39
+ readonly intl: readonly ["hi"];
40
+ }, {
41
+ readonly code: "ar";
42
+ readonly english: "Arabic";
43
+ readonly native: "العربية";
44
+ readonly dir: "rtl";
45
+ readonly intl: readonly ["ar"];
46
+ }, {
47
+ readonly code: "fr";
48
+ readonly english: "French";
49
+ readonly native: "Français";
50
+ readonly dir: "ltr";
51
+ readonly intl: readonly ["fr"];
52
+ }, {
53
+ readonly code: "pt-BR";
54
+ readonly english: "Portuguese (Brazil)";
55
+ readonly native: "Português (Brasil)";
56
+ readonly dir: "ltr";
57
+ readonly intl: readonly ["pt-BR"];
58
+ }, {
59
+ readonly code: "ru";
60
+ readonly english: "Russian";
61
+ readonly native: "Русский";
62
+ readonly dir: "ltr";
63
+ readonly intl: readonly ["ru"];
64
+ }, {
65
+ readonly code: "bn";
66
+ readonly english: "Bengali";
67
+ readonly native: "বাংলা";
68
+ readonly dir: "ltr";
69
+ readonly intl: readonly ["bn"];
70
+ }, {
71
+ readonly code: "ja";
72
+ readonly english: "Japanese";
73
+ readonly native: "日本語";
74
+ readonly dir: "ltr";
75
+ readonly intl: readonly ["ja"];
76
+ }, {
77
+ readonly code: "de";
78
+ readonly english: "German";
79
+ readonly native: "Deutsch";
80
+ readonly dir: "ltr";
81
+ readonly intl: readonly ["de"];
82
+ }, {
83
+ readonly code: "id";
84
+ readonly english: "Indonesian";
85
+ readonly native: "Bahasa Indonesia";
86
+ readonly dir: "ltr";
87
+ readonly intl: readonly ["id"];
88
+ }, {
89
+ readonly code: "ur";
90
+ readonly english: "Urdu";
91
+ readonly native: "اردو";
92
+ readonly dir: "rtl";
93
+ readonly intl: readonly ["ur"];
94
+ }, {
95
+ readonly code: "vi";
96
+ readonly english: "Vietnamese";
97
+ readonly native: "Tiếng Việt";
98
+ readonly dir: "ltr";
99
+ readonly intl: readonly ["vi"];
100
+ }, {
101
+ readonly code: "ko";
102
+ readonly english: "Korean";
103
+ readonly native: "한국어";
104
+ readonly dir: "ltr";
105
+ readonly intl: readonly ["ko"];
106
+ }, {
107
+ readonly code: "fil";
108
+ readonly english: "Tagalog";
109
+ readonly native: "Tagalog";
110
+ readonly dir: "ltr";
111
+ readonly intl: readonly ["fil"];
112
+ }, {
113
+ readonly code: "ht";
114
+ readonly english: "Haitian Creole";
115
+ readonly native: "Kreyòl ayisyen";
116
+ readonly dir: "ltr";
117
+ readonly intl: readonly ["ht", "fr-HT"];
118
+ }];
119
+ export type LocaleCode = (typeof LOCALES)[number]["code"];
120
+ export type LocaleInfo = (typeof LOCALES)[number];
121
+ export declare const SOURCE_LOCALE: LocaleCode;
122
+ export declare const LOCALE_CODES: readonly LocaleCode[];
123
+ /**
124
+ * Pseudo-locale for testing: English run through `pseudoize`, so any text
125
+ * still showing as plain English was never extracted. Never offered to users
126
+ * unless a product turns it on.
127
+ */
128
+ export declare const PSEUDO_LOCALE = "en-XA";
129
+ export declare const isLocaleCode: (x: unknown) => x is LocaleCode;
130
+ export declare function localeInfo(code: LocaleCode): LocaleInfo;
131
+ export declare const dirOf: (code: string) => "ltr" | "rtl";
132
+ /** Locales for the Intl formatters (see the note on `intl` above). */
133
+ export declare const intlLocalesOf: (code: string) => string[];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The languages every Pulse product offers (ENG-1). English is the source
3
+ * language; every other catalog is translated from it.
4
+ *
5
+ * `code` is the BCP 47 tag used for catalogs, the cookie and the `lang`
6
+ * attribute. `intl` lists the locales handed to the Intl formatters: a
7
+ * language CLDR has no data for (Haitian Creole) names a close neighbour so
8
+ * dates and numbers don't silently come out in US English.
9
+ */
10
+ export const LOCALES = [
11
+ { code: "en", english: "English", native: "English", dir: "ltr", intl: ["en"] },
12
+ { code: "es", english: "Spanish", native: "Español", dir: "ltr", intl: ["es"] },
13
+ { code: "zh-Hans", english: "Chinese (Simplified)", native: "简体中文", dir: "ltr", intl: ["zh-Hans"] },
14
+ { code: "zh-Hant", english: "Chinese (Traditional)", native: "繁體中文", dir: "ltr", intl: ["zh-Hant"] },
15
+ { code: "hi", english: "Hindi", native: "हिन्दी", dir: "ltr", intl: ["hi"] },
16
+ { code: "ar", english: "Arabic", native: "العربية", dir: "rtl", intl: ["ar"] },
17
+ { code: "fr", english: "French", native: "Français", dir: "ltr", intl: ["fr"] },
18
+ { code: "pt-BR", english: "Portuguese (Brazil)", native: "Português (Brasil)", dir: "ltr", intl: ["pt-BR"] },
19
+ { code: "ru", english: "Russian", native: "Русский", dir: "ltr", intl: ["ru"] },
20
+ { code: "bn", english: "Bengali", native: "বাংলা", dir: "ltr", intl: ["bn"] },
21
+ { code: "ja", english: "Japanese", native: "日本語", dir: "ltr", intl: ["ja"] },
22
+ { code: "de", english: "German", native: "Deutsch", dir: "ltr", intl: ["de"] },
23
+ { code: "id", english: "Indonesian", native: "Bahasa Indonesia", dir: "ltr", intl: ["id"] },
24
+ { code: "ur", english: "Urdu", native: "اردو", dir: "rtl", intl: ["ur"] },
25
+ { code: "vi", english: "Vietnamese", native: "Tiếng Việt", dir: "ltr", intl: ["vi"] },
26
+ { code: "ko", english: "Korean", native: "한국어", dir: "ltr", intl: ["ko"] },
27
+ // "tl" (Tagalog) canonicalises to "fil" in Intl; matching accepts both.
28
+ { code: "fil", english: "Tagalog", native: "Tagalog", dir: "ltr", intl: ["fil"] },
29
+ { code: "ht", english: "Haitian Creole", native: "Kreyòl ayisyen", dir: "ltr", intl: ["ht", "fr-HT"] },
30
+ ];
31
+ export const SOURCE_LOCALE = "en";
32
+ export const LOCALE_CODES = LOCALES.map((l) => l.code);
33
+ /**
34
+ * Pseudo-locale for testing: English run through `pseudoize`, so any text
35
+ * still showing as plain English was never extracted. Never offered to users
36
+ * unless a product turns it on.
37
+ */
38
+ export const PSEUDO_LOCALE = "en-XA";
39
+ const byCode = new Map(LOCALES.map((l) => [l.code, l]));
40
+ export const isLocaleCode = (x) => typeof x === "string" && byCode.has(x);
41
+ export function localeInfo(code) {
42
+ return byCode.get(code);
43
+ }
44
+ export const dirOf = (code) => byCode.get(code)?.dir ?? "ltr";
45
+ /** Locales for the Intl formatters (see the note on `intl` above). */
46
+ export const intlLocalesOf = (code) => [...(byCode.get(code)?.intl ?? (code === PSEUDO_LOCALE ? ["en"] : [code])), "en"];
47
+ //# sourceMappingURL=locales.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locales.js","sourceRoot":"","sources":["../src/locales.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import { type LocaleCode } from "./locales.js";
2
+ /**
3
+ * The supported locale a single language tag should get, or null.
4
+ * exact code (any case) → that code
5
+ * zh-TW / zh-HK / zh-MO → zh-Hant (by script); other zh → zh-Hans
6
+ * pt, pt-PT → pt-BR (the only Portuguese we offer)
7
+ * es-MX, en-GB, fr-CA, … → the base language when we offer it
8
+ */
9
+ export declare function matchLocale(tag: string | null | undefined): LocaleCode | null;
10
+ /** The first tag, in order of preference, that we support. */
11
+ export declare function resolveLocale(tags: Iterable<string | null | undefined>): LocaleCode | null;
12
+ /**
13
+ * An Accept-Language header as tags, most preferred first. Entries with q=0
14
+ * ("not this one") and the "*" wildcard are dropped; equal weights keep the
15
+ * order they were sent in.
16
+ */
17
+ export declare function parseAcceptLanguage(header: string | null | undefined): string[];
package/dist/match.js ADDED
@@ -0,0 +1,60 @@
1
+ import { LOCALE_CODES } from "./locales.js";
2
+ const lowerToCode = new Map(LOCALE_CODES.map((c) => [c.toLowerCase(), c]));
3
+ /**
4
+ * The supported locale a single language tag should get, or null.
5
+ * exact code (any case) → that code
6
+ * zh-TW / zh-HK / zh-MO → zh-Hant (by script); other zh → zh-Hans
7
+ * pt, pt-PT → pt-BR (the only Portuguese we offer)
8
+ * es-MX, en-GB, fr-CA, … → the base language when we offer it
9
+ */
10
+ export function matchLocale(tag) {
11
+ if (!tag)
12
+ return null;
13
+ let canonical;
14
+ let loc;
15
+ try {
16
+ canonical = Intl.getCanonicalLocales(tag.trim().replace(/_/g, "-"))[0];
17
+ loc = new Intl.Locale(canonical);
18
+ }
19
+ catch {
20
+ return null; // not a language tag at all
21
+ }
22
+ const exact = lowerToCode.get(canonical.toLowerCase());
23
+ if (exact)
24
+ return exact;
25
+ if (loc.language === "zh")
26
+ return loc.maximize().script === "Hant" ? "zh-Hant" : "zh-Hans";
27
+ if (loc.language === "pt")
28
+ return "pt-BR";
29
+ return lowerToCode.get(loc.language) ?? null;
30
+ }
31
+ /** The first tag, in order of preference, that we support. */
32
+ export function resolveLocale(tags) {
33
+ for (const t of tags) {
34
+ const m = matchLocale(t);
35
+ if (m)
36
+ return m;
37
+ }
38
+ return null;
39
+ }
40
+ /**
41
+ * An Accept-Language header as tags, most preferred first. Entries with q=0
42
+ * ("not this one") and the "*" wildcard are dropped; equal weights keep the
43
+ * order they were sent in.
44
+ */
45
+ export function parseAcceptLanguage(header) {
46
+ if (!header)
47
+ return [];
48
+ return header
49
+ .split(",")
50
+ .map((part) => {
51
+ const [tag, ...params] = part.trim().split(";");
52
+ const qParam = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
53
+ const q = qParam ? Number(qParam.slice(2)) : 1;
54
+ return { tag: tag.trim(), q: Number.isFinite(q) ? q : 0 };
55
+ })
56
+ .filter((x) => x.tag && x.tag !== "*" && x.q > 0)
57
+ .sort((a, b) => b.q - a.q) // stable: ties keep the order they were sent in
58
+ .map((x) => x.tag);
59
+ }
60
+ //# sourceMappingURL=match.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"match.js","sourceRoot":"","sources":["../src/match.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import { type LocaleCode } from "./locales.js";
2
+ /** One cookie for the whole fleet, so a choice made in one product carries to the others. */
3
+ export declare const LANG_COOKIE = "pulse_lang";
4
+ /**
5
+ * Where the cookie should live: shared across every *.pulsetechnologies.ai
6
+ * product, or host-only anywhere else (partner white-label domains, localhost).
7
+ */
8
+ export declare function cookieDomainFor(hostname: string): string | undefined;
9
+ /** The language in a Cookie header / `document.cookie`, if it's one we support. */
10
+ export declare function readLangCookie(cookieHeader: string | null | undefined): LocaleCode | null;
11
+ /** A Set-Cookie / `document.cookie` string that remembers the language for a year. */
12
+ export declare function langCookie(code: LocaleCode, opts: {
13
+ hostname: string;
14
+ secure?: boolean;
15
+ maxAgeDays?: number;
16
+ }): string;
17
+ export type LocaleSource = "user" | "cookie" | "workspace" | "browser" | "default";
18
+ export interface LocaleInputs {
19
+ /** The signed-in user's saved choice (ENG-3: stored on the Pulse SSO profile). */
20
+ user?: string | null;
21
+ /** The `pulse_lang` cookie value, or the whole Cookie header. */
22
+ cookie?: string | null;
23
+ /** The workspace / tenant default set by an admin. */
24
+ workspace?: string | null;
25
+ /** `navigator.languages`, or an Accept-Language header. */
26
+ browser?: readonly string[] | string | null;
27
+ }
28
+ /**
29
+ * Which language to show, in order: the user's own choice, the cookie from
30
+ * any Pulse product, the workspace default, the browser, then English.
31
+ */
32
+ export declare function pickLocale(inputs: LocaleInputs): {
33
+ locale: LocaleCode;
34
+ source: LocaleSource;
35
+ };
@@ -0,0 +1,63 @@
1
+ import { isLocaleCode, SOURCE_LOCALE } from "./locales.js";
2
+ import { parseAcceptLanguage, resolveLocale } from "./match.js";
3
+ /** One cookie for the whole fleet, so a choice made in one product carries to the others. */
4
+ export const LANG_COOKIE = "pulse_lang";
5
+ const FLEET_DOMAIN = "pulsetechnologies.ai";
6
+ /**
7
+ * Where the cookie should live: shared across every *.pulsetechnologies.ai
8
+ * product, or host-only anywhere else (partner white-label domains, localhost).
9
+ */
10
+ export function cookieDomainFor(hostname) {
11
+ const h = hostname.toLowerCase();
12
+ return h === FLEET_DOMAIN || h.endsWith(`.${FLEET_DOMAIN}`) ? `.${FLEET_DOMAIN}` : undefined;
13
+ }
14
+ /** The language in a Cookie header / `document.cookie`, if it's one we support. */
15
+ export function readLangCookie(cookieHeader) {
16
+ for (const part of (cookieHeader ?? "").split(";")) {
17
+ const eq = part.indexOf("=");
18
+ if (eq < 0 || part.slice(0, eq).trim() !== LANG_COOKIE)
19
+ continue;
20
+ let value;
21
+ try {
22
+ value = decodeURIComponent(part.slice(eq + 1).trim());
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ return isLocaleCode(value) ? value : null;
28
+ }
29
+ return null;
30
+ }
31
+ /** A Set-Cookie / `document.cookie` string that remembers the language for a year. */
32
+ export function langCookie(code, opts) {
33
+ const domain = cookieDomainFor(opts.hostname);
34
+ return [
35
+ `${LANG_COOKIE}=${encodeURIComponent(code)}`,
36
+ "Path=/",
37
+ `Max-Age=${Math.round((opts.maxAgeDays ?? 365) * 86_400)}`,
38
+ "SameSite=Lax",
39
+ ...(domain ? [`Domain=${domain}`] : []),
40
+ ...(opts.secure === false ? [] : ["Secure"]),
41
+ ].join("; ");
42
+ }
43
+ /**
44
+ * Which language to show, in order: the user's own choice, the cookie from
45
+ * any Pulse product, the workspace default, the browser, then English.
46
+ */
47
+ export function pickLocale(inputs) {
48
+ const cookie = inputs.cookie?.includes("=") ? readLangCookie(inputs.cookie) : inputs.cookie;
49
+ const browser = typeof inputs.browser === "string" ? parseAcceptLanguage(inputs.browser) : (inputs.browser ?? []);
50
+ const steps = [
51
+ ["user", [inputs.user]],
52
+ ["cookie", [cookie]],
53
+ ["workspace", [inputs.workspace]],
54
+ ["browser", browser],
55
+ ];
56
+ for (const [source, tags] of steps) {
57
+ const locale = resolveLocale(tags);
58
+ if (locale)
59
+ return { locale, source };
60
+ }
61
+ return { locale: SOURCE_LOCALE, source: "default" };
62
+ }
63
+ //# sourceMappingURL=preference.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preference.js","sourceRoot":"","sources":["../src/preference.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAmB,MAAM,cAAc,CAAC;AAC5E,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,6FAA6F;AAC7F,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC;AAExC,MAAM,YAAY,GAAG,sBAAsB,CAAC;AAE5C;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,QAAgB;IAC9C,MAAM,CAAC,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAC;IACjC,OAAO,CAAC,KAAK,YAAY,IAAI,CAAC,CAAC,QAAQ,CAAC,IAAI,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,YAAY,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC/F,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,cAAc,CAAC,YAAuC;IACpE,KAAK,MAAM,IAAI,IAAI,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACnD,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,KAAK,WAAW;YAAE,SAAS;QACjE,IAAI,KAAa,CAAC;QAClB,IAAI,CAAC;YACH,KAAK,GAAG,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACxD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5C,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,UAAU,CAAC,IAAgB,EAAE,IAAiE;IAC5G,MAAM,MAAM,GAAG,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC9C,OAAO;QACL,GAAG,WAAW,IAAI,kBAAkB,CAAC,IAAI,CAAC,EAAE;QAC5C,QAAQ;QACR,WAAW,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,UAAU,IAAI,GAAG,CAAC,GAAG,MAAM,CAAC,EAAE;QAC1D,cAAc;QACd,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,UAAU,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACvC,GAAG,CAAC,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;KAC7C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAeD;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,MAAoB;IAC7C,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;IAC5F,MAAM,OAAO,GAAG,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,mBAAmB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC;IAClH,MAAM,KAAK,GAA0D;QACnE,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACvB,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,CAAC;QACpB,CAAC,WAAW,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACjC,CAAC,SAAS,EAAE,OAAO,CAAC;KACrB,CAAC;IACF,KAAK,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,KAAK,EAAE,CAAC;QACnC,MAAM,MAAM,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,MAAM;YAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IACxC,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AACtD,CAAC"}
@@ -0,0 +1,3 @@
1
+ export declare function pseudoize(text: string): string;
2
+ /** A whole catalog (nested objects of strings) run through `pseudoize`. */
3
+ export declare function pseudoizeCatalog<T>(catalog: T): T;
package/dist/pseudo.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pseudo-translation: "Save changes" → "[Šåvé çĥåñĝéš ·····]". Anything on
3
+ * screen that still reads as plain English was never extracted, and the
4
+ * padding (~40%, what German and Russian typically need) shows where layouts
5
+ * will clip. Placeholders, nesting and markup are left intact so the result
6
+ * still interpolates.
7
+ */
8
+ const ACCENTED = {
9
+ a: "å", b: "ƀ", c: "ç", d: "ð", e: "é", f: "ƒ", g: "ĝ", h: "ĥ", i: "î", j: "ĵ", k: "ķ", l: "ļ", m: "ɱ",
10
+ n: "ñ", o: "ö", p: "þ", q: "ǫ", r: "ŕ", s: "š", t: "ţ", u: "û", v: "ṽ", w: "ŵ", x: "ẋ", y: "ý", z: "ž",
11
+ A: "Å", B: "Ɓ", C: "Ç", D: "Ð", E: "É", F: "Ƒ", G: "Ĝ", H: "Ĥ", I: "Î", J: "Ĵ", K: "Ķ", L: "Ļ", M: "Ṁ",
12
+ N: "Ñ", O: "Ö", P: "Þ", Q: "Ǫ", R: "Ŕ", S: "Š", T: "Ţ", U: "Û", V: "Ṽ", W: "Ŵ", X: "Ẋ", Y: "Ý", Z: "Ž",
13
+ };
14
+ /** {{name}}, {{count, number}}, $t(other.key), <b>, </0>, <br/> */
15
+ const KEEP = /(\{\{[^}]*\}\}|\$t\([^)]*\)|<\/?[a-zA-Z0-9]+\s*\/?>)/;
16
+ export function pseudoize(text) {
17
+ let letters = 0;
18
+ const body = text
19
+ .split(KEEP)
20
+ .map((part, i) => {
21
+ if (i % 2 === 1)
22
+ return part; // a kept token
23
+ return part.replace(/[a-zA-Z]/g, (ch) => {
24
+ letters++;
25
+ return ACCENTED[ch] ?? ch;
26
+ });
27
+ })
28
+ .join("");
29
+ const pad = letters ? " " + "·".repeat(Math.max(1, Math.round(letters * 0.4))) : "";
30
+ return `[${body}${pad}]`;
31
+ }
32
+ /** A whole catalog (nested objects of strings) run through `pseudoize`. */
33
+ export function pseudoizeCatalog(catalog) {
34
+ if (typeof catalog === "string")
35
+ return pseudoize(catalog);
36
+ if (Array.isArray(catalog))
37
+ return catalog.map((v) => pseudoizeCatalog(v));
38
+ if (catalog && typeof catalog === "object") {
39
+ return Object.fromEntries(Object.entries(catalog).map(([k, v]) => [k, pseudoizeCatalog(v)]));
40
+ }
41
+ return catalog;
42
+ }
43
+ //# sourceMappingURL=pseudo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pseudo.js","sourceRoot":"","sources":["../src/pseudo.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ import { type CSSProperties } from "react";
2
+ import { PSEUDO_LOCALE, type LocaleCode } from "../locales.js";
3
+ export interface LanguageSelectProps {
4
+ value: string;
5
+ onChange: (code: LocaleCode | typeof PSEUDO_LOCALE) => void;
6
+ /** Limit or reorder the list; defaults to all 18. */
7
+ locales?: readonly LocaleCode[];
8
+ /** Also list the en-XA pseudo-locale (dev / staging only). */
9
+ pseudo?: boolean;
10
+ /** Accessible name. Kept bilingual by default so it's findable in any language. */
11
+ label?: string;
12
+ id?: string;
13
+ className?: string;
14
+ style?: CSSProperties;
15
+ disabled?: boolean;
16
+ }
17
+ /**
18
+ * The language dropdown. A native <select>, so it's keyboard- and
19
+ * screen-reader-friendly on every platform and takes each product's own
20
+ * styling via className. Each language is shown in its own script ("Español",
21
+ * "العربية"), which is what a reader who can't read the current language
22
+ * looks for.
23
+ */
24
+ export declare function LanguageSelect({ value, onChange, locales, pseudo, label, id, className, style, disabled }: LanguageSelectProps): import("react").JSX.Element;
25
+ export interface UseLanguageOptions {
26
+ /**
27
+ * Save the choice beyond this browser — e.g. to the Pulse SSO profile (ENG-3).
28
+ * Errors are reported to `onPersistError` and never undo the switch.
29
+ */
30
+ persist?: (code: LocaleCode) => void | Promise<void>;
31
+ onPersistError?: (err: unknown) => void;
32
+ }
33
+ /**
34
+ * The active language, and a setter that switches the UI, keeps <html lang dir>
35
+ * in step, writes the fleet-wide `pulse_lang` cookie and calls `persist`.
36
+ * Needs an i18next instance provided through react-i18next.
37
+ */
38
+ export declare function useLanguage(opts?: UseLanguageOptions): {
39
+ locale: string;
40
+ setLocale: (code: LocaleCode | typeof PSEUDO_LOCALE) => Promise<void>;
41
+ };
42
+ export type LanguagePickerProps = Omit<LanguageSelectProps, "value" | "onChange"> & UseLanguageOptions;
43
+ /** Drop-in picker: `<LanguagePicker persist={saveToProfile} />` inside an I18nextProvider. */
44
+ export declare function LanguagePicker({ persist, onPersistError, ...select }: LanguagePickerProps): import("react").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+ import { applyDocumentLocale } from "../i18n.js";
5
+ import { isLocaleCode, LOCALES, PSEUDO_LOCALE, SOURCE_LOCALE } from "../locales.js";
6
+ import { langCookie } from "../preference.js";
7
+ /**
8
+ * The language dropdown. A native <select>, so it's keyboard- and
9
+ * screen-reader-friendly on every platform and takes each product's own
10
+ * styling via className. Each language is shown in its own script ("Español",
11
+ * "العربية"), which is what a reader who can't read the current language
12
+ * looks for.
13
+ */
14
+ export function LanguageSelect({ value, onChange, locales, pseudo, label = "Language", id, className, style, disabled }) {
15
+ const shown = locales ? LOCALES.filter((l) => locales.includes(l.code)) : LOCALES;
16
+ return (_jsxs("select", { id: id, className: className, style: style, disabled: disabled, "aria-label": label, value: value, onChange: (e) => {
17
+ const v = e.target.value;
18
+ if (isLocaleCode(v) || (pseudo && v === PSEUDO_LOCALE))
19
+ onChange(v);
20
+ }, children: [shown.map((l) => (_jsx("option", { value: l.code, lang: l.code, dir: l.dir, title: l.english, children: l.native }, l.code))), pseudo ? _jsx("option", { value: PSEUDO_LOCALE, lang: "en", title: "Pseudo-locale (testing)", children: "[\u00DE\u0161\u00E9\u00FB\u00F0\u00F6]" }) : null] }));
21
+ }
22
+ /**
23
+ * The active language, and a setter that switches the UI, keeps <html lang dir>
24
+ * in step, writes the fleet-wide `pulse_lang` cookie and calls `persist`.
25
+ * Needs an i18next instance provided through react-i18next.
26
+ */
27
+ export function useLanguage(opts = {}) {
28
+ const { i18n } = useTranslation();
29
+ // The language the user chose, even where its catalog is still thin and
30
+ // strings fall back to English — the picker must not snap back to English.
31
+ const locale = i18n.language ?? SOURCE_LOCALE;
32
+ const { persist, onPersistError } = opts;
33
+ useEffect(() => {
34
+ applyDocumentLocale(locale);
35
+ }, [locale]);
36
+ const setLocale = useCallback(async (code) => {
37
+ await i18n.changeLanguage(code);
38
+ if (!isLocaleCode(code))
39
+ return; // the pseudo-locale is never remembered
40
+ if (typeof document !== "undefined" && typeof location !== "undefined") {
41
+ document.cookie = langCookie(code, { hostname: location.hostname, secure: location.protocol === "https:" });
42
+ }
43
+ if (persist) {
44
+ try {
45
+ await persist(code);
46
+ }
47
+ catch (err) {
48
+ onPersistError?.(err);
49
+ }
50
+ }
51
+ }, [i18n, persist, onPersistError]);
52
+ return { locale, setLocale };
53
+ }
54
+ /** Drop-in picker: `<LanguagePicker persist={saveToProfile} />` inside an I18nextProvider. */
55
+ export function LanguagePicker({ persist, onPersistError, ...select }) {
56
+ const { locale, setLocale } = useLanguage({ persist, onPersistError });
57
+ return _jsx(LanguageSelect, { ...select, value: locale, onChange: (c) => void setLocale(c) });
58
+ }
59
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@pulsetechnologies/i18n",
3
+ "version": "0.1.0",
4
+ "description": "Shared language list, detection, formatting and language picker for every Pulse product.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pulsetechnologies-ai/pulse-i18n.git"
10
+ },
11
+ "homepage": "https://pulsetechnologies.ai",
12
+ "keywords": [
13
+ "i18n",
14
+ "locale",
15
+ "pulse"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "sideEffects": false,
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./react": {
27
+ "types": "./dist/react/index.d.ts",
28
+ "import": "./dist/react/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.build.json",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run",
38
+ "demo": "vite --config demo/vite.config.ts",
39
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "peerDependencies": {
45
+ "i18next": ">=25",
46
+ "react": ">=18",
47
+ "react-i18next": ">=15"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "react": {
51
+ "optional": true
52
+ },
53
+ "react-i18next": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@testing-library/react": "^16.3.3",
59
+ "@types/react": "^19.3.0",
60
+ "@types/react-dom": "^19.3.0",
61
+ "@vitejs/plugin-react": "^6.1.1",
62
+ "i18next": "^26.4.2",
63
+ "jsdom": "^30.1.1",
64
+ "react": "^19.3.0",
65
+ "react-dom": "^19.3.0",
66
+ "react-i18next": "^17.0.15",
67
+ "typescript": "^5.9.3",
68
+ "vite": "^8.3.1",
69
+ "vitest": "^5.0.3"
70
+ }
71
+ }