prosebook 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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +594 -2
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +508 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config-CF7HziDa.d.ts +498 -0
  7. package/dist/config-CF7HziDa.d.ts.map +1 -0
  8. package/dist/config-CxVMn19f.js +1146 -0
  9. package/dist/config-CxVMn19f.js.map +1 -0
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +1 -0
  12. package/dist/copy-DXnzXsJa.d.ts +162 -0
  13. package/dist/copy-DXnzXsJa.d.ts.map +1 -0
  14. package/dist/helpers-BPuPGjQP.js +61 -0
  15. package/dist/helpers-BPuPGjQP.js.map +1 -0
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +188 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inspector.d.ts +37 -0
  21. package/dist/inspector.d.ts.map +1 -0
  22. package/dist/inspector.js +572 -0
  23. package/dist/inspector.js.map +1 -0
  24. package/dist/react/index.d.ts +37 -0
  25. package/dist/react/index.d.ts.map +1 -0
  26. package/dist/react/index.js +86 -0
  27. package/dist/react/index.js.map +1 -0
  28. package/dist/sarif-PkPvsB07.js +1033 -0
  29. package/dist/sarif-PkPvsB07.js.map +1 -0
  30. package/dist/scan-B0tZ7y-D.js +2447 -0
  31. package/dist/scan-B0tZ7y-D.js.map +1 -0
  32. package/dist/tools.d.ts +351 -0
  33. package/dist/tools.d.ts.map +1 -0
  34. package/dist/tools.js +4 -0
  35. package/dist/vite.d.ts +15 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +238 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +146 -4
  40. package/src/cli.ts +528 -0
  41. package/src/index.ts +31 -0
  42. package/src/inspector-dialog.ts +450 -0
  43. package/src/inspector.ts +228 -0
  44. package/src/react/index.tsx +112 -0
  45. package/src/runtime/copy.ts +221 -0
  46. package/src/runtime/helpers.ts +66 -0
  47. package/src/runtime/locale.ts +49 -0
  48. package/src/runtime/types.ts +106 -0
  49. package/src/tools/catalog.ts +449 -0
  50. package/src/tools/compile.ts +110 -0
  51. package/src/tools/config.ts +195 -0
  52. package/src/tools/diff.ts +84 -0
  53. package/src/tools/entry.ts +325 -0
  54. package/src/tools/extract.ts +694 -0
  55. package/src/tools/generate.ts +259 -0
  56. package/src/tools/icu.ts +182 -0
  57. package/src/tools/index.ts +78 -0
  58. package/src/tools/lint.ts +828 -0
  59. package/src/tools/lock.ts +95 -0
  60. package/src/tools/mf1.ts +246 -0
  61. package/src/tools/mf2.ts +752 -0
  62. package/src/tools/migrate.ts +138 -0
  63. package/src/tools/presets.ts +159 -0
  64. package/src/tools/pseudo.ts +40 -0
  65. package/src/tools/sarif.ts +80 -0
  66. package/src/tools/scan.ts +173 -0
  67. package/src/tools/syntax.ts +24 -0
  68. package/src/tools/types.ts +137 -0
  69. package/src/vite.ts +252 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The dev inspector the Vite plugin loads into the page: hold Alt and point at a string to see its
