prosebook 0.3.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.
- package/README.md +357 -26
- package/dist/bind-BIZNv8nU.js +62 -0
- package/dist/bind-BIZNv8nU.js.map +1 -0
- package/dist/bind-ZS86jHXk.d.ts +31 -0
- package/dist/bind-ZS86jHXk.d.ts.map +1 -0
- package/dist/bun.d.ts +12 -3
- package/dist/bun.d.ts.map +1 -1
- package/dist/bun.js +77 -26
- package/dist/bun.js.map +1 -1
- package/dist/{bundle-KD-DiiFa.d.ts → bundle-CLHY_97D.d.ts} +3 -3
- package/dist/bundle-CLHY_97D.d.ts.map +1 -0
- package/dist/bundle-Ut6Y2X6N.js +217 -0
- package/dist/bundle-Ut6Y2X6N.js.map +1 -0
- package/dist/cli.js +55 -12
- package/dist/cli.js.map +1 -1
- package/dist/{config-BPSmoYel.js → config-7T4hX_f7.js} +13 -5
- package/dist/{config-BPSmoYel.js.map → config-7T4hX_f7.js.map} +1 -1
- package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
- package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
- package/dist/copy-BXn0aYQn.d.ts.map +1 -0
- package/dist/copy-Bv6S-Hr2.js +235 -0
- package/dist/copy-Bv6S-Hr2.js.map +1 -0
- package/dist/endpoints-Dhgowovz.js +217 -0
- package/dist/endpoints-Dhgowovz.js.map +1 -0
- package/dist/generate-1BeQAd1O.d.ts +72 -0
- package/dist/generate-1BeQAd1O.d.ts.map +1 -0
- package/dist/{generate-9pR3i5Bj.js → generate-CKv86Iki.js} +1724 -627
- package/dist/generate-CKv86Iki.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/next-loader.d.ts +9 -2
- package/dist/next-loader.d.ts.map +1 -1
- package/dist/next-loader.js +12 -3
- package/dist/next-loader.js.map +1 -1
- package/dist/next.d.ts +11 -3
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +94 -21
- package/dist/next.js.map +1 -1
- package/dist/preact/index.d.ts +34 -0
- package/dist/preact/index.d.ts.map +1 -0
- package/dist/preact/index.js +72 -0
- package/dist/preact/index.js.map +1 -0
- package/dist/react/index.d.ts +5 -14
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +9 -33
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +1 -1
- package/dist/react/server.js +1 -1
- package/dist/{sarif-CWyTAQ0y.js → sarif-BnwYC78_.js} +8 -98
- package/dist/sarif-BnwYC78_.js.map +1 -0
- package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
- package/dist/scan-C0ap4Mpp.js.map +1 -0
- package/dist/solid/index.d.ts +29 -0
- package/dist/solid/index.d.ts.map +1 -0
- package/dist/solid/index.js +48 -0
- package/dist/solid/index.js.map +1 -0
- package/dist/svelte/Text.svelte +13 -0
- package/dist/svelte/Text.svelte.d.ts +20 -0
- package/dist/svelte/index.d.ts +31 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +35 -0
- package/dist/svelte/index.js.map +1 -0
- package/dist/tools.d.ts +44 -65
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -5
- package/dist/vite.d.ts +13 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +82 -25
- package/dist/vite.js.map +1 -1
- package/dist/vue/index.d.ts +43 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +76 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +63 -7
- package/src/bun.ts +77 -20
- package/src/cli.ts +62 -8
- package/src/index.ts +1 -1
- package/src/next-loader.ts +17 -5
- package/src/next.ts +120 -16
- package/src/preact/index.ts +91 -0
- package/src/react/index.tsx +12 -42
- package/src/runtime/bind.ts +96 -0
- package/src/runtime/copy.ts +148 -24
- package/src/solid/index.ts +67 -0
- package/src/svelte/Text.svelte +13 -0
- package/src/svelte/Text.svelte.d.ts +20 -0
- package/src/svelte/index.ts +65 -0
- package/src/tools/bundle.ts +143 -2
- package/src/tools/catalog.ts +10 -1
- package/src/tools/chunks.ts +142 -0
- package/src/tools/config.ts +25 -7
- package/src/tools/entry.ts +19 -20
- package/src/tools/exchange.ts +275 -0
- package/src/tools/extract.ts +2 -2
- package/src/tools/generate.ts +7 -1
- package/src/tools/index.ts +2 -1
- package/src/tools/native/android.ts +186 -0
- package/src/tools/native/apple.ts +201 -0
- package/src/tools/native/date.ts +219 -0
- package/src/tools/native/index.ts +17 -5
- package/src/tools/native/kotlin.ts +47 -6
- package/src/tools/native/model.ts +67 -29
- package/src/tools/native/printf.ts +137 -0
- package/src/tools/native/rust.ts +86 -5
- package/src/tools/native/swift.ts +62 -6
- package/src/tools/scan.ts +146 -25
- package/src/tools/todo.ts +6 -1
- package/src/vite.ts +121 -28
- package/src/vue/index.ts +117 -0
- package/dist/bundle-DboItAre.js +0 -111
- package/dist/bundle-DboItAre.js.map +0 -1
- package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
- package/dist/compile-xF3F4oIp.d.ts +0 -16
- package/dist/compile-xF3F4oIp.d.ts.map +0 -1
- package/dist/copy-B3V_QRkT.d.ts.map +0 -1
- package/dist/copy-Bo_FIoLy.js +0 -154
- package/dist/copy-Bo_FIoLy.js.map +0 -1
- package/dist/endpoints-CnUfCkIk.js +0 -96
- package/dist/endpoints-CnUfCkIk.js.map +0 -1
- package/dist/generate-9pR3i5Bj.js.map +0 -1
- package/dist/sarif-CWyTAQ0y.js.map +0 -1
- package/dist/scan-Bx0_yDSi.js.map +0 -1
package/src/react/index.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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():
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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> } });
|
package/src/runtime/copy.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
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.
|
|
230
|
+
return locales.map(layerOf);
|
|
132
231
|
}
|
|
133
232
|
|
|
134
|
-
function load(l: L): Promise<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
let p = pending.get(
|
|
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 =
|
|
141
|
-
if (loader === undefined)
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
pending.delete(l);
|
|
146
|
-
return layer;
|
|
247
|
+
take(l, area, mod.default);
|
|
248
|
+
pending.delete(id);
|
|
147
249
|
});
|
|
148
|
-
p.catch(() => pending.delete(
|
|
149
|
-
pending.set(
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|