prosebook 0.4.0 → 0.5.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 (122) hide show
  1. package/README.md +288 -28
  2. package/dist/bind-BIZNv8nU.js +62 -0
  3. package/dist/bind-BIZNv8nU.js.map +1 -0
  4. package/dist/bind-ZS86jHXk.d.ts +31 -0
  5. package/dist/bind-ZS86jHXk.d.ts.map +1 -0
  6. package/dist/bun.d.ts +11 -2
  7. package/dist/bun.d.ts.map +1 -1
  8. package/dist/bun.js +77 -26
  9. package/dist/bun.js.map +1 -1
  10. package/dist/{bundle-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
  11. package/dist/bundle-CLHY_97D.d.ts.map +1 -0
  12. package/dist/bundle-Ut6Y2X6N.js +217 -0
  13. package/dist/bundle-Ut6Y2X6N.js.map +1 -0
  14. package/dist/cli.js +55 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/{config-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
  17. package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/config-CzaJJQ60.d.ts.map +1 -1
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  21. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  22. package/dist/copy-Bv6S-Hr2.js +235 -0
  23. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  24. package/dist/endpoints-Dhgowovz.js +217 -0
  25. package/dist/endpoints-Dhgowovz.js.map +1 -0
  26. package/dist/generate-1BeQAd1O.d.ts +72 -0
  27. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  28. package/dist/{generate-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
  29. package/dist/generate-CKv86Iki.js.map +1 -0
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +2 -2
  32. package/dist/next-loader.d.ts +9 -2
  33. package/dist/next-loader.d.ts.map +1 -1
  34. package/dist/next-loader.js +12 -3
  35. package/dist/next-loader.js.map +1 -1
  36. package/dist/next.d.ts +10 -2
  37. package/dist/next.d.ts.map +1 -1
  38. package/dist/next.js +94 -21
  39. package/dist/next.js.map +1 -1
  40. package/dist/preact/index.d.ts +34 -0
  41. package/dist/preact/index.d.ts.map +1 -0
  42. package/dist/preact/index.js +72 -0
  43. package/dist/preact/index.js.map +1 -0
  44. package/dist/react/index.d.ts +5 -14
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +9 -33
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/server.d.ts +1 -1
  49. package/dist/react/server.js +1 -1
  50. package/dist/{sarif-B5nrMqrT.js → sarif-BnwYC78_.js} +8 -98
  51. package/dist/sarif-BnwYC78_.js.map +1 -0
  52. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  53. package/dist/scan-C0ap4Mpp.js.map +1 -0
  54. package/dist/solid/index.d.ts +29 -0
  55. package/dist/solid/index.d.ts.map +1 -0
  56. package/dist/solid/index.js +48 -0
  57. package/dist/solid/index.js.map +1 -0
  58. package/dist/svelte/Text.svelte +13 -0
  59. package/dist/svelte/Text.svelte.d.ts +20 -0
  60. package/dist/svelte/index.d.ts +31 -0
  61. package/dist/svelte/index.d.ts.map +1 -0
  62. package/dist/svelte/index.js +35 -0
  63. package/dist/svelte/index.js.map +1 -0
  64. package/dist/tools.d.ts +43 -64
  65. package/dist/tools.d.ts.map +1 -1
  66. package/dist/tools.js +5 -5
  67. package/dist/vite.d.ts +12 -2
  68. package/dist/vite.d.ts.map +1 -1
  69. package/dist/vite.js +82 -25
  70. package/dist/vite.js.map +1 -1
  71. package/dist/vue/index.d.ts +43 -0
  72. package/dist/vue/index.d.ts.map +1 -0
  73. package/dist/vue/index.js +76 -0
  74. package/dist/vue/index.js.map +1 -0
  75. package/package.json +63 -7
  76. package/src/bun.ts +77 -20
  77. package/src/cli.ts +62 -8
  78. package/src/index.ts +1 -1
  79. package/src/next-loader.ts +17 -5
  80. package/src/next.ts +120 -16
  81. package/src/preact/index.ts +91 -0
  82. package/src/react/index.tsx +12 -42
  83. package/src/runtime/bind.ts +96 -0
  84. package/src/runtime/copy.ts +148 -24
  85. package/src/solid/index.ts +67 -0
  86. package/src/svelte/Text.svelte +13 -0
  87. package/src/svelte/Text.svelte.d.ts +20 -0
  88. package/src/svelte/index.ts +65 -0
  89. package/src/tools/bundle.ts +143 -2
  90. package/src/tools/catalog.ts +10 -1
  91. package/src/tools/chunks.ts +142 -0
  92. package/src/tools/config.ts +1 -1
  93. package/src/tools/entry.ts +19 -20
  94. package/src/tools/exchange.ts +275 -0
  95. package/src/tools/extract.ts +2 -2
  96. package/src/tools/generate.ts +7 -1
  97. package/src/tools/index.ts +2 -1
  98. package/src/tools/native/android.ts +10 -25
  99. package/src/tools/native/apple.ts +70 -8
  100. package/src/tools/native/date.ts +219 -0
  101. package/src/tools/native/kotlin.ts +47 -6
  102. package/src/tools/native/model.ts +42 -16
  103. package/src/tools/native/printf.ts +46 -4
  104. package/src/tools/native/rust.ts +86 -5
  105. package/src/tools/native/swift.ts +62 -6
  106. package/src/tools/scan.ts +146 -25
  107. package/src/tools/todo.ts +6 -1
  108. package/src/vite.ts +121 -28
  109. package/src/vue/index.ts +117 -0
  110. package/dist/bundle-CpUIWb9j.d.ts.map +0 -1
  111. package/dist/bundle-DboItAre.js +0 -111
  112. package/dist/bundle-DboItAre.js.map +0 -1
  113. package/dist/compile-ClK9htKr.d.ts +0 -16
  114. package/dist/compile-ClK9htKr.d.ts.map +0 -1
  115. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  116. package/dist/copy-Bo_FIoLy.js +0 -154
  117. package/dist/copy-Bo_FIoLy.js.map +0 -1
  118. package/dist/endpoints-BgG_5Vpm.js +0 -96
  119. package/dist/endpoints-BgG_5Vpm.js.map +0 -1
  120. package/dist/generate-BW3N8EGb.js.map +0 -1
  121. package/dist/sarif-B5nrMqrT.js.map +0 -1
  122. package/dist/scan-Bx0_yDSi.js.map +0 -1
@@ -7,16 +7,14 @@
7
7
  */
8
8
  import { createContext, Fragment, isValidElement, use, useMemo, useSyncExternalStore, type ReactNode } from 'react';
9
9
 
10
+ import { bindCopy, type BoundCopy, type TextProps as Props } from '#src/runtime/bind.ts';
10
11
  import type { Copy } from '#src/runtime/copy.ts';
11
- import type { MessageShape, RegisteredLocale, RegisteredMessages, RichKey, RichParam, TagFn } from '#src/runtime/types.ts';
12
+ import type { RegisteredLocale, RegisteredMessages, TagFn } from '#src/runtime/types.ts';
12
13
 
13
14
  type M = RegisteredMessages;
14
- type L = RegisteredLocale;
15
15
 
16
16
  /** `Copy`, with `rich` returning a `ReactNode`. */
17
- export interface ReactCopy extends Omit<Copy, 'rich'> {
18
- rich<K extends RichKey<M>>(this: void, key: K, ...values: RichParam<M[K], ReactNode>): ReactNode;
19
- }
17
+ export type ReactCopy = BoundCopy<ReactNode>;
20
18
 
21
19
  const CopyContext = createContext<{ copy: Copy; tags: Tags } | null>(null);
22
20
 
@@ -29,8 +27,8 @@ export interface CopyProviderProps {
29
27
  children?: ReactNode;
30
28
  }
31
29
 
32
- export function CopyProvider({ copy, tags, children }: CopyProviderProps) {
33
- const value = useMemo(() => ({ copy: copy, tags: { ...(copy.tags as unknown as Tags), ...tags } }), [copy, tags]);
30
+ export function CopyProvider({ copy, tags = {}, children }: CopyProviderProps) {
31
+ const value = useMemo(() => ({ copy, tags }), [copy, tags]);
34
32
  return <CopyContext value={value}>{children}</CopyContext>;
35
33
  }
36
34
 
@@ -44,11 +42,12 @@ export function useCopy(): ReactCopy {
44
42
  () => copy.version,
45
43
  () => copy.version,
46
44
  );
47
- return useMemo(() => readerFor(copy, tags, version), [copy, tags, version]);
45
+ // Rebuilt on each version, so its identity changes when what it reads does.
46
+ return useMemo(() => bindCopy(copy, tags, { tag, whole }), [copy, tags, version]);
48
47
  }
49
48
 
50
49
  /** The current locale alone, for components that only format or switch. */
51
- export function useLocale(): L {
50
+ export function useLocale(): RegisteredLocale {
52
51
  const context = use(CopyContext);
53
52
  if (context === null) throw new Error('prosebook: useLocale() needs a <CopyProvider> above it');
54
53
  return useSyncExternalStore(
@@ -58,33 +57,8 @@ export function useLocale(): L {
58
57
  );
59
58
  }
60
59
 
61
- // Takes the version only so the reader is rebuilt, and its identity changes, when what it reads does.
62
- function readerFor(copy: Copy, 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
- }
60
+ const tag = (render: (chunks: never) => unknown, chunks: unknown[]) => render(keyed(chunks) as never);
61
+ const whole = (parts: unknown[]): ReactNode => <>{keyed(parts)}</>;
88
62
 
89
63
  /** Text joined where it can be, and each element under its own key, so React never warns about a list. */
90
64
  function keyed(parts: unknown[]): (string | ReactNode)[] {
@@ -95,11 +69,7 @@ function keyed(parts: unknown[]): (string | ReactNode)[] {
95
69
  );
96
70
  }
97
71
 
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]>;
72
+ export type TextProps<K extends Extract<keyof M, string>> = Props<K, ReactNode>;
103
73
 
104
74
  /**
105
75
  * A message as an element: `<Text id="cart.items" values={{ count }} />`. Rich messages render their
@@ -108,5 +78,5 @@ export type TextProps<K extends Extract<keyof M, string>> = { id: K } & ValuesPr
108
78
  export function Text<K extends Extract<keyof M, string>>(props: TextProps<K>) {
109
79
  const copy = useCopy();
110
80
  const values = (props as { values?: object }).values;
111
- return <>{copy.rich(props.id, values as never)}</>;
81
+ return <>{copy.rich(props.id as never, values as never)}</>;
112
82
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What every framework binding shares: a copy read through the framework's reactivity, with a
3
+ * provider's tag renderers under each rich read's own.
4
+ */
5
+ import type { Copy } from './copy.ts';
6
+ import type { MessageShape, RegisteredLocale, RegisteredMessages, RichKey, RichParam, TagFn } from './types.ts';
7
+
8
+ type M = RegisteredMessages;
9
+
10
+ /**
11
+ * `Copy`, with `rich` returning `R`, a node of the framework's, whose tag renderers make `N`s. The locale is
12
+ * a parameter so it survives a lint that, where no catalog is registered, sees `RegisteredLocale` as the default.
13
+ */
14
+ export interface BoundCopy<N, R = N, L extends string = RegisteredLocale> extends Omit<Copy<M, L>, 'rich'> {
15
+ rich<K extends RichKey<M>>(this: void, key: K, ...values: RichParam<M[K], N>): R;
16
+ }
17
+
18
+ export type Tags = { readonly [tag: string]: (chunks: never) => unknown };
19
+
20
+ export interface Binding<R> {
21
+ /** Called on every read, so the framework knows the reader depends on the copy's version. */
22
+ track?: () => void;
23
+ /** What a tag's renderer is turned into, given what it wraps. Calls it by default. */
24
+ tag?: (render: (chunks: never) => unknown, chunks: unknown[]) => unknown;
25
+ /** What a rich read returns, given its parts. */
26
+ whole: (parts: unknown[]) => R;
27
+ }
28
+
29
+ /** A reader of `copy` whose reads go through `track`, and whose rich reads get `tags` unless they give their own. */
30
+ export function bindCopy<N, R>(copy: Copy, tags: Tags, { track = () => {}, tag, whole }: Binding<R>): BoundCopy<N, R> {
31
+ const merged = { ...copy.tags, ...tags };
32
+ return {
33
+ get locale() {
34
+ track();
35
+ return copy.locale;
36
+ },
37
+ get version() {
38
+ track();
39
+ return copy.version;
40
+ },
41
+ locales: copy.locales,
42
+ source: copy.source,
43
+ tags: copy.tags,
44
+ ready: copy.ready,
45
+ t(key, ...args) {
46
+ track();
47
+ return copy.t(key, ...args);
48
+ },
49
+ has(key): key is keyof M & string {
50
+ track();
51
+ return copy.has(key);
52
+ },
53
+ setLocale: copy.setLocale,
54
+ subscribe: copy.subscribe,
55
+ number(value, options) {
56
+ track();
57
+ return copy.number(value, options);
58
+ },
59
+ date(value, options) {
60
+ track();
61
+ return copy.date(value, options);
62
+ },
63
+ list(items, options) {
64
+ track();
65
+ return copy.list(items, options);
66
+ },
67
+ relative(value, unit, options) {
68
+ track();
69
+ return copy.relative(value, unit, options);
70
+ },
71
+ rich(key, ...values) {
72
+ track();
73
+ const all: Record<string, unknown> = { ...merged, ...(values[0] as object | undefined) };
74
+ if (tag !== undefined) {
75
+ for (const [name, value] of Object.entries(all))
76
+ if (typeof value === 'function') all[name] = (chunks: unknown[]) => tag(value as (chunks: never) => unknown, chunks);
77
+ }
78
+ return whole(copy.rich(key, all as never));
79
+ },
80
+ };
81
+ }
82
+
83
+ type NoValues<S extends MessageShape> = {} extends S['args'] & { [T in S['tags']]: unknown } ? true : false;
84
+
85
+ /**
86
+ * A `<Text>`'s props where the call site renders every tag the provider doesn't: `values` is needed
87
+ * once the message takes arguments or wraps text in a tag.
88
+ */
89
+ export type TextProps<K extends Extract<keyof M, string>, N> = { id: K } & (NoValues<M[K]> extends true
90
+ ? { values?: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N> } }
91
+ : { values: RichParam<M[K], N>[0] });
92
+
93
+ /** A `<Text>`'s props where tags may come from elsewhere (slots, snippets): `values` holds arguments, and tags if you like. */
94
+ export type LooseTextProps<K extends Extract<keyof M, string>, N> = { id: K } & ({} extends M[K]['args']
95
+ ? { values?: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N> } }
96
+ : { values: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N> } });
@@ -16,6 +16,12 @@ import type {
16
16
 
17
17
  type Loader = () => Promise<{ default: CompiledMessages }>;
18
18
 
19
+ /** Messages that ship in the bundle and loaders for the rest, per locale: a catalog's, or one area's. */
20
+ interface Pieces<L extends string = string> {
21
+ readonly messages: { readonly [locale in L]?: CompiledMessages };
22
+ readonly loaders: { readonly [locale in L]?: Loader };
23
+ }
24
+
19
25
  /** A generated catalog: which locales exist, which ship in the bundle and how to fetch the rest. */
20
26
  export interface Catalog<M extends MessageShapes = MessageShapes, L extends string = string> {
21
27
  /** The locale the catalog is written in, which every other falls back to. */
@@ -25,10 +31,49 @@ export interface Catalog<M extends MessageShapes = MessageShapes, L extends stri
25
31
  readonly messages: { readonly [locale in L]?: CompiledMessages };
26
32
  /** Locales fetched when first asked for. */
27
33
  readonly loaders: { readonly [locale in L]?: Loader };
34
+ /**
35
+ * Set when a build splits the catalog by area (the Vite plugin's `split`): `messages` and `loaders`
36
+ * are empty, and each area adds its own (`addArea`) as the code reading it loads.
37
+ */
38
+ readonly split?: { readonly id: string; readonly areas: readonly string[] };
39
+ /**
40
+ * Set in a server build whose client is split (the Vite plugin's `split`): the file the browser
41
+ * loads each area's messages from, per lazy locale, which `CopyOptions.preload` is handed.
42
+ */
43
+ readonly areaFiles?: { readonly [area: string]: { readonly [locale in L]?: string } };
28
44
  /** Type-only: carries the message shapes from the generated module to `createCopy`. */
29
45
  readonly '~messages'?: M;
30
46
  }
31
47
 
48
+ /** A split catalog's areas, as they load, and each copy's hook for taking a new one in. */
49
+ interface Registry {
50
+ areas: Map<string, Pieces>;
51
+ copies: Set<(area: string) => Promise<unknown>>;
52
+ }
53
+
54
+ // By `split.id`, not by catalog: in dev an edit runs the index again, and an area that loads after it
55
+ // must still reach the copies made from the catalog before.
56
+ const registries = new Map<string, Registry>();
57
+
58
+ function registry(id: string): Registry {
59
+ let r = registries.get(id);
60
+ if (r === undefined) registries.set(id, (r = { areas: new Map(), copies: new Set() }));
61
+ return r;
62
+ }
63
+
64
+ /**
65
+ * Adds an area's messages to a split catalog. Called by generated code, from a module beside the code
66
+ * that reads the area; settles once every copy holds the area in its locale, so that code's first
67
+ * render reads it. A locale that fails to load reads in its fallback, and is reported.
68
+ */
69
+ export async function addArea(id: string, area: string, messages: Pieces['messages'], loaders: Pieces['loaders']): Promise<void> {
70
+ const r = registry(id);
71
+ if (r.areas.has(area)) return;
72
+ r.areas.set(area, { messages, loaders });
73
+ for (const result of await Promise.allSettled([...r.copies].map((take) => take(area))))
74
+ if (result.status === 'rejected') console.error(`prosebook: the ${area} area did not load:`, result.reason);
75
+ }
76
+
32
77
  /** Ties a generated catalog to its message shapes. Called by generated code. */
33
78
  export function defineCatalog<M extends MessageShapes, L extends string = string>(
34
79
  catalog: Omit<Catalog<M, L>, '~messages'>,
@@ -45,9 +90,17 @@ export interface CopyOptions<L extends string> {
45
90
  onMissing?: (key: string, locale: L) => string;
46
91
  /**
47
92
  * Told of a placeholder that had no value, or one `Intl` refused (`currency: 'euro'`): the message
48
- * still renders, showing `{name}` there. Defaults to `console.error`.
93
+ * still renders, showing `{name}` there. In a split build, also of a key read before its area
94
+ * loaded. Defaults to `console.error`.
49
95
  */
50
96
  onError?: (error: Error, key: string, locale: L) => void;
97
+ /**
98
+ * On a server, handed each file the browser will load for the areas this copy reads, once each,
99
+ * so the page can link it and the browser fetch it beside the code reading it rather than after:
100
+ * `(href) => preloadModule(href, { as: 'script' })` under React. Only a catalog built for a split
101
+ * client has such files (`Catalog.areaFiles`), and only its lazy locales.
102
+ */
103
+ preload?: (href: string) => void;
51
104
  }
52
105
 
53
106
  /** Its methods don't use `this`, so they can be passed around and destructured: `const { t } = copy`. */
@@ -81,7 +134,19 @@ export interface Copy<M extends MessageShapes = MessageShapes, L extends string
81
134
  relative(this: void, value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
82
135
  }
83
136
 
84
- type Layer = { messages: CompiledMessages; h: Helpers };
137
+ /** A locale's messages, from each area loaded in it so far. */
138
+ type Layer = { messages: CompiledMessages; h: Helpers; areas: Set<string> };
139
+
140
+ /** The one area of a catalog that isn't split. */
141
+ const WHOLE = '';
142
+
143
+ const areaOf = (key: string) => key.slice(0, key.indexOf('.'));
144
+
145
+ function pick(messages: CompiledMessages, keep: (key: string) => boolean): CompiledMessages {
146
+ const out: Record<string, Compiled> = {};
147
+ for (const key in messages) if (keep(key)) out[key] = messages[key]!;
148
+ return out;
149
+ }
85
150
 
86
151
  /** A message as the text it puts on screen: rendered again with its tags left as their text. */
87
152
  function textOf(message: Compiled, values: Values, h: Helpers): string {
@@ -93,24 +158,50 @@ function textOf(message: Compiled, values: Values, h: Helpers): string {
93
158
  /** A catalog reader with a current locale. Create one per app, or one per request on a server. */
94
159
  export function createCopy<M extends MessageShapes, L extends string>(catalog: Catalog<M, L>, options: CopyOptions<L> = {}): Copy<M, L> {
95
160
  const loaded = new Map<L, Layer>();
96
- const pending = new Map<L, Promise<Layer>>();
161
+ const pending = new Map<string, Promise<void>>();
97
162
  const listeners = new Set<() => void>();
98
163
  const defaultTags = options.tags ?? {};
99
164
  const formatters = new Map<string, Intl.NumberFormat | Intl.DateTimeFormat | Intl.ListFormat | Intl.RelativeTimeFormat>();
165
+ const split = catalog.split === undefined ? undefined : registry(catalog.split.id);
100
166
  // The key being rendered, which a helper's report names; none while the inspector renders it again.
101
167
  let reading: string | undefined;
168
+ // The files handed to `preload`, and each area and locale read, which hands on nothing new after.
169
+ const preloaded = new Set<string>();
170
+ const report = (error: Error, key: string, l: L) => {
171
+ if (options.onError) options.onError(error, key, l);
172
+ else console.error(`prosebook: ${key} in ${l}: ${error.message}`);
173
+ };
102
174
  const helpers = (l: L) =>
103
175
  createHelpers(l, (error) => {
104
- if (reading === undefined) return;
105
- if (options.onError) options.onError(error, reading, l);
106
- else console.error(`prosebook: ${reading} in ${l}: ${error.message}`);
176
+ if (reading !== undefined) report(error, reading, l);
107
177
  });
108
178
 
109
- for (const [locale, messages] of Object.entries(catalog.messages) as [L, CompiledMessages][])
110
- loaded.set(locale, { messages, h: helpers(locale) });
179
+ const areas = () => (split === undefined ? [WHOLE] : [...split.areas.keys()]);
180
+ const piecesOf = (area: string): Pieces<L> => (area === WHOLE ? catalog : split!.areas.get(area)!);
181
+
182
+ function layerOf(l: L): Layer {
183
+ let layer = loaded.get(l);
184
+ if (layer === undefined) loaded.set(l, (layer = { messages: {}, h: helpers(l), areas: new Set() }));
185
+ return layer;
186
+ }
187
+
188
+ function take(l: L, area: string, messages: CompiledMessages) {
189
+ const layer = layerOf(l);
190
+ layer.areas.add(area);
191
+ // In dev an area is handed its locales whole; only the area's own keys are its.
192
+ layer.messages = area === WHOLE ? messages : { ...layer.messages, ...pick(messages, (key) => areaOf(key) === area) };
193
+ }
194
+
195
+ function takeBundled(area: string) {
196
+ for (const [l, messages] of Object.entries(piecesOf(area).messages) as [L, CompiledMessages][]) take(l, area, messages);
197
+ }
198
+
199
+ for (const area of areas()) takeBundled(area);
111
200
 
112
201
  let locale = catalog.source;
113
202
  let chain: Layer[] = layers(chainOf(locale, catalog.locales, catalog.source));
203
+ // The locales the latest switch asked for, which an area arriving during it loads in too.
204
+ let wanted: L[] = chainOf(locale, catalog.locales, catalog.source);
114
205
  let latest = 0;
115
206
  let version = 0;
116
207
 
@@ -123,30 +214,41 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
123
214
  (globalThis as DevHooks).__PROSEBOOK_HOT__?.add((l, messages) => {
124
215
  const layer = loaded.get(l as L);
125
216
  if (layer === undefined) return;
126
- layer.messages = messages;
217
+ layer.messages = layer.areas.has(WHOLE) ? messages : pick(messages, (key) => layer.areas.has(areaOf(key)));
218
+ changed();
219
+ });
220
+
221
+ // An area that loads after the copy was made comes in, in the locale shown and the one being switched to.
222
+ split?.copies.add(async (area) => {
223
+ takeBundled(area);
224
+ const locales = new Set([...chainOf(locale, catalog.locales, catalog.source), ...wanted]);
225
+ await Promise.all([...locales].map((l) => load(l, area)));
127
226
  changed();
128
227
  });
129
228
 
130
229
  function layers(locales: L[]): Layer[] {
131
- return locales.flatMap((l) => loaded.get(l) ?? []);
230
+ return locales.map(layerOf);
132
231
  }
133
232
 
134
- function load(l: L): Promise<Layer> {
135
- const done = loaded.get(l);
136
- if (done) return Promise.resolve(done);
137
- let p = pending.get(l);
233
+ function load(l: L, area: string): Promise<void> {
234
+ if (loaded.get(l)?.areas.has(area) === true) return Promise.resolve();
235
+ const id = `${area}/${l}`;
236
+ let p = pending.get(id);
138
237
  if (p === undefined) {
139
238
  // oxlint-disable-next-line typescript/no-unsafe-enum-assignment -- `L` is a string union, not an enum; the rule misreads the mapped type
140
- const loader = catalog.loaders[l];
141
- if (loader === undefined) return Promise.reject(new Error(`prosebook: no messages for locale "${l}"`));
239
+ const loader = piecesOf(area).loaders[l];
240
+ if (loader === undefined) {
241
+ if (split === undefined) return Promise.reject(new Error(`prosebook: no messages for locale "${l}"`));
242
+ // An area with nothing translated in a locale has no loader for it.
243
+ take(l, area, {});
244
+ return Promise.resolve();
245
+ }
142
246
  p = loader().then((mod) => {
143
- const layer = { messages: mod.default, h: helpers(l) };
144
- loaded.set(l, layer);
145
- pending.delete(l);
146
- return layer;
247
+ take(l, area, mod.default);
248
+ pending.delete(id);
147
249
  });
148
- p.catch(() => pending.delete(l));
149
- pending.set(l, p);
250
+ p.catch(() => pending.delete(id));
251
+ pending.set(id, p);
150
252
  }
151
253
  return p;
152
254
  }
@@ -155,7 +257,8 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
155
257
  const target = negotiate(next, catalog.locales, catalog.source);
156
258
  const ticket = ++latest;
157
259
  const locales = chainOf(target, catalog.locales, catalog.source);
158
- await Promise.all(locales.map(load));
260
+ wanted = locales;
261
+ await Promise.all(locales.flatMap((l) => areas().map((area) => load(l, area))));
159
262
  // A slower load must not undo a later switch.
160
263
  if (ticket !== latest || target === locale) return;
161
264
  locale = target;
@@ -172,9 +275,30 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
172
275
  return undefined;
173
276
  }
174
277
 
278
+ function preload(area: string) {
279
+ const files = catalog.areaFiles?.[area];
280
+ const read = `${area}/${locale}`;
281
+ if (files === undefined || options.preload === undefined || preloaded.has(read)) return;
282
+ preloaded.add(read);
283
+ for (const l of chainOf(locale, catalog.locales, catalog.source)) {
284
+ const href = files[l];
285
+ if (href === undefined || preloaded.has(href)) continue;
286
+ preloaded.add(href);
287
+ options.preload(href);
288
+ }
289
+ }
290
+
175
291
  function render(key: string, values: Values): unknown {
292
+ preload(areaOf(key));
176
293
  const found = find(key);
177
- if (found === undefined) return options.onMissing ? options.onMissing(key, locale) : key;
294
+ if (found === undefined) {
295
+ const area = areaOf(key);
296
+ if (split !== undefined && !split.areas.has(area) && catalog.split!.areas.includes(area)) {
297
+ const fix = `name one of its keys in the module that reads it, or keep it: \`keep: ['${area}.*']\``;
298
+ report(new Error(`the ${area} area has not loaded: ${fix}`), key, locale);
299
+ }
300
+ return options.onMissing ? options.onMissing(key, locale) : key;
301
+ }
178
302
  const [message, h] = found;
179
303
  reading = key;
180
304
  let out: unknown;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `prosebook/solid`: the catalog in Solid components.
3
+ *
4
+ * Every read tracks a signal the copy sets on each change, so JSX that reads a message updates in
5
+ * place when the locale switches; components never run again. `rich` returns what its tag renderers
6
+ * made, beside the text, ready to put in JSX. Written without JSX, so it needs no Solid compiler.
7
+ */
8
+ import { createComponent, createContext, createMemo, createSignal, onCleanup, useContext, type Accessor, type JSX } from 'solid-js';
9
+
10
+ import { bindCopy, type BoundCopy, type TextProps as Props } from '#src/runtime/bind.ts';
11
+ import type { Copy } from '#src/runtime/copy.ts';
12
+ import type { RegisteredLocale, RegisteredMessages, TagFn } from '#src/runtime/types.ts';
13
+
14
+ type M = RegisteredMessages;
15
+
16
+ /** `Copy`, with `rich` returning a `JSX.Element`, whose reads are tracked. */
17
+ export type SolidCopy = BoundCopy<JSX.Element>;
18
+
19
+ type Tags = { readonly [tag: string]: TagFn<JSX.Element> };
20
+
21
+ const CopyContext = createContext<SolidCopy>();
22
+
23
+ export interface CopyProviderProps {
24
+ /** Read once: give a provider a new key to change the copy under it. */
25
+ copy: Copy;
26
+ /** Tag renderers every rich read under this provider gets, beside the copy's own. */
27
+ tags?: Tags;
28
+ children?: JSX.Element;
29
+ }
30
+
31
+ export function CopyProvider(props: CopyProviderProps): JSX.Element {
32
+ const { copy, tags = {} } = props;
33
+ const [version, setVersion] = createSignal(copy.version);
34
+ onCleanup(copy.subscribe(() => setVersion(copy.version)));
35
+ const value = bindCopy<JSX.Element, JSX.Element>(copy, tags, { track: version, whole: (parts) => parts as JSX.Element });
36
+ return createComponent(CopyContext.Provider, {
37
+ value,
38
+ get children() {
39
+ return props.children;
40
+ },
41
+ });
42
+ }
43
+
44
+ /** The catalog, in the current locale. What reads it updates when the locale changes, or in dev the catalog. */
45
+ export function useCopy(): SolidCopy {
46
+ const reader = useContext(CopyContext);
47
+ if (reader === undefined) throw new Error('prosebook: useCopy() needs a <CopyProvider> above it');
48
+ return reader;
49
+ }
50
+
51
+ /** The current locale, as an accessor. */
52
+ export function useLocale(): Accessor<RegisteredLocale> {
53
+ const reader = useCopy();
54
+ return () => reader.locale;
55
+ }
56
+
57
+ export type TextProps<K extends Extract<keyof M, string>> = Props<K, JSX.Element>;
58
+
59
+ /**
60
+ * A message as an element: `<Text id="cart.items" values={{ count }} />`. Rich messages render their
61
+ * tags through the provider's renderers and any given in `values`.
62
+ */
63
+ export function Text<K extends Extract<keyof M, string>>(props: TextProps<K>): JSX.Element {
64
+ const copy = useCopy();
65
+ // An accessor is something JSX renders, and runs again when the message or its values change.
66
+ return createMemo(() => copy.rich(props.id as never, (props as { values?: object }).values as never)) as unknown as JSX.Element;
67
+ }
@@ -0,0 +1,13 @@
1
+ <!--
2
+ A message, its tags rendered by snippets of the same name: `<Text id="cart.items" values={{ count }} />`.
3
+ Given `chunks` instead, as a tag's snippet is, renders what the tag wraps.
4
+ -->
5
+ <script lang="ts">
6
+ import { getCopy, type RichPart } from 'prosebook/svelte';
7
+
8
+ let { id, values, chunks, ...snippets }: { id?: string; values?: object; chunks?: RichPart[]; [tag: string]: unknown } = $props();
9
+ const copy = getCopy();
10
+ const parts = $derived(chunks ?? copy.rich(id as never, { ...snippets, ...values } as never));
11
+ </script>
12
+
13
+ {#each parts as part}{#if typeof part === 'string'}{part}{:else}{@render part.snippet(part.chunks)}{/if}{/each}
@@ -0,0 +1,20 @@
1
+ // Beside `Text.svelte` in src and in dist, so it names prosebook by package rather than by path.
2
+ import type { RegisteredMessages } from 'prosebook';
3
+ import type { RichPart, TagSnippet } from 'prosebook/svelte';
4
+ import type { Component } from 'svelte';
5
+
6
+ type M = RegisteredMessages;
7
+
8
+ /**
9
+ * A message's props: its key, `values` once it takes arguments, and a snippet per tag no default renders.
10
+ * Per key, so a key Svelte's tooling widens to the union still needs its own `values`.
11
+ */
12
+ export type TextProps<K extends Extract<keyof M, string>> = K extends unknown
13
+ ? { id: K } & ({} extends M[K]['args'] ? { values?: M[K]['args'] } : { values: M[K]['args'] }) & { [T in M[K]['tags']]?: TagSnippet }
14
+ : never;
15
+
16
+ /** A message as markup, or with `chunks`, what a tag wraps. */
17
+ declare const Text: <K extends Extract<keyof M, string>>(
18
+ ...args: Parameters<Component<TextProps<K> | { chunks: RichPart[] }>>
19
+ ) => ReturnType<Component<TextProps<K> | { chunks: RichPart[] }>>;
20
+ export default Text;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `prosebook/svelte`: the catalog in Svelte 5 components.
3
+ *
4
+ * Every read goes through a subscriber the copy updates on each change, so markup, `$derived` and
5
+ * effects that read a message run again when the locale switches. Tags render through snippets:
6
+ * a rich read returns text and `{ snippet, chunks }` parts, which `<Text>` renders.
7
+ */
8
+ import { getContext, setContext, type Snippet } from 'svelte';
9
+ import { createSubscriber } from 'svelte/reactivity';
10
+
11
+ import { bindCopy, type BoundCopy } from '#src/runtime/bind.ts';
12
+ import type { Copy } from '#src/runtime/copy.ts';
13
+ import type { DefaultTags, MessageShape, RegisteredMessages, RichKey } from '#src/runtime/types.ts';
14
+
15
+ export { default as Text } from './Text.svelte';
16
+
17
+ type M = RegisteredMessages;
18
+
19
+ /** What a rich read returns: text, and each tag as the snippet that renders it and what it wraps. */
20
+ export type RichPart = string | { snippet: TagSnippet; chunks: RichPart[] };
21
+
22
+ /** Renders a tag: `{#snippet link(chunks)}<a href="/terms"><Text {chunks} /></a>{/snippet}`. */
23
+ export type TagSnippet = Snippet<[chunks: RichPart[]]>;
24
+
25
+ type Tags = { readonly [tag: string]: TagSnippet };
26
+
27
+ /** The arguments of a rich read, and a snippet for each tag no default renders. */
28
+ export type RichSnippets<S extends MessageShape> = S['args'] & { [T in Exclude<S['tags'], DefaultTags>]: TagSnippet } & {
29
+ [T in Extract<S['tags'], DefaultTags>]?: TagSnippet;
30
+ };
31
+
32
+ /** `Copy`, whose reads are reactive, with `rich` taking snippets and returning parts for `<Text>`. */
33
+ export interface SvelteCopy extends Omit<BoundCopy<never>, 'rich'> {
34
+ rich<K extends RichKey<M>>(
35
+ this: void,
36
+ key: K,
37
+ ...values: {} extends RichSnippets<M[K]> ? [values?: RichSnippets<M[K]>] : [values: RichSnippets<M[K]>]
38
+ ): RichPart[];
39
+ }
40
+
41
+ export interface CopyOptions {
42
+ /** Snippets every rich read gets for these tags, beside the copy's own renderers. */
43
+ tags?: Tags;
44
+ }
45
+
46
+ const KEY = Symbol('prosebook');
47
+
48
+ /** Gives this component and those under it the copy, and returns it as `getCopy()` would. Call it while the component initialises. */
49
+ export function setCopy(copy: Copy, options: CopyOptions = {}): SvelteCopy {
50
+ const track = createSubscriber((update) => copy.subscribe(update));
51
+ const reader = bindCopy<never, RichPart[]>(copy, options.tags ?? {}, {
52
+ track,
53
+ // A snippet renders only inside markup, so a tag's parts wait there for `<Text>`.
54
+ tag: (snippet, chunks) => ({ snippet, chunks }),
55
+ whole: (parts) => parts as RichPart[],
56
+ });
57
+ return setContext(KEY, reader as unknown as SvelteCopy);
58
+ }
59
+
60
+ /** The catalog, in the current locale. What reads it runs again when the locale changes, or in dev the catalog. */
61
+ export function getCopy(): SvelteCopy {
62
+ const reader = getContext<SvelteCopy | undefined>(KEY);
63
+ if (reader === undefined) throw new Error('prosebook: getCopy() needs setCopy(copy) in a component above it');
64
+ return reader;
65
+ }