3
+ * key, Alt-click to open the entry it came from, Alt-Shift-click to open the catalog entry instead of
4
+ * the translation. Alt-right-click edits the entry on the page instead (`inspector-dialog.ts`).
5
+ *
6
+ * Text on screen carries no key. `<Text>` renders a fragment, so marking it would take a wrapper
7
+ * element that exists only in dev and changes what a stylesheet sees; `t()` returns a bare string. So
8
+ * the runtime reports every read (`ReadHook`) and the text under the pointer is matched back to them.
9
+ */
10
+ import type { DevHooks } from '#src/runtime/types.ts';
11
+
12
+ import { openDialog } from './inspector-dialog.ts';
13
+
14
+ export interface Read {
15
+ key: string;
16
+ /** The locale the message was found in. */
17
+ locale: string;
18
+ }
19
+
20
+ /** Recent reads by the text they came to, least recent text first; each text's keys most recent first. */
21
+ export type Reads = Map<string, Read[]>;
22
+
23
+ /** The character under the pointer: a text node and its index there. */
24
+ export interface Caret {
25
+ node: Node;
26
+ offset: number;
27
+ }
28
+
29
+ export interface Hit {
30
+ text: string;
31
+ read: Read;
32
+ /** How many other keys came to the same text. */
33
+ others: number;
34
+ /** The innermost element holding all of the text. */
35
+ element: Element;
36
+ }
37
+
38
+ // Enough for every string on a busy screen and the ones it showed a moment ago.
39
+ const LIMIT = 2000;
40
+ const ATTRIBUTES = ['placeholder', 'title', 'aria-label', 'alt', 'value'];
41
+
42
+ export function record(reads: Reads, key: string, text: string, locale: string): void {
43
+ if (text.trim() === '') return;
44
+ const seen = reads.get(text) ?? [];
45
+ reads.delete(text);
46
+ reads.set(text, seen[0]?.key === key && seen[0].locale === locale ? seen : [{ key, locale }, ...seen.filter((r) => r.key !== key)]);
47
+ if (reads.size > LIMIT) reads.delete(reads.keys().next().value!);
48
+ }
49
+
50
+ /**
51
+ * The read under the caret: the longest text that covers it, in the innermost element holding all of
52
+ * it. A rich message runs across its elements, so it wins over a shorter text inside one of them.
53
+ * Without a caret (over an input, or padding) an element's whole text, or the target's readable
54
+ * attribute, must equal a read.
55
+ */
56
+ export function keyAt(target: Element, caret: Caret | undefined, reads: Reads): Hit | undefined {
57
+ let best: Hit | undefined;
58
+ for (let el: Element | null = target; el !== null; el = el.parentElement) {
59
+ const text = el.textContent ?? '';
60
+ const at = caret === undefined ? undefined : offsetIn(el, caret);
61
+ for (const [said, seen] of reads) {
62
+ if (said.length <= (best?.text.length ?? 0)) continue;
63
+ if (at === undefined ? text.trim() === said.trim() : covers(text, said, at))
64
+ best = { text: said, read: seen[0]!, others: seen.length - 1, element: el };
65
+ }
66
+ if (best !== undefined || el !== target) continue;
67
+ for (const name of ATTRIBUTES) {
68
+ const value = el.getAttribute(name);
69
+ const seen = value === null ? undefined : reads.get(value);
70
+ if (value !== null && seen !== undefined) return { text: value, read: seen[0]!, others: seen.length - 1, element: el };
71
+ }
72
+ }
73
+ return best;
74
+ }
75
+
76
+ /** Whether an occurrence of `said` in `text` holds the character at `at`. */
77
+ function covers(text: string, said: string, at: number): boolean {
78
+ const start = text.indexOf(said, Math.max(0, at - said.length + 1));
79
+ return start !== -1 && start <= at;
80
+ }
81
+
82
+ /** The caret's index in the element's text, or nothing when the caret is outside it. */
83
+ function offsetIn(el: Element, caret: Caret): number | undefined {
84
+ if (caret.node.nodeType !== Node.TEXT_NODE || !el.contains(caret.node)) return undefined;
85
+ const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
86
+ let offset = 0;
87
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
88
+ if (node === caret.node) return offset + caret.offset;
89
+ offset += node.textContent?.length ?? 0;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /** The character under a point on screen, where the browser can say. */
95
+ export function caretAt(x: number, y: number): Caret | undefined {
96
+ if (typeof document.caretPositionFromPoint !== 'function') return undefined;
97
+ const position = document.caretPositionFromPoint(x, y);
98
+ if (position === null) return undefined;
99
+ const { offsetNode: node, offset } = position;
100
+ // A caret position sits between two characters, and where one string ends the next may begin: take
101
+ // the character the pointer is over, or the one before (past the end of a line, in padding).
102
+ if (node.nodeType !== Node.TEXT_NODE || offset === 0) return { node, offset };
103
+ const range = document.createRange();
104
+ range.setStart(node, offset);
105
+ range.setEnd(node, Math.min(offset + 1, node.textContent?.length ?? 0));
106
+ const r = range.getBoundingClientRect();
107
+ const over = !range.collapsed && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
108
+ return { node, offset: over ? offset : offset - 1 };
109
+ }
110
+
111
+ export interface InspectorOptions {
112
+ /** The dev server's base URL, ending in `/`. */
113
+ base: string;
114
+ }
115
+
116
+ /** Starts listening for reads and for Alt on the page. Called by the module the Vite plugin serves. */
117
+ export function install({ base }: InspectorOptions): void {
118
+ const reads: Reads = new Map();
119
+ (globalThis as DevHooks).__PROSEBOOK_READ__ = (key, text, locale) => record(reads, key, text, locale);
120
+
121
+ const box = document.createElement('div');
122
+ const label = document.createElement('div');
123
+ box.append(label);
124
+ Object.assign(box.style, {
125
+ position: 'fixed',
126
+ display: 'none',
127
+ zIndex: '2147483647',
128
+ pointerEvents: 'none',
129
+ boxSizing: 'border-box',
130
+ outline: '2px solid #7c3aed',
131
+ background: 'rgb(124 58 237 / 0.08)',
132
+ });
133
+ Object.assign(label.style, {
134
+ position: 'absolute',
135
+ left: '-2px',
136
+ padding: '1px 6px',
137
+ font: '12px/1.5 ui-monospace, monospace',
138
+ whiteSpace: 'nowrap',
139
+ color: '#fff',
140
+ background: '#7c3aed',
141
+ });
142
+
143
+ let pointer: { x: number; y: number } | undefined;
144
+ let frame = 0;
145
+
146
+ const hitAt = (x: number, y: number) => {
147
+ const target = document.elementFromPoint(x, y);
148
+ return target === null ? undefined : keyAt(target, caretAt(x, y), reads);
149
+ };
150
+ const hide = () => {
151
+ box.style.display = 'none';
152
+ };
153
+ const show = () => {
154
+ frame = 0;
155
+ const hit = pointer === undefined ? undefined : hitAt(pointer.x, pointer.y);
156
+ if (hit === undefined) return hide();
157
+ const r = hit.element.getBoundingClientRect();
158
+ Object.assign(box.style, { display: 'block', left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
159
+ // Below the box when there is no room above it.
160
+ Object.assign(label.style, r.top < 24 ? { top: '100%', bottom: '' } : { top: '', bottom: '100%' });
161
+ label.textContent = `${hit.read.key} · ${hit.read.locale}${hit.others > 0 ? ` (+${hit.others} with this text)` : ''}`;
162
+ if (!box.isConnected) document.body.append(box);
163
+ };
164
+ const update = (e: MouseEvent | KeyboardEvent) => {
165
+ if (!e.altKey) return hide();
166
+ if (frame === 0) frame = requestAnimationFrame(show);
167
+ };
168
+
169
+ window.addEventListener(
170
+ 'mousemove',
171
+ (e) => {
172
+ pointer = { x: e.clientX, y: e.clientY };
173
+ update(e);
174
+ },
175
+ { capture: true, passive: true },
176
+ );
177
+ window.addEventListener('keydown', update, true);
178
+ window.addEventListener('keyup', update, true);
179
+ window.addEventListener('blur', hide);
180
+ // Alt-click on a link would otherwise download it, and on a field focus it.
181
+ window.addEventListener(
182
+ 'mousedown',
183
+ (e) => {
184
+ if (e.altKey && hitAt(e.clientX, e.clientY) !== undefined) e.preventDefault();
185
+ },
186
+ true,
187
+ );
188
+ window.addEventListener(
189
+ 'click',
190
+ (e) => {
191
+ if (!e.altKey) return;
192
+ const hit = hitAt(e.clientX, e.clientY);
193
+ if (hit === undefined) return;
194
+ e.preventDefault();
195
+ e.stopPropagation();
196
+ void open(base, hit.read, e.shiftKey);
197
+ },
198
+ true,
199
+ );
200
+ window.addEventListener(
201
+ 'contextmenu',
202
+ (e) => {
203
+ if (!e.altKey) return;
204
+ const hit = hitAt(e.clientX, e.clientY);
205
+ if (hit === undefined) return;
206
+ e.preventDefault();
207
+ e.stopPropagation();
208
+ hide();
209
+ const catalog = e.shiftKey;
210
+ void openDialog({
211
+ base,
212
+ key: hit.read.key,
213
+ locale: catalog ? undefined : hit.read.locale,
214
+ x: e.clientX,
215
+ y: e.clientY,
216
+ openFile: () => void open(base, hit.read, catalog),
217
+ });
218
+ },
219
+ true,
220
+ );
221
+ }
222
+
223
+ async function open(base: string, read: Read, catalog: boolean): Promise<void> {
224
+ const query = new URLSearchParams({ key: read.key, locale: read.locale });
225
+ if (catalog) query.set('catalog', '');
226
+ const res = await fetch(`${base}__prosebook/open?${query}`);
227
+ if (!res.ok) console.warn(`prosebook: ${await res.text()}`);
228
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `prosebook/react`: the catalog in components.
3
+ *
4
+ * `useCopy()` re-renders its component when the locale changes, and its `rich` returns a ready
5
+ * `ReactNode`: elements are keyed and tag functions receive keyed children, so a sentence with a
6
+ * link inside it is one catalog entry and one call, with no wrapper to remember.
7
+ */
8
+ import { createContext, Fragment, isValidElement, use, useMemo, useSyncExternalStore, type ReactNode } from 'react';
9
+
10
+ import type { Copy } from '#src/runtime/copy.ts';
11
+ import type { MessageShape, RegisteredLocale, RegisteredMessages, RichKey, RichParam, TagFn } from '#src/runtime/types.ts';
12
+
13
+ type M = RegisteredMessages;
14
+ type L = RegisteredLocale;
15
+
16
+ /** `Copy`, with `rich` returning a `ReactNode`. */
17
+ export interface ReactCopy extends Omit<Copy<M, L>, 'rich'> {
18
+ rich<K extends RichKey<M>>(this: void, key: K, ...values: RichParam<M[K], ReactNode>): ReactNode;
19
+ }
20
+
21
+ const CopyContext = createContext<{ copy: Copy<M, L>; tags: Tags } | null>(null);
22
+
23
+ type Tags = { readonly [tag: string]: TagFn<ReactNode> };
24
+
25
+ export interface CopyProviderProps {
26
+ copy: Copy<M, L> | Copy;
27
+ /** Tag renderers every rich read under this provider gets, beside the copy's own. */
28
+ tags?: Tags;
29
+ children?: ReactNode;
30
+ }
31
+
32
+ export function CopyProvider({ copy, tags, children }: CopyProviderProps) {
33
+ const value = useMemo(() => ({ copy: copy as Copy<M, L>, tags: { ...(copy.tags as unknown as Tags), ...tags } }), [copy, tags]);
34
+ return <CopyContext value={value}>{children}</CopyContext>;
35
+ }
36
+
37
+ /** The catalog, in the current locale. Re-renders the component when the locale changes, or in dev the catalog. */
38
+ export function useCopy(): ReactCopy {
39
+ const context = use(CopyContext);
40
+ if (context === null) throw new Error('prosebook: useCopy() needs a <CopyProvider> above it');
41
+ const { copy, tags } = context;
42
+ const version = useSyncExternalStore(
43
+ copy.subscribe,
44
+ () => copy.version,
45
+ () => copy.version,
46
+ );
47
+ return useMemo(() => readerFor(copy, tags, version), [copy, tags, version]);
48
+ }
49
+
50
+ /** The current locale alone, for components that only format or switch. */
51
+ export function useLocale(): L {
52
+ const context = use(CopyContext);
53
+ if (context === null) throw new Error('prosebook: useLocale() needs a <CopyProvider> above it');
54
+ return useSyncExternalStore(
55
+ context.copy.subscribe,
56
+ () => context.copy.locale,
57
+ () => context.copy.locale,
58
+ );
59
+ }
60
+
61
+ // Takes the version only so the reader is rebuilt, and its identity changes, when what it reads does.
62
+ function readerFor(copy: Copy<M, L>, tags: Tags, version: number): ReactCopy {
63
+ return {
64
+ locale: copy.locale,
65
+ version,
66
+ locales: copy.locales,
67
+ source: copy.source,
68
+ tags: copy.tags,
69
+ ready: copy.ready,
70
+ t: copy.t,
71
+ has: copy.has,
72
+ setLocale: copy.setLocale,
73
+ subscribe: copy.subscribe,
74
+ number: copy.number,
75
+ date: copy.date,
76
+ list: copy.list,
77
+ relative: copy.relative,
78
+ rich(key, ...values) {
79
+ const given = (values[0] ?? {}) as Record<string, unknown>;
80
+ const merged: Record<string, unknown> = { ...tags, ...given };
81
+ for (const [name, value] of Object.entries(merged))
82
+ if (typeof value === 'function') merged[name] = (chunks: unknown[]) => (value as TagFn<ReactNode>)(keyed(chunks));
83
+
84
+ return <>{keyed(copy.rich(key as never, merged as never))}</>;
85
+ },
86
+ };
87
+ }
88
+
89
+ /** Text joined where it can be, and each element under its own key, so React never warns about a list. */
90
+ function keyed(parts: unknown[]): (string | ReactNode)[] {
91
+ if (parts.every((p) => typeof p === 'string')) return [parts.join('')];
92
+ return parts.map((p, i) =>
93
+ // oxlint-disable-next-line react/no-array-index-key -- the parts of one message never reorder, so positions are stable keys
94
+ isValidElement(p) || typeof p === 'object' ? <Fragment key={i}>{p as ReactNode}</Fragment> : (p as ReactNode),
95
+ );
96
+ }
97
+
98
+ type ValuesProp<S extends MessageShape> = {} extends S['args'] & { [T in S['tags']]: unknown }
99
+ ? { values?: S['args'] & { [T in S['tags']]?: TagFn<ReactNode> } }
100
+ : { values: RichParam<S, ReactNode>[0] };
101
+
102
+ export type TextProps<K extends Extract<keyof M, string>> = { id: K } & ValuesProp<M[K]>;
103
+
104
+ /**
105
+ * A message as an element: `<Text id="cart.items" values={{ count }} />`. Rich messages render their
106
+ * tags through the provider's renderers and any given in `values`.
107
+ */
108
+ export function Text<K extends Extract<keyof M, string>>(props: TextProps<K>) {
109
+ const copy = useCopy();
110
+ const values = (props as { values?: object }).values;
111
+ return <>{copy.rich(props.id as RichKey<M>, values)}</>;
112
+ }
@@ -0,0 +1,221 @@
1
+ import { createHelpers, flatten } from './helpers.ts';
2
+ import { chainOf, negotiate } from './locale.ts';
3
+ import type {
4
+ AnyTagFn,
5
+ ArgsParam,
6
+ Chunks,
7
+ Compiled,
8
+ CompiledMessages,
9
+ Helpers,
10
+ DevHooks,
11
+ MessageShapes,
12
+ RichKey,
13
+ RichParam,
14
+ Values,
15
+ } from './types.ts';
16
+
17
+ type Loader = () => Promise<{ default: CompiledMessages }>;
18
+
19
+ /** A generated catalog: which locales exist, which ship in the bundle and how to fetch the rest. */
20
+ export interface Catalog<M extends MessageShapes = MessageShapes, L extends string = string> {
21
+ /** The locale the catalog is written in, which every other falls back to. */
22
+ readonly source: L;
23
+ readonly locales: readonly L[];
24
+ /** Locales whose messages ship in the bundle. Always holds the source. */
25
+ readonly messages: { readonly [locale in L]?: CompiledMessages };
26
+ /** Locales fetched when first asked for. */
27
+ readonly loaders: { readonly [locale in L]?: Loader };
28
+ /** Type-only: carries the message shapes from the generated module to `createCopy`. */
29
+ readonly '~messages'?: M;
30
+ }
31
+
32
+ /** Ties a generated catalog to its message shapes. Called by generated code. */
33
+ export function defineCatalog<M extends MessageShapes, L extends string = string>(
34
+ catalog: Omit<Catalog<M, L>, '~messages'>,
35
+ ): Catalog<M, L> {
36
+ return catalog;
37
+ }
38
+
39
+ export interface CopyOptions<L extends string> {
40
+ /** The locale to start in, or the locales a reader prefers, most preferred first. Defaults to the source. */
41
+ locale?: L | (string & {}) | readonly string[];
42
+ /** Tag renderers every rich read gets unless it supplies its own, such as `b` or `link`. */
43
+ tags?: { [tag: string]: AnyTagFn };
44
+ /** What a key the catalog lacks reads as. Defaults to the key itself. */
45
+ onMissing?: (key: string, locale: L) => string;
46
+ }
47
+
48
+ /** Its methods don't use `this`, so they can be passed around and destructured: `const { t } = copy`. */
49
+ export interface Copy<M extends MessageShapes = MessageShapes, L extends string = string> {
50
+ /** The locale messages are coming back in. */
51
+ readonly locale: L;
52
+ readonly locales: readonly L[];
53
+ readonly source: L;
54
+ /** The tag renderers rich reads fall back to, from `CopyOptions.tags`. */
55
+ readonly tags: { readonly [tag: string]: AnyTagFn };
56
+ /** Settles once the starting locale's messages are loaded. */
57
+ readonly ready: Promise<void>;
58
+ /** A message as plain text. Tags render as the text they wrap. */
59
+ t<K extends keyof M & string>(this: void, key: K, ...args: ArgsParam<M[K]>): string;
60
+ /** A message with elements inside it: each tag's function turns what it wraps into a node. */
61
+ rich<K extends RichKey<M>, N = unknown>(this: void, key: K, ...values: RichParam<M[K], N>): Chunks<N>;
62
+ has(this: void, key: string): key is keyof M & string;
63
+ /** Switches locale once its messages (and its fallbacks') are loaded. */
64
+ setLocale(this: void, next: L | (string & {}) | readonly string[]): Promise<void>;
65
+ /** Counts changes to what messages read as: each locale switch, and each catalog edit in dev. */
66
+ readonly version: number;
67
+ /** Called after each locale switch, and in dev after each catalog edit. Returns the unsubscribe. */
68
+ subscribe(this: void, listener: () => void): () => void;
69
+ /** Formats a number in the current locale. */
70
+ number(this: void, value: number | bigint, options?: Intl.NumberFormatOptions): string;
71
+ /** Formats a date in the current locale. */
72
+ date(this: void, value: Date | number, options?: Intl.DateTimeFormatOptions): string;
73
+ /** Joins items as a list in the current locale: `a, b and c`. */
74
+ list(this: void, items: Iterable<string>, options?: Intl.ListFormatOptions): string;
75
+ /** `in 3 days`, `2 hours ago`, in the current locale. */
76
+ relative(this: void, value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
77
+ }
78
+
79
+ type Layer = { messages: CompiledMessages; h: Helpers };
80
+
81
+ /** A message as the text it puts on screen: rendered again with its tags left as their text. */
82
+ function textOf(message: Compiled, values: Values, h: Helpers): string {
83
+ if (typeof message === 'string') return message;
84
+ const plain = Object.fromEntries(Object.entries(values).filter(([, v]) => typeof v !== 'function'));
85
+ return flatten(message(plain, h)).join('');
86
+ }
87
+
88
+ /** A catalog reader with a current locale. Create one per app, or one per request on a server. */
89
+ export function createCopy<M extends MessageShapes, L extends string>(catalog: Catalog<M, L>, options: CopyOptions<L> = {}): Copy<M, L> {
90
+ const loaded = new Map<L, Layer>();
91
+ const pending = new Map<L, Promise<Layer>>();
92
+ const listeners = new Set<() => void>();
93
+ const defaultTags = options.tags ?? {};
94
+ const formatters = new Map<string, Intl.NumberFormat | Intl.DateTimeFormat | Intl.ListFormat | Intl.RelativeTimeFormat>();
95
+
96
+ for (const [locale, messages] of Object.entries(catalog.messages) as [L, CompiledMessages][])
97
+ loaded.set(locale, { messages, h: createHelpers(locale) });
98
+
99
+ let locale = catalog.source;
100
+ let chain: Layer[] = layers(chainOf(locale, catalog.locales, catalog.source));
101
+ let latest = 0;
102
+ let version = 0;
103
+
104
+ function changed() {
105
+ version++;
106
+ for (const listener of listeners) listener();
107
+ }
108
+
109
+ // A layer's messages are swapped in place, so the chain that holds it reads the new ones.
110
+ (globalThis as DevHooks).__PROSEBOOK_HOT__?.add((l, messages) => {
111
+ const layer = loaded.get(l as L);
112
+ if (layer === undefined) return;
113
+ layer.messages = messages;
114
+ changed();
115
+ });
116
+
117
+ function layers(locales: L[]): Layer[] {
118
+ return locales.flatMap((l) => loaded.get(l) ?? []);
119
+ }
120
+
121
+ function load(l: L): Promise<Layer> {
122
+ const done = loaded.get(l);
123
+ if (done) return Promise.resolve(done);
124
+ let p = pending.get(l);
125
+ if (p === undefined) {
126
+ // oxlint-disable-next-line typescript/no-unsafe-enum-assignment -- `L` is a string union, not an enum; the rule misreads the mapped type
127
+ const loader = catalog.loaders[l];
128
+ if (loader === undefined) return Promise.reject(new Error(`prosebook: no messages for locale "${l}"`));
129
+ p = loader().then((mod) => {
130
+ const layer = { messages: mod.default, h: createHelpers(l) };
131
+ loaded.set(l, layer);
132
+ pending.delete(l);
133
+ return layer;
134
+ });
135
+ p.catch(() => pending.delete(l));
136
+ pending.set(l, p);
137
+ }
138
+ return p;
139
+ }
140
+
141
+ async function setLocale(next: L | (string & {}) | readonly string[]): Promise<void> {
142
+ const target = negotiate(next, catalog.locales, catalog.source);
143
+ const ticket = ++latest;
144
+ const locales = chainOf(target, catalog.locales, catalog.source);
145
+ await Promise.all(locales.map(load));
146
+ // A slower load must not undo a later switch.
147
+ if (ticket !== latest || target === locale) return;
148
+ locale = target;
149
+ chain = layers(locales);
150
+ formatters.clear();
151
+ changed();
152
+ }
153
+
154
+ function find(key: string): [Compiled, Helpers] | undefined {
155
+ for (const layer of chain) {
156
+ const message = layer.messages[key];
157
+ if (message !== undefined) return [message, layer.h];
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ function render(key: string, values: Values): unknown {
163
+ const found = find(key);
164
+ if (found === undefined) return options.onMissing ? options.onMissing(key, locale) : key;
165
+ const [message, h] = found;
166
+ const out = typeof message === 'string' ? message : message(values, h);
167
+ const read = (globalThis as DevHooks).__PROSEBOOK_READ__;
168
+ if (read !== undefined) read(key, typeof out === 'string' ? out : textOf(message, values, h), h.locale);
169
+ return out;
170
+ }
171
+
172
+ function formatter<F>(kind: string, options: object | undefined, make: () => F): F {
173
+ const id = `${kind}${JSON.stringify(options ?? {})}`;
174
+ let made = formatters.get(id) as F | undefined;
175
+ if (made === undefined) formatters.set(id, (made = make()) as never);
176
+ return made;
177
+ }
178
+
179
+ const ready = options.locale === undefined ? Promise.resolve() : setLocale(options.locale);
180
+
181
+ return {
182
+ get locale() {
183
+ return locale;
184
+ },
185
+ get version() {
186
+ return version;
187
+ },
188
+ locales: catalog.locales,
189
+ source: catalog.source,
190
+ tags: defaultTags,
191
+ ready,
192
+ t(key, ...args) {
193
+ const out = render(key, (args[0] as Values | undefined) ?? {});
194
+ return typeof out === 'string' ? out : flatten(out).join('');
195
+ },
196
+ rich(key, ...values) {
197
+ const out = render(key, { ...defaultTags, ...(values[0] as Values | undefined) });
198
+ return flatten(out) as Chunks<never>;
199
+ },
200
+ has(key): key is keyof M & string {
201
+ return find(key) !== undefined;
202
+ },
203
+ setLocale,
204
+ subscribe(listener) {
205
+ listeners.add(listener);
206
+ return () => listeners.delete(listener);
207
+ },
208
+ number(value, opts) {
209
+ return formatter('n', opts, () => new Intl.NumberFormat(locale, opts)).format(value);
210
+ },
211
+ date(value, opts) {
212
+ return formatter('d', opts, () => new Intl.DateTimeFormat(locale, opts)).format(value);
213
+ },
214
+ list(items, opts) {
215
+ return formatter('l', opts, () => new Intl.ListFormat(locale, opts)).format(items);
216
+ },
217
+ relative(value, unit, opts) {
218
+ return formatter('r', opts, () => new Intl.RelativeTimeFormat(locale, opts)).format(value, unit);
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,66 @@
1
+ import type { Arms, Helpers, Values } from './types.ts';
2
+
3
+ /**
4
+ * The helpers compiled messages call, for one locale. Formatters are built once per options object:
5
+ * the generator hoists each message's options to a module-level constant, so identity is a stable key.
6
+ */
7
+ export function createHelpers(locale: string): Helpers {
8
+ const numbers = new Map<Intl.NumberFormatOptions | undefined, Intl.NumberFormat>();
9
+ const dates = new Map<Intl.DateTimeFormatOptions | undefined, Intl.DateTimeFormat>();
10
+ let cardinal: Intl.PluralRules | undefined;
11
+ let ordinal: Intl.PluralRules | undefined;
12
+
13
+ return {
14
+ locale,
15
+ n(value, options) {
16
+ let format = numbers.get(options);
17
+ if (format === undefined) numbers.set(options, (format = new Intl.NumberFormat(locale, options)));
18
+ return format.format(value);
19
+ },
20
+ d(value, options) {
21
+ let format = dates.get(options);
22
+ if (format === undefined) dates.set(options, (format = new Intl.DateTimeFormat(locale, options)));
23
+ return format.format(value);
24
+ },
25
+ p(value, offset, isOrdinal, arms) {
26
+ const exact = arms[`=${value}`];
27
+ if (exact !== undefined) return exact();
28
+ const rules = isOrdinal
29
+ ? (ordinal ??= new Intl.PluralRules(locale, { type: 'ordinal' }))
30
+ : (cardinal ??= new Intl.PluralRules(locale));
31
+ return pick(arms, rules.select(value - offset));
32
+ },
33
+ s(value, arms) {
34
+ return pick(arms, String(value));
35
+ },
36
+ t(values: Values, tag, children) {
37
+ const render = values[tag];
38
+ return typeof render === 'function' ? (render as (chunks: unknown[]) => unknown)(flatten(children)) : children;
39
+ },
40
+ };
41
+ }
42
+
43
+ function pick(arms: Arms, arm: string): unknown {
44
+ // ICU requires an `other` arm, and the generator refuses a message without one.
45
+ return (Object.hasOwn(arms, arm) ? arms[arm]! : arms['other']!)();
46
+ }
47
+
48
+ /**
49
+ * A compiled message's output as a flat list: nested arrays opened, adjacent text joined, and
50
+ * `null`, `undefined` and booleans dropped, as React drops them.
51
+ */
52
+ export function flatten(output: unknown): unknown[] {
53
+ const out: unknown[] = [];
54
+ const walk = (part: unknown) => {
55
+ if (Array.isArray(part)) {
56
+ for (const p of part) walk(p);
57
+ return;
58
+ }
59
+ if (part === null || part === undefined || typeof part === 'boolean') return;
60
+ if (typeof part === 'number' || typeof part === 'bigint') part = String(part);
61
+ if (typeof part === 'string' && typeof out.at(-1) === 'string') out[out.length - 1] += part;
62
+ else out.push(part);
63
+ };
64
+ walk(output);
65
+ return out;
66
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Picks the best available locale for what was asked, in the order it was asked.
3
+ *
4
+ * Each requested tag tries an exact match, then its parents (`pt-BR` finds `pt`), then any locale
5
+ * of the same language (`pt` finds `pt-BR`). Matching ignores case, as BCP 47 does.
6
+ */
7
+ export function negotiate<L extends string>(requested: string | readonly string[], available: readonly L[], fallback: L): L {
8
+ const find = (tag: string) => available.find((a) => a.toLowerCase() === tag.toLowerCase());
9
+ for (const want of typeof requested === 'string' ? [requested] : requested) {
10
+ let tag = want;
11
+ for (;;) {
12
+ const found = find(tag);
13
+ if (found !== undefined) return found;
14
+ const cut = tag.lastIndexOf('-');
15
+ if (cut < 0) break;
16
+ tag = tag.slice(0, cut);
17
+ }
18
+ const language = languageOf(want);
19
+ const sibling = available.find((a) => languageOf(a) === language);
20
+ if (sibling !== undefined) return sibling;
21
+ }
22
+ return fallback;
23
+ }
24
+
25
+ /** The locales a message is looked up in, in order: the locale, its available parents, then the source. */
26
+ export function chainOf<L extends string>(locale: L, available: readonly L[], source: L): L[] {
27
+ const chain: L[] = [locale];
28
+ let tag: string = locale;
29
+ while (tag.includes('-')) {
30
+ tag = tag.slice(0, tag.lastIndexOf('-'));
31
+ const parent = available.find((a) => a.toLowerCase() === tag.toLowerCase());
32
+ if (parent !== undefined && !chain.includes(parent)) chain.push(parent);
33
+ }
34
+ if (!chain.includes(source)) chain.push(source);
35
+ return chain;
36
+ }
37
+
38
+ /** The locales the browser or the environment asks for, most preferred first. */
39
+ export function preferredLocales(): string[] {
40
+ const nav = (globalThis as { navigator?: { languages?: readonly string[]; language?: string } }).navigator;
41
+ if (nav?.languages && nav.languages.length > 0) return [...nav.languages];
42
+ if (nav?.language) return [nav.language];
43
+ const resolved = Intl.DateTimeFormat().resolvedOptions().locale;
44
+ return resolved ? [resolved] : [];
45
+ }
46
+
47
+ function languageOf(tag: string): string {
48
+ return tag.split('-', 1)[0]!.toLowerCase();
49
+ }