saykit 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # saykit
2
+
3
+ > Type-safe i18n library with compile-time macro transforms.
4
+
5
+ [![Coverage](https://codecov.io/gh/k0d13/saykit/graph/badge.svg?flag=integration)](https://codecov.io/gh/k0d13/saykit?flags%5B0%5D=integration)
6
+
7
+ The core runtime for [SayKit](https://saykit.js.org). Exports the `Say` class, which stores your locales, loads message catalogues, and formats messages using ICU MessageFormat.
8
+
9
+ You author messages with the `` say`...` `` tagged template (and `say.plural`, `say.ordinal`, `say.select`); a SayKit build-tool plugin rewrites them at build time into small runtime calls.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ pnpm add saykit
15
+ ```
16
+
17
+ You will normally also want [`@saykit/config`](https://github.com/k0d13/saykit/tree/main/packages/config) and a build-tool plugin ([`unplugin-saykit`](https://github.com/k0d13/saykit/tree/main/packages/plugin-unplugin) or [`babel-plugin-saykit`](https://github.com/k0d13/saykit/tree/main/packages/plugin-babel)).
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { Say } from 'saykit';
23
+ import en from './locales/en.po';
24
+ import fr from './locales/fr.po';
25
+
26
+ const say = new Say({
27
+ locales: ['en', 'fr'],
28
+ messages: { en, fr },
29
+ });
30
+
31
+ say.activate('en');
32
+
33
+ say`Hello, ${name}!`;
34
+ say.plural(count, { one: '1 item', other: '# items' });
35
+ ```
36
+
37
+ ## Documentation
38
+
39
+ Full guide at [saykit.js.org](https://saykit.js.org).
@@ -0,0 +1,207 @@
1
+ //#region src/types.d.ts
2
+ type Tuple = [any, ...any[]];
3
+ type Disallow<T, K extends PropertyKey> = T & Partial<Record<K, never>>;
4
+ type Awaitable<T> = T | PromiseLike<T>;
5
+ interface NumeralOptions extends Omit<Partial<Record<Intl.LDMLPluralRule, string>>, 'other'> {
6
+ other: string;
7
+ [digit: number]: string;
8
+ }
9
+ interface SelectOptions {
10
+ other: string;
11
+ [match: string | number]: string;
12
+ }
13
+ //#endregion
14
+ //#region src/runtime.d.ts
15
+ declare namespace Say {
16
+ type Messages = {
17
+ [key: string]: string;
18
+ };
19
+ type Loader<Locale extends string> = (locale: Locale) => Messages | Promise<Messages>;
20
+ type Options<Locale extends string, Loader extends Say.Loader<Locale> | undefined> = {
21
+ locales: Locale[];
22
+ } & ({
23
+ messages: Record<Locale, Messages>;
24
+ loader?: Loader;
25
+ } | {
26
+ messages?: Partial<Record<Locale, Messages>>;
27
+ loader: Loader;
28
+ });
29
+ }
30
+ interface Say {
31
+ /**
32
+ * Define a message.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * say`Hello, ${name}!`
37
+ * ```
38
+ *
39
+ * @remark This is a macro and must be used with the relevant saykit plugin
40
+ */
41
+ (strings: TemplateStringsArray, ...placeholders: unknown[]): string;
42
+ /**
43
+ * Provide a custom id or context for the message, the latter used to disambiguate
44
+ * identical strings that have different meanings depending on usage.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * say({ context: 'direction' })`Right`
49
+ * say({ context: 'correctness' })`Right`
50
+ * ```
51
+ *
52
+ * @param descriptor Object containing optional `id` and `context` properties
53
+ * @remark This is a macro and must be used with the relevant saykit plugin
54
+ */
55
+ (descriptor: {
56
+ id?: string;
57
+ context?: string;
58
+ }): Say;
59
+ }
60
+ type ReadonlySay<Locale extends string = string, Loader extends Say.Loader<Locale> | undefined = Say.Loader<Locale> | undefined> = Say<Locale, Loader> & {
61
+ activate: never;
62
+ load: never;
63
+ assign: never;
64
+ };
65
+ declare class Say<Locale extends string = string, Loader extends Say.Loader<Locale> | undefined = Say.Loader<Locale> | undefined> {
66
+ #private;
67
+ constructor(options: Say.Options<Locale, Loader>);
68
+ /**
69
+ * The currently active locale.
70
+ *
71
+ * @throws If no locale is active
72
+ */
73
+ get locale(): Locale;
74
+ /**
75
+ * All available messages mapped by locale.
76
+ *
77
+ * @throws If no locale is active
78
+ * @throws If no messages are available for the active locale
79
+ */
80
+ get messages(): Say.Messages;
81
+ /**
82
+ * All available locales.
83
+ */
84
+ get locales(): Locale[];
85
+ /**
86
+ * Loads messages for the given locales.
87
+ * If no locales are provided, all available locales are loaded.
88
+ * Requires a {@link Say.Loader} to be provided.
89
+ * If `loader` returns a promise, so will this method.
90
+ *
91
+ * @param locales Locales to load messages for, defaults to {@link Say.locales}
92
+ * @returns This
93
+ */
94
+ load(...locales: Locale[]): this | Promise<this>;
95
+ /**
96
+ * Manually bulk assign messages.
97
+ *
98
+ * @param messages Messages map to assign
99
+ */
100
+ assign(messages: Partial<Record<Locale, Say.Messages>>): this;
101
+ /**
102
+ * Manually assign messages to a locale.
103
+ *
104
+ * @param locale Locale to assign messages to
105
+ * @param messages Messages to assign
106
+ * @returns This
107
+ */
108
+ assign(locale: Locale, messages: Say.Messages): this;
109
+ /**
110
+ * Set the active locale.
111
+ *
112
+ * @param locale Locale to set
113
+ * @returns This
114
+ * @throws If locale is not available
115
+ */
116
+ activate(locale: Locale): this;
117
+ /**
118
+ * Creates a clone of the Say instance, with the same locales and messages.
119
+ *
120
+ * @returns A clone of the Say instance
121
+ */
122
+ clone(): this;
123
+ /**
124
+ * Make this `Say` instance immutable.
125
+ */
126
+ freeze(): ReadonlySay<Locale, Loader>;
127
+ [Symbol.iterator](): Generator<[ReadonlySay<Locale, Loader>, Locale], void, unknown>;
128
+ /**
129
+ * Matches the best locale from a list of guesses.
130
+ *
131
+ * @param guesses List of locale guesses
132
+ *
133
+ * @returns The best matching locale, or the first locale if no matches are found
134
+ */
135
+ match(...guesses: (string | string[])[]): Locale;
136
+ /**
137
+ * Get the translation for a descriptor.
138
+ *
139
+ * @param descriptor Descriptor to get the translation for
140
+ * @returns The translation string for the descriptor
141
+ * @throws If no locale is active
142
+ * @throws If no messages are available for the active locale
143
+ * @throws If descriptor id is not found
144
+ */
145
+ call(descriptor: {
146
+ id: string;
147
+ [match: string | number]: unknown;
148
+ }): string;
149
+ /**
150
+ * Define a pluralised message.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * say.plural(count, {
155
+ * one: 'You have 1 item',
156
+ * other: 'You have # items',
157
+ * })
158
+ * ```
159
+ *
160
+ * The `#` symbol inside options is replaced with the numeric value.
161
+ * @param _ Number to determine the plural form of
162
+ * @param options Pluralisation rules keyed by CLDR categories or specific numbers
163
+ * @returns The plural form of the number
164
+ * @remark This is a macro and must be used with the relevant saykit plugin
165
+ */
166
+ plural(_: number, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
167
+ /**
168
+ * Define an ordinal message (e.g. "1st", "2nd", "3rd").
169
+ * The `#` symbol inside options is replaced with the numeric value.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * say.ordinal(position, {
174
+ * 1: '#st',
175
+ * 2: '#nd',
176
+ * 3: '#rd',
177
+ * other: '#th',
178
+ * })
179
+ * ```
180
+ *
181
+ * @param _ Number to determine the ordinal form of
182
+ * @param options Ordinal rules keyed by CLDR categories or specific numbers
183
+ * @returns The ordinal form of the number
184
+ * @remark This is a macro and must be used with the relevant saykit plugin
185
+ */
186
+ ordinal(_: number, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
187
+ /**
188
+ * Define a select message, useful for handling gender, status, or other categories.
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * say.select(gender, {
193
+ * male: 'He',
194
+ * female: 'She',
195
+ * other: 'They',
196
+ * })
197
+ * ```
198
+ *
199
+ * @param _ Selector value to determine which option is chosen
200
+ * @param options A mapping of possible selector values to message strings
201
+ * @returns The select form of the value
202
+ * @remark This is a macro and must be used with the relevant saykit plugin
203
+ */
204
+ select(_: string, options: Disallow<SelectOptions, 'id' | 'context'>): string;
205
+ }
206
+ //#endregion
207
+ export { Awaitable, Disallow, NumeralOptions, ReadonlySay, Say, SelectOptions, Tuple };
@@ -0,0 +1,255 @@
1
+ import { mf1ToMessage } from "@messageformat/icu-messageformat-1";
2
+ //#region \0@oxc-project+runtime@0.127.0/helpers/checkPrivateRedeclaration.js
3
+ function _checkPrivateRedeclaration(e, t) {
4
+ if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object");
5
+ }
6
+ //#endregion
7
+ //#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateMethodInitSpec.js
8
+ function _classPrivateMethodInitSpec(e, a) {
9
+ _checkPrivateRedeclaration(e, a), a.add(e);
10
+ }
11
+ //#endregion
12
+ //#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateFieldInitSpec.js
13
+ function _classPrivateFieldInitSpec(e, t, a) {
14
+ _checkPrivateRedeclaration(e, t), t.set(e, a);
15
+ }
16
+ //#endregion
17
+ //#region \0@oxc-project+runtime@0.127.0/helpers/assertClassBrand.js
18
+ function _assertClassBrand(e, t, n) {
19
+ if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n;
20
+ throw new TypeError("Private element is not present on this object");
21
+ }
22
+ //#endregion
23
+ //#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateFieldSet2.js
24
+ function _classPrivateFieldSet2(s, a, r) {
25
+ return s.set(_assertClassBrand(s, a), r), r;
26
+ }
27
+ //#endregion
28
+ //#region \0@oxc-project+runtime@0.127.0/helpers/classPrivateFieldGet2.js
29
+ function _classPrivateFieldGet2(s, a) {
30
+ return s.get(_assertClassBrand(s, a));
31
+ }
32
+ //#endregion
33
+ //#region src/runtime.ts
34
+ let _Symbol$iterator, _Symbol$for;
35
+ var _locales = /* @__PURE__ */ new WeakMap();
36
+ var _loader = /* @__PURE__ */ new WeakMap();
37
+ var _messages = /* @__PURE__ */ new WeakMap();
38
+ var _formats = /* @__PURE__ */ new WeakMap();
39
+ var _active = /* @__PURE__ */ new WeakMap();
40
+ var _Say_brand = /* @__PURE__ */ new WeakSet();
41
+ _Symbol$iterator = Symbol.iterator;
42
+ _Symbol$for = Symbol.for("nodejs.util.inspect.custom");
43
+ var Say = class Say {
44
+ constructor(options) {
45
+ _classPrivateMethodInitSpec(this, _Say_brand);
46
+ _classPrivateFieldInitSpec(this, _locales, void 0);
47
+ _classPrivateFieldInitSpec(this, _loader, void 0);
48
+ _classPrivateFieldInitSpec(this, _messages, void 0);
49
+ _classPrivateFieldInitSpec(this, _formats, void 0);
50
+ _classPrivateFieldInitSpec(this, _active, void 0);
51
+ _classPrivateFieldSet2(_locales, this, options.locales);
52
+ _classPrivateFieldSet2(_loader, this, options.loader);
53
+ _classPrivateFieldSet2(_messages, this, /* @__PURE__ */ new Map());
54
+ _classPrivateFieldSet2(_formats, this, /* @__PURE__ */ new Map());
55
+ if (options.messages) this.assign(options.messages);
56
+ }
57
+ /**
58
+ * The currently active locale.
59
+ *
60
+ * @throws If no locale is active
61
+ */
62
+ get locale() {
63
+ if (!_classPrivateFieldGet2(_active, this)) throw new Error("No active locale");
64
+ return _classPrivateFieldGet2(_active, this);
65
+ }
66
+ /**
67
+ * All available messages mapped by locale.
68
+ *
69
+ * @throws If no locale is active
70
+ * @throws If no messages are available for the active locale
71
+ */
72
+ get messages() {
73
+ /* v8 ignore next */
74
+ if (!_classPrivateFieldGet2(_messages, this).has(this.locale)) throw new Error("No messages loaded for locale");
75
+ return _classPrivateFieldGet2(_messages, this).get(this.locale);
76
+ }
77
+ /**
78
+ * All available locales.
79
+ */
80
+ get locales() {
81
+ return _classPrivateFieldGet2(_locales, this);
82
+ }
83
+ /**
84
+ * Loads messages for the given locales.
85
+ * If no locales are provided, all available locales are loaded.
86
+ * Requires a {@link Say.Loader} to be provided.
87
+ * If `loader` returns a promise, so will this method.
88
+ *
89
+ * @param locales Locales to load messages for, defaults to {@link Say.locales}
90
+ * @returns This
91
+ */
92
+ load(...locales) {
93
+ if (Object.isFrozen(this)) throw new Error("Cannot load messages on a frozen Say");
94
+ if (locales.length === 0) locales = _classPrivateFieldGet2(_locales, this);
95
+ const tasks = [];
96
+ for (const locale of locales) {
97
+ if (_classPrivateFieldGet2(_messages, this).has(locale)) continue;
98
+ if (!_classPrivateFieldGet2(_loader, this)) throw new Error("No loader provided, cannot load messages");
99
+ const result = _classPrivateFieldGet2(_loader, this).call(this, locale);
100
+ if (result instanceof Promise) {
101
+ const task = result.then((m) => this.assign(locale, m));
102
+ tasks.push(task);
103
+ } else this.assign(locale, result);
104
+ }
105
+ return tasks.length > 0 ? Promise.all(tasks).then(() => this) : this;
106
+ }
107
+ assign(localeOrMessages, maybeMessages) {
108
+ if (Object.isFrozen(this)) throw new Error("Cannot assign messages on a frozen Say");
109
+ if (typeof localeOrMessages === "string") _classPrivateFieldGet2(_messages, this).set(localeOrMessages, maybeMessages);
110
+ else for (const locale in localeOrMessages) _classPrivateFieldGet2(_messages, this).set(locale, localeOrMessages[locale]);
111
+ return this;
112
+ }
113
+ /**
114
+ * Set the active locale.
115
+ *
116
+ * @param locale Locale to set
117
+ * @returns This
118
+ * @throws If locale is not available
119
+ */
120
+ activate(locale) {
121
+ if (Object.isFrozen(this)) throw new Error("Cannot activate locale on a frozen Say");
122
+ if (!_classPrivateFieldGet2(_messages, this).has(locale)) throw new Error("No messages loaded for locale");
123
+ _classPrivateFieldSet2(_active, this, locale);
124
+ return this;
125
+ }
126
+ /**
127
+ * Creates a clone of the Say instance, with the same locales and messages.
128
+ *
129
+ * @returns A clone of the Say instance
130
+ */
131
+ clone() {
132
+ const copy = new Say({
133
+ locales: _classPrivateFieldGet2(_locales, this),
134
+ messages: Object.fromEntries(_classPrivateFieldGet2(_messages, this)),
135
+ loader: _classPrivateFieldGet2(_loader, this)
136
+ });
137
+ _classPrivateFieldSet2(_active, copy, _classPrivateFieldGet2(_active, this));
138
+ return copy;
139
+ }
140
+ /**
141
+ * Make this `Say` instance immutable.
142
+ */
143
+ freeze() {
144
+ return Object.freeze(this);
145
+ }
146
+ *[_Symbol$iterator]() {
147
+ for (const l of _classPrivateFieldGet2(_locales, this)) yield [this.clone().activate(l).freeze(), l];
148
+ }
149
+ /**
150
+ * Matches the best locale from a list of guesses.
151
+ *
152
+ * @param guesses List of locale guesses
153
+ *
154
+ * @returns The best matching locale, or the first locale if no matches are found
155
+ */
156
+ match(...guesses) {
157
+ const flat = guesses.flat();
158
+ if (flat.length === 0) return _classPrivateFieldGet2(_locales, this)[0];
159
+ for (const guess of flat) {
160
+ if (_classPrivateFieldGet2(_locales, this).includes(guess)) return guess;
161
+ const prefix = guess.split("-")[0];
162
+ if (!prefix) continue;
163
+ const match = _classPrivateFieldGet2(_locales, this).find((l) => l.startsWith(prefix));
164
+ if (match) return match;
165
+ }
166
+ return _classPrivateFieldGet2(_locales, this)[0];
167
+ }
168
+ /**
169
+ * Get the translation for a descriptor.
170
+ *
171
+ * @param descriptor Descriptor to get the translation for
172
+ * @returns The translation string for the descriptor
173
+ * @throws If no locale is active
174
+ * @throws If no messages are available for the active locale
175
+ * @throws If descriptor id is not found
176
+ */
177
+ call(descriptor) {
178
+ return _assertClassBrand(_Say_brand, this, _call).call(this, this.locale, this.messages, descriptor);
179
+ }
180
+ [_Symbol$for](_depth, context, inspect) {
181
+ if (_classPrivateFieldGet2(_active, this)) return `${this.constructor.name}<${inspect(_classPrivateFieldGet2(_active, this), context)}> {}`;
182
+ else return `${this.constructor.name} {}`;
183
+ }
184
+ /**
185
+ * Define a pluralised message.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * say.plural(count, {
190
+ * one: 'You have 1 item',
191
+ * other: 'You have # items',
192
+ * })
193
+ * ```
194
+ *
195
+ * The `#` symbol inside options is replaced with the numeric value.
196
+ * @param _ Number to determine the plural form of
197
+ * @param options Pluralisation rules keyed by CLDR categories or specific numbers
198
+ * @returns The plural form of the number
199
+ * @remark This is a macro and must be used with the relevant saykit plugin
200
+ */
201
+ plural(_, options) {
202
+ throw new Error("'Say#plural' is a macro and must be used with the relevant saykit plugin");
203
+ }
204
+ /**
205
+ * Define an ordinal message (e.g. "1st", "2nd", "3rd").
206
+ * The `#` symbol inside options is replaced with the numeric value.
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * say.ordinal(position, {
211
+ * 1: '#st',
212
+ * 2: '#nd',
213
+ * 3: '#rd',
214
+ * other: '#th',
215
+ * })
216
+ * ```
217
+ *
218
+ * @param _ Number to determine the ordinal form of
219
+ * @param options Ordinal rules keyed by CLDR categories or specific numbers
220
+ * @returns The ordinal form of the number
221
+ * @remark This is a macro and must be used with the relevant saykit plugin
222
+ */
223
+ ordinal(_, options) {
224
+ throw new Error("'Say#ordinal' is a macro and must be used with the relevant saykit plugin");
225
+ }
226
+ /**
227
+ * Define a select message, useful for handling gender, status, or other categories.
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * say.select(gender, {
232
+ * male: 'He',
233
+ * female: 'She',
234
+ * other: 'They',
235
+ * })
236
+ * ```
237
+ *
238
+ * @param _ Selector value to determine which option is chosen
239
+ * @param options A mapping of possible selector values to message strings
240
+ * @returns The select form of the value
241
+ * @remark This is a macro and must be used with the relevant saykit plugin
242
+ */
243
+ select(_, options) {
244
+ throw new Error("'Say#select' is a macro and must be used with the relevant saykit plugin");
245
+ }
246
+ };
247
+ function _call(locale, messages, descriptor) {
248
+ const message = messages[descriptor.id];
249
+ if (typeof message !== "string") throw new Error(`Message for ${descriptor.id} is not a string`);
250
+ const key = `${locale}:${descriptor.id}`;
251
+ const format = _classPrivateFieldGet2(_formats, this).get(key) ?? _classPrivateFieldGet2(_formats, this).set(key, mf1ToMessage(locale, message)).get(key);
252
+ return String(format.format(descriptor));
253
+ }
254
+ //#endregion
255
+ export { Say };
package/package.json CHANGED
@@ -1,4 +1,45 @@
1
1
  {
2
2
  "name": "saykit",
3
- "version": "0.0.0"
3
+ "version": "0.2.0",
4
+ "description": "Type-safe i18n library with compile-time macro transforms",
5
+ "keywords": [
6
+ "i18n",
7
+ "icu",
8
+ "internationalization",
9
+ "localization",
10
+ "macros",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://github.com/k0d13/saykit#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/k0d13/saykit/issues"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/k0d13/saykit.git",
21
+ "directory": "packages/integration"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "!dist/**/*.map"
26
+ ],
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/runtime.d.mts",
31
+ "default": "./dist/runtime.mjs"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "dependencies": {
39
+ "@messageformat/icu-messageformat-1": "^0.12.0"
40
+ },
41
+ "scripts": {
42
+ "check": "tsc --noEmit",
43
+ "build": "tsdown"
44
+ }
4
45
  }