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.
- package/README.md +288 -28
- 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 +11 -2
- package/dist/bun.d.ts.map +1 -1
- package/dist/bun.js +77 -26
- package/dist/bun.js.map +1 -1
- package/dist/{bundle-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
- 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-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
- package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
- package/dist/config-CzaJJQ60.d.ts.map +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-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
- 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 +10 -2
- 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-B5nrMqrT.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 +43 -64
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -5
- package/dist/vite.d.ts +12 -2
- 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 +1 -1
- 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 +10 -25
- package/src/tools/native/apple.ts +70 -8
- package/src/tools/native/date.ts +219 -0
- package/src/tools/native/kotlin.ts +47 -6
- package/src/tools/native/model.ts +42 -16
- package/src/tools/native/printf.ts +46 -4
- 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-CpUIWb9j.d.ts.map +0 -1
- package/dist/bundle-DboItAre.js +0 -111
- package/dist/bundle-DboItAre.js.map +0 -1
- package/dist/compile-ClK9htKr.d.ts +0 -16
- package/dist/compile-ClK9htKr.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-BgG_5Vpm.js +0 -96
- package/dist/endpoints-BgG_5Vpm.js.map +0 -1
- package/dist/generate-BW3N8EGb.js.map +0 -1
- package/dist/sarif-B5nrMqrT.js.map +0 -1
- package/dist/scan-Bx0_yDSi.js.map +0 -1
package/src/bun.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* locale module accepts its own update and hands the new messages to every copy, as under Vite. The
|
|
12
12
|
* index starts the inspector on the page too, whose endpoints are `inspector()`'s routes, spread into
|
|
13
13
|
* `Bun.serve`'s; an entry opens in the editor `Bun.openInEditor` finds.
|
|
14
|
+
*
|
|
15
|
+
* With `split`, the page's catalog is split by area as the Vite plugin's is (`tools/bundle.ts`): each
|
|
16
|
+
* source file under `scan.dirs` naming an area's keys imports the area, a module of the plugin's own
|
|
17
|
+
* namespace. Bun has no transform hook, so those files are read here and handed back. A build knows
|
|
18
|
+
* the keys it keeps before it loads anything, so each area loads already pruned, and which areas
|
|
19
|
+
* share a chunk (`tools/chunks.ts`), so a lazy locale's part is a file per group of them.
|
|
14
20
|
*/
|
|
15
21
|
import { readFileSync } from 'node:fs';
|
|
16
22
|
import { basename, resolve } from 'node:path';
|
|
@@ -18,19 +24,29 @@ import { basename, resolve } from 'node:path';
|
|
|
18
24
|
import type { BunPlugin } from 'bun';
|
|
19
25
|
|
|
20
26
|
import {
|
|
27
|
+
AREA,
|
|
28
|
+
areaModule,
|
|
29
|
+
areasNamed,
|
|
30
|
+
lazyAreas,
|
|
21
31
|
beside,
|
|
22
32
|
HOT_INDEX,
|
|
23
33
|
HOT_SET,
|
|
24
34
|
hotLocale,
|
|
35
|
+
importingAreas,
|
|
36
|
+
inAreas,
|
|
25
37
|
installInspector,
|
|
26
38
|
keeper,
|
|
39
|
+
membersOf,
|
|
27
40
|
prunedModule,
|
|
28
41
|
scanSources,
|
|
42
|
+
splitIndex,
|
|
29
43
|
unseenMessage,
|
|
30
44
|
watchCatalog,
|
|
31
45
|
type Prune,
|
|
32
46
|
} from './tools/bundle.ts';
|
|
33
47
|
import { CatalogError } from './tools/catalog.ts';
|
|
48
|
+
import { areaGroups } from './tools/chunks.ts';
|
|
49
|
+
import { compileLocale, localeExpression, type Entry } from './tools/compile.ts';
|
|
34
50
|
import { loadConfig, type ProsebookConfig } from './tools/config.ts';
|
|
35
51
|
import { inspectorEndpoints } from './tools/endpoints.ts';
|
|
36
52
|
import { generate } from './tools/generate.ts';
|
|
@@ -46,16 +62,28 @@ export interface ProsebookBunOptions {
|
|
|
46
62
|
* check; `false` ships every message.
|
|
47
63
|
*/
|
|
48
64
|
prune?: Prune;
|
|
49
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. With
|
|
67
|
+
* `split`, their areas load with the catalog.
|
|
68
|
+
*/
|
|
50
69
|
keep?: string[];
|
|
70
|
+
/**
|
|
71
|
+
* Split each locale by area in the page, as the Vite plugin's `split` does, so a lazy route loads
|
|
72
|
+
* only the messages it reads. `Bun.build` needs `splitting: true`. A server's own code loads whole
|
|
73
|
+
* locales.
|
|
74
|
+
*/
|
|
75
|
+
split?: boolean;
|
|
51
76
|
/** Under the dev server, Alt-click a string on the page to open its entry in the editor. On by default. */
|
|
52
77
|
inspect?: boolean;
|
|
53
78
|
}
|
|
54
79
|
|
|
55
80
|
const inspectorPath = beside(import.meta.url, 'inspector');
|
|
81
|
+
const NAMESPACE = 'prosebook-area';
|
|
56
82
|
|
|
57
83
|
const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
58
84
|
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
85
|
+
/** A folder's path as a pattern, by either separator: Bun gives paths with either on Windows. */
|
|
86
|
+
const folderPattern = (path: string) => path.split(/[\\/]/).map(escape).join('[\\\\/]');
|
|
59
87
|
|
|
60
88
|
export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
|
|
61
89
|
return {
|
|
@@ -63,25 +91,25 @@ export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
|
|
|
63
91
|
async setup(build) {
|
|
64
92
|
const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);
|
|
65
93
|
let generated = generate(config);
|
|
66
|
-
// The generated modules, by path as Bun gives it
|
|
67
|
-
const
|
|
68
|
-
const filter = new RegExp(`^${folder}[\\\\/][^\\\\/]+\\.[jt]s$`);
|
|
94
|
+
// The generated modules, by path as Bun gives it.
|
|
95
|
+
const filter = new RegExp(`^${folderPattern(config.out)}[\\\\/][^\\\\/]+\\.[jt]s$`);
|
|
69
96
|
const nameOf = (path: string) => basename(path).replace(/\.[jt]s$/, '');
|
|
97
|
+
const building = 'entrypoints' in build.config;
|
|
98
|
+
const split = options.split === true;
|
|
99
|
+
if (building && split && build.config.splitting !== true)
|
|
100
|
+
throw new Error('prosebook: `split` needs `splitting: true` in Bun.build, or every area ships in the entry chunk');
|
|
101
|
+
// A full-stack build, one `Bun.build` of a server and the pages it imports, names the side it
|
|
102
|
+
// loads for (undocumented, and not in Bun's types); any other build is for its target.
|
|
103
|
+
const client = (args: object) => ('side' in args ? args.side === 'client' : (build.config.target ?? 'browser') === 'browser');
|
|
70
104
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
105
|
+
const prune = options.prune ?? 'strict';
|
|
106
|
+
let kept: (key: string) => boolean = () => true;
|
|
107
|
+
if (building && prune !== false) {
|
|
74
108
|
const { refs, unseen } = scanSources(config, prune === 'strict');
|
|
75
109
|
if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
|
|
76
|
-
|
|
77
|
-
build.onLoad({ filter }, ({ path }) => {
|
|
78
|
-
const entries = generated.entries.get(nameOf(path));
|
|
79
|
-
return entries === undefined ? undefined : { contents: prunedModule(entries, kept), loader: 'js' };
|
|
80
|
-
});
|
|
81
|
-
return;
|
|
110
|
+
kept = keeper(refs, options.keep);
|
|
82
111
|
}
|
|
83
|
-
|
|
84
|
-
const dev = process.env.NODE_ENV !== 'production';
|
|
112
|
+
const dev = !building && process.env.NODE_ENV !== 'production';
|
|
85
113
|
const inspector = dev && options.inspect !== false ? installInspector(inspectorPath, '/') : '';
|
|
86
114
|
if (dev) {
|
|
87
115
|
watchCatalog(config, () => {
|
|
@@ -92,11 +120,40 @@ export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
|
|
|
92
120
|
}
|
|
93
121
|
});
|
|
94
122
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
123
|
+
|
|
124
|
+
build.onLoad({ filter }, (args) => {
|
|
125
|
+
const name = nameOf(args.path);
|
|
126
|
+
if (name === 'index' && split && client(args)) {
|
|
127
|
+
const index = splitIndex(config, generated, options.keep);
|
|
128
|
+
return { contents: building ? index : HOT_SET + inspector + index + HOT_INDEX, loader: 'js' };
|
|
129
|
+
}
|
|
130
|
+
const entries = generated.entries.get(name);
|
|
131
|
+
if (building) return entries === undefined || prune === false ? undefined : { contents: prunedModule(entries, kept), loader: 'js' };
|
|
132
|
+
const loader = args.path.endsWith('.ts') ? 'ts' : 'js';
|
|
133
|
+
if (entries !== undefined) return { contents: readFileSync(args.path, 'utf8') + hotLocale(name), loader };
|
|
134
|
+
return name === 'index' ? { contents: HOT_SET + inspector + readFileSync(args.path, 'utf8') + HOT_INDEX, loader } : undefined;
|
|
135
|
+
});
|
|
136
|
+
if (!split) return;
|
|
137
|
+
|
|
138
|
+
const groups = building ? areaGroups(config, generated, options.keep) : new Map<string, string>();
|
|
139
|
+
// `cart`, or in a build `cart/de`, a lazy locale's part of the group `cart` leads.
|
|
140
|
+
build.onResolve({ filter: new RegExp(`^${escape(AREA)}`) }, ({ path }) => ({ path: path.slice(AREA.length), namespace: NAMESPACE }));
|
|
141
|
+
build.onLoad({ filter: /.*/, namespace: NAMESPACE }, ({ path }) => {
|
|
142
|
+
const [area = '', locale] = path.split('/');
|
|
143
|
+
if (locale !== undefined) {
|
|
144
|
+
const entries = inAreas(generated.entries.get(locale) ?? [], membersOf(groups, area));
|
|
145
|
+
return { contents: prunedModule(entries, kept), loader: 'js' };
|
|
146
|
+
}
|
|
147
|
+
const inline = building ? (_locale: string, entries: Entry[]) => localeExpression(compileLocale(entries, kept)) : undefined;
|
|
148
|
+
return { contents: areaModule(config, generated, area, inline, undefined, groups), loader: 'js' };
|
|
149
|
+
});
|
|
150
|
+
const dirs = config.scan.dirs.map((dir) => folderPattern(resolve(config.root, dir))).join('|');
|
|
151
|
+
const out = new RegExp(`^${folderPattern(config.out)}[\\\\/]`);
|
|
152
|
+
build.onLoad({ filter: new RegExp(`^(?:${dirs})[\\\\/].*\\.[cm]?[jt]sx?$`) }, (args) => {
|
|
153
|
+
if (!client(args) || out.test(args.path) || /[\\/]node_modules[\\/]/.test(args.path)) return undefined;
|
|
154
|
+
const code = readFileSync(args.path, 'utf8');
|
|
155
|
+
const areas = areasNamed(code, lazyAreas(generated, options.keep));
|
|
156
|
+
return areas.length === 0 ? undefined : { contents: importingAreas(code, args.path, areas), loader: args.loader };
|
|
100
157
|
});
|
|
101
158
|
},
|
|
102
159
|
};
|
package/src/cli.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* ones CI needs in one go.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { join, relative, resolve } from 'node:path';
|
|
7
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
|
|
10
10
|
import { watchCatalog } from './tools/bundle.ts';
|
|
11
|
-
import { CatalogError, entryLocator, whereOf, type Catalog, type Problem } from './tools/catalog.ts';
|
|
11
|
+
import { CatalogError, entryLocator, SCHEMA_LINE, whereOf, type Catalog, type Problem } from './tools/catalog.ts';
|
|
12
12
|
import { CONFIG_FILES, loadConfig, type ResolvedConfig } from './tools/config.ts';
|
|
13
13
|
import { applyFixes, importTranslations } from './tools/entry.ts';
|
|
14
|
+
import { exportOf, type ExchangeFormat } from './tools/exchange.ts';
|
|
14
15
|
import { applyExtract, planExtract } from './tools/extract.ts';
|
|
15
16
|
import { generate, render } from './tools/generate.ts';
|
|
16
17
|
import { lint, type Finding } from './tools/lint.ts';
|
|
@@ -34,8 +35,11 @@ Commands:
|
|
|
34
35
|
accept <locale> [keys] Mark stale translations as still right (--all for every stale one)
|
|
35
36
|
todo <locale> Print the missing and stale entries of a locale, ready to translate
|
|
36
37
|
(--prompt: as a prompt for a model, saying how to reply)
|
|
37
|
-
|
|
38
|
-
(
|
|
38
|
+
export <locale> [file] Write the entries todo lists as XLIFF 2.0 or JSON, for a translation tool
|
|
39
|
+
(--all: every entry; the format from the file's extension, or --format)
|
|
40
|
+
import <locale> [file] Write translations from todo's YAML filled in, a model's reply to --prompt,
|
|
41
|
+
or a tool's XLIFF or JSON (stdin without a file); each is checked first,
|
|
42
|
+
and one that fails is left out
|
|
39
43
|
scan Find hardcoded text in JSX and catalog keys no code reads (--strict)
|
|
40
44
|
extract [paths] Move hardcoded JSX text into the catalog and rewrite the code to read it
|
|
41
45
|
(dry run unless --write; --area <name> puts every entry in one area)
|
|
@@ -47,10 +51,12 @@ Options:
|
|
|
47
51
|
--root <dir> Project root (default: the current directory)
|
|
48
52
|
--format <text|sarif> With lint, scan, status and check: sarif writes a SARIF 2.1.0 log to stdout
|
|
49
53
|
for code scanning, and the usual text to stderr
|
|
54
|
+
--format <xliff|json> With export: the file's format (XLIFF by default on stdout)
|
|
50
55
|
-h, --help Show this help
|
|
51
56
|
`;
|
|
52
57
|
|
|
53
58
|
const SARIF_COMMANDS = ['lint', 'scan', 'status', 'check'];
|
|
59
|
+
const EXCHANGE_FORMATS: Record<string, ExchangeFormat> = { xliff: 'xliff', xlf: 'xliff', json: 'json' };
|
|
54
60
|
|
|
55
61
|
/** What the checks that aren't lint rules hold the project to, for the rules in a SARIF log. */
|
|
56
62
|
const RULES = {
|
|
@@ -91,7 +97,12 @@ async function main(argv: string[]): Promise<number> {
|
|
|
91
97
|
out(HELP);
|
|
92
98
|
return 0;
|
|
93
99
|
}
|
|
94
|
-
if (
|
|
100
|
+
if (command === 'export') {
|
|
101
|
+
if (values.format !== undefined && EXCHANGE_FORMATS[values.format] === undefined) {
|
|
102
|
+
err(`prosebook: export writes xliff or json, not "${values.format}"`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
} else if (values.format !== undefined && values.format !== 'text' && values.format !== 'sarif') {
|
|
95
106
|
err(`prosebook: --format is text or sarif, not "${values.format}"`);
|
|
96
107
|
return 1;
|
|
97
108
|
}
|
|
@@ -123,6 +134,8 @@ async function main(argv: string[]): Promise<number> {
|
|
|
123
134
|
return runAccept(config, rest, values.all === true);
|
|
124
135
|
case 'todo':
|
|
125
136
|
return runTodo(config, rest[0], values.prompt === true);
|
|
137
|
+
case 'export':
|
|
138
|
+
return runExport(config, rest[0], rest[1], values.format, values.all === true);
|
|
126
139
|
case 'import':
|
|
127
140
|
return runImport(config, rest[0], rest[1]);
|
|
128
141
|
case 'scan':
|
|
@@ -187,10 +200,17 @@ function runGenerate(config: ResolvedConfig, check: boolean, report?: SarifRepor
|
|
|
187
200
|
out(`prosebook: generated files are up to date (${summary})`);
|
|
188
201
|
return 0;
|
|
189
202
|
}
|
|
190
|
-
|
|
203
|
+
// A catalog file is listed for its schema line, which generate adds.
|
|
204
|
+
const why = (p: string) =>
|
|
205
|
+
dirname(p) === config.catalog && p.endsWith('.yaml')
|
|
206
|
+
? `its first line isn't \`${SCHEMA_LINE}\`, so editors can't find the schema`
|
|
207
|
+
: 'out of date';
|
|
208
|
+
err(
|
|
209
|
+
`prosebook: out of date, run \`prosebook generate\`:\n ${changed.map((p) => `${rel(config, p)}${why(p) === 'out of date' ? '' : `: ${why(p)}`}`).join('\n ')}`,
|
|
210
|
+
);
|
|
191
211
|
report?.rule('out-of-date', RULES['out-of-date']);
|
|
192
212
|
for (const p of changed)
|
|
193
|
-
report?.add({ rule: 'out-of-date', level: 'error', message:
|
|
213
|
+
report?.add({ rule: 'out-of-date', level: 'error', message: `${why(p)}: run \`prosebook generate\``, file: rel(config, p) });
|
|
194
214
|
return 1;
|
|
195
215
|
}
|
|
196
216
|
out(`prosebook: ${changed.length === 0 ? 'nothing changed' : `wrote ${changed.length} file(s)`} (${summary})`);
|
|
@@ -352,6 +372,40 @@ function runTodo(config: ResolvedConfig, locale: string | undefined, prompt: boo
|
|
|
352
372
|
return 0;
|
|
353
373
|
}
|
|
354
374
|
|
|
375
|
+
function runExport(
|
|
376
|
+
config: ResolvedConfig,
|
|
377
|
+
locale: string | undefined,
|
|
378
|
+
file: string | undefined,
|
|
379
|
+
named: string | undefined,
|
|
380
|
+
all: boolean,
|
|
381
|
+
): number {
|
|
382
|
+
if (locale === undefined) {
|
|
383
|
+
err('Usage: prosebook export <locale> [file] [--all] [--format xliff|json]');
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
const format = EXCHANGE_FORMATS[named ?? (file === undefined ? 'xliff' : extname(file).slice(1).toLowerCase())];
|
|
387
|
+
if (format === undefined) {
|
|
388
|
+
err(`prosebook: ${file} is neither .xlf nor .json: name the format with --format xliff or --format json`);
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
const { catalog, stale } = render(config);
|
|
392
|
+
const exported = exportOf(config, catalog, stale.get(locale) ?? new Map(), locale, format, all);
|
|
393
|
+
if (exported.count === 0) {
|
|
394
|
+
err(`prosebook: ${locale} is complete; --all exports every entry`);
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
if (file === undefined) {
|
|
398
|
+
process.stdout.write(exported.text);
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
const path = resolve(file);
|
|
402
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
403
|
+
writeFileSync(path, exported.text);
|
|
404
|
+
out(`prosebook: exported ${exported.count} entr${exported.count === 1 ? 'y' : 'ies'} for ${locale} to ${file}`);
|
|
405
|
+
if (locale !== config.source) out(`Translate them, then \`prosebook import ${locale} ${file}\`.`);
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
|
|
355
409
|
function runImport(config: ResolvedConfig, locale: string | undefined, file: string | undefined): number {
|
|
356
410
|
if (locale === undefined || ((file === undefined || file === '-') && process.stdin.isTTY)) {
|
|
357
411
|
err('Usage: prosebook import <locale> <file>, or the reply on stdin');
|
|
@@ -488,7 +542,7 @@ export default defineConfig({
|
|
|
488
542
|
});
|
|
489
543
|
`;
|
|
490
544
|
|
|
491
|
-
const SAMPLE_CATALOG =
|
|
545
|
+
const SAMPLE_CATALOG = `${SCHEMA_LINE}
|
|
492
546
|
app.title:
|
|
493
547
|
kind: name
|
|
494
548
|
surface: title
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `prosebook generate` compiles it into typed modules, one per locale, whose messages are plain
|
|
6
6
|
* functions: this runtime looks them up, picks the locale and falls back, and parses nothing.
|
|
7
7
|
*/
|
|
8
|
-
export { createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
|
|
8
|
+
export { addArea, createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
|
|
9
9
|
export { acceptedLocales, chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
|
|
10
10
|
export { createHelpers, flatten } from './runtime/helpers.ts';
|
|
11
11
|
export type {
|
package/src/next-loader.ts
CHANGED
|
@@ -3,14 +3,26 @@
|
|
|
3
3
|
* locale module `withProsebook` has already pruned, since that reads the source once for every
|
|
4
4
|
* locale. In dev, in the browser, it puts what `withProsebook` hands it before the index (the hot
|
|
5
5
|
* hooks, and the inspector), and makes each locale module update the copies holding it whenever Fast
|
|
6
|
-
* Refresh runs it again.
|
|
6
|
+
* Refresh runs it again. With `split`, it also runs over the app's own source in the browser, and
|
|
7
|
+
* appends to each file an import of every area whose keys it names, from the folder of area files
|
|
8
|
+
* `withProsebook` writes.
|
|
7
9
|
*/
|
|
8
|
-
import {
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
import { areaFile, areasNamed, importingAreas, notifyingLocale, relativeImport } from './tools/bundle.ts';
|
|
11
13
|
|
|
12
|
-
export
|
|
14
|
+
export type LoaderOptions = { code: string } | { prepend: string } | { locale: string } | { areas: string[]; dir: string };
|
|
15
|
+
|
|
16
|
+
type Context = { getOptions(): LoaderOptions; resourcePath?: string; rootContext?: string };
|
|
17
|
+
|
|
18
|
+
export default function loader(this: Context, source: string): string {
|
|
19
|
+
const { resourcePath = '', rootContext = process.cwd() } = this;
|
|
13
20
|
const options = this.getOptions();
|
|
14
21
|
if ('code' in options) return options.code;
|
|
15
|
-
|
|
22
|
+
if ('prepend' in options) return options.prepend + source;
|
|
23
|
+
if ('locale' in options) return notifyingLocale(source, options.locale);
|
|
24
|
+
const areas = areasNamed(source, new Set(options.areas));
|
|
25
|
+
if (areas.length === 0) return source;
|
|
26
|
+
const file = isAbsolute(resourcePath) ? resourcePath : resolve(rootContext, resourcePath);
|
|
27
|
+
return importingAreas(source, file, areas, (area) => relativeImport(dirname(file), join(options.dir, areaFile(area))));
|
|
16
28
|
}
|
package/src/next.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* route of the app without a file in it, so a server of their own answers them on a local port, and a
|
|
15
15
|
* rewrite sends `/__prosebook/*` there; an entry opens through Next's own `/__nextjs_launch-editor`.
|
|
16
16
|
*
|
|
17
|
+
* With `split`, client components get the catalog split by area, as under Vite (`tools/bundle.ts`).
|
|
18
|
+
* Turbopack has no virtual modules, so the areas are files, written under `node_modules/.prosebook`,
|
|
19
|
+
* where they are a dependency's code to Turbopack and not the app's; and it has no transform hook, so
|
|
20
|
+
* a loader rule over the app's own source in the browser appends the imports of the areas each file
|
|
21
|
+
* names. Server components and the server render read whole locales.
|
|
22
|
+
*
|
|
17
23
|
* Turbopack only: Next 16 fails a Turbopack build whose config has a `webpack` key, so none is added.
|
|
18
24
|
*/
|
|
19
25
|
import { createServer } from 'node:http';
|
|
@@ -22,20 +28,31 @@ import { join, relative, resolve } from 'node:path';
|
|
|
22
28
|
|
|
23
29
|
import type { LoaderOptions } from './next-loader.ts';
|
|
24
30
|
import {
|
|
31
|
+
areaFile,
|
|
32
|
+
areaModule,
|
|
33
|
+
areasOf,
|
|
25
34
|
beside,
|
|
35
|
+
fileSpecifiers,
|
|
26
36
|
HOT_SET,
|
|
37
|
+
inAreas,
|
|
27
38
|
installInspector,
|
|
28
39
|
keeper,
|
|
40
|
+
lazyAreas,
|
|
41
|
+
membersOf,
|
|
29
42
|
prunedModule,
|
|
30
43
|
scanSources,
|
|
44
|
+
splitIndex,
|
|
31
45
|
unseenMessage,
|
|
32
46
|
watchCatalog,
|
|
33
47
|
type Prune,
|
|
34
48
|
} from './tools/bundle.ts';
|
|
35
49
|
import { CatalogError } from './tools/catalog.ts';
|
|
50
|
+
import { areaGroups } from './tools/chunks.ts';
|
|
51
|
+
import { compileLocale, localeExpression } from './tools/compile.ts';
|
|
52
|
+
import type { Entry } from './tools/compile.ts';
|
|
36
53
|
import { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';
|
|
37
54
|
import { inspectorEndpoints, nodeMiddleware, redirect } from './tools/endpoints.ts';
|
|
38
|
-
import { generate, type Generated } from './tools/generate.ts';
|
|
55
|
+
import { generate, writeFiles, type Generated } from './tools/generate.ts';
|
|
39
56
|
|
|
40
57
|
export interface ProsebookNextOptions {
|
|
41
58
|
/** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
|
|
@@ -48,8 +65,16 @@ export interface ProsebookNextOptions {
|
|
|
48
65
|
* every message.
|
|
49
66
|
*/
|
|
50
67
|
prune?: Prune;
|
|
51
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. With
|
|
70
|
+
* `split`, their areas load with the catalog.
|
|
71
|
+
*/
|
|
52
72
|
keep?: string[];
|
|
73
|
+
/**
|
|
74
|
+
* Split each locale by area in client components, as the Vite plugin's `split` does, so a route's
|
|
75
|
+
* client code brings only the messages it reads. Server components read whole locales.
|
|
76
|
+
*/
|
|
77
|
+
split?: boolean;
|
|
53
78
|
/** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */
|
|
54
79
|
inspect?: boolean;
|
|
55
80
|
}
|
|
@@ -89,23 +114,26 @@ export function withProsebook<C extends object>(
|
|
|
89
114
|
throw new Error("prosebook: Turbopack doesn't resolve `./en.js` to `en.ts`: set `importExtension: ''` in the prosebook config");
|
|
90
115
|
const generated = generate(config);
|
|
91
116
|
if (phase === DEV) {
|
|
92
|
-
watch(config);
|
|
93
|
-
if (options.inspect === false) return withRules(resolved, hotRules(config, generated, HOT_SET));
|
|
117
|
+
watch(config, options);
|
|
118
|
+
if (options.inspect === false) return withRules(resolved, hotRules(config, generated, HOT_SET, options));
|
|
94
119
|
const base = `${(resolved as { basePath?: string }).basePath ?? ''}/`;
|
|
95
120
|
// A relative import, which Turbopack resolves as it would the app's own.
|
|
96
121
|
const path = relative(config.out, inspectorPath).replaceAll('\\', '/');
|
|
97
122
|
const prepend = HOT_SET + installInspector(path.startsWith('.') ? path : `./${path}`, base);
|
|
98
|
-
return withRewrite(withRules(resolved, hotRules(config, generated, prepend)), await serveInspector(config, base));
|
|
123
|
+
return withRewrite(withRules(resolved, hotRules(config, generated, prepend, options)), await serveInspector(config, base));
|
|
99
124
|
}
|
|
100
125
|
const prune = options.prune ?? 'strict';
|
|
101
|
-
if (prune === false) return resolved;
|
|
102
|
-
return withRules(resolved,
|
|
126
|
+
if (prune === false && options.split !== true) return resolved;
|
|
127
|
+
return withRules(resolved, buildRules(config, generated, prune, options));
|
|
103
128
|
};
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/** The app's rules and ours: where both have one for a glob, theirs first, then ours, in a list. */
|
|
106
132
|
function withRules<C extends object>(resolved: C, rules: Rules): C {
|
|
107
133
|
const turbopack = (resolved as { turbopack?: { rules?: Rules } }).turbopack;
|
|
108
|
-
|
|
134
|
+
const all: Rules = { ...turbopack?.rules };
|
|
135
|
+
for (const [glob, rule] of Object.entries(rules)) all[glob] = glob in all ? [all[glob], rule].flat() : rule;
|
|
136
|
+
return { ...resolved, turbopack: { ...turbopack, rules: all } };
|
|
109
137
|
}
|
|
110
138
|
|
|
111
139
|
/** Sends `/__prosebook/*` to the inspector's server, before any of the app's own rewrites. */
|
|
@@ -146,12 +174,13 @@ function serveInspector(config: ResolvedConfig, base: string): Promise<number> {
|
|
|
146
174
|
return port;
|
|
147
175
|
}
|
|
148
176
|
|
|
149
|
-
function watch(config: ResolvedConfig): void {
|
|
177
|
+
function watch(config: ResolvedConfig, options: ProsebookNextOptions): void {
|
|
150
178
|
if (watching.has(config.catalog)) return;
|
|
151
179
|
watching.add(config.catalog);
|
|
152
180
|
watchCatalog(config, () => {
|
|
153
181
|
try {
|
|
154
|
-
generate(config);
|
|
182
|
+
const generated = generate(config);
|
|
183
|
+
if (options.split === true) writeAreas(config, generated);
|
|
155
184
|
console.info(`prosebook: regenerated ${relative(config.root, config.out).replaceAll('\\', '/')}`);
|
|
156
185
|
} catch (e) {
|
|
157
186
|
console.error(describe(e));
|
|
@@ -173,16 +202,91 @@ function rule(config: ResolvedConfig, name: string, options: LoaderOptions, brow
|
|
|
173
202
|
* In the browser: the index starts with `prepend`, the hot hooks and the inspector, there before any
|
|
174
203
|
* copy exists, and each locale updates the copies.
|
|
175
204
|
*/
|
|
176
|
-
function hotRules(config: ResolvedConfig, generated: Generated, prepend: string): Rules {
|
|
205
|
+
function hotRules(config: ResolvedConfig, generated: Generated, prepend: string, options: ProsebookNextOptions): Rules {
|
|
177
206
|
const locales = [...generated.entries.keys()].map((locale) => rule(config, locale, { locale }, true));
|
|
207
|
+
if (options.split === true) return Object.fromEntries([...locales, ...splitRules(config, generated, options, prepend)]);
|
|
178
208
|
return Object.fromEntries([rule(config, 'index', { prepend }, true), ...locales]);
|
|
179
209
|
}
|
|
180
210
|
|
|
181
|
-
function
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
211
|
+
function buildRules(config: ResolvedConfig, generated: Generated, prune: Prune, options: ProsebookNextOptions): Rules {
|
|
212
|
+
let kept: (key: string) => boolean = () => true;
|
|
213
|
+
if (prune !== false) {
|
|
214
|
+
const { refs, unseen } = scanSources(config, prune === 'strict');
|
|
215
|
+
if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
|
|
216
|
+
kept = keeper(refs, options.keep);
|
|
217
|
+
}
|
|
218
|
+
const locales =
|
|
219
|
+
prune === false ? [] : [...generated.entries].map(([locale, entries]) => rule(config, locale, { code: prunedModule(entries, kept) }));
|
|
220
|
+
return Object.fromEntries([...locales, ...(options.split === true ? splitRules(config, generated, options, '', kept) : [])]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Where a split catalog's area files go: out of the app's source, where Turbopack takes them for a dependency's. */
|
|
224
|
+
const areasDir = (config: ResolvedConfig) => join(config.root, 'node_modules', '.prosebook', 'areas');
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Writes each area's file, and in a build, with `kept`, each lazy locale's part of each group of
|
|
228
|
+
* areas that share a chunk (`tools/chunks.ts`), pruned; in dev an area holds its locales' generated
|
|
229
|
+
* modules whole, so their own updates reach the copies.
|
|
230
|
+
*/
|
|
231
|
+
function writeAreas(config: ResolvedConfig, generated: Generated, kept?: (key: string) => boolean, keep?: string[]): void {
|
|
232
|
+
const dir = areasDir(config);
|
|
233
|
+
const where = fileSpecifiers(dir, dir);
|
|
234
|
+
const inline = kept === undefined ? undefined : (_locale: string, entries: Entry[]) => localeExpression(compileLocale(entries, kept));
|
|
235
|
+
const groups = kept === undefined ? new Map<string, string>() : areaGroups(config, generated, keep);
|
|
236
|
+
const files = new Map<string, string>();
|
|
237
|
+
for (const area of areasOf(generated)) files.set(join(dir, areaFile(area)), areaModule(config, generated, area, inline, where, groups));
|
|
238
|
+
if (kept === undefined) {
|
|
239
|
+
writeFiles(files);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
for (const lead of new Set(groups.values())) {
|
|
243
|
+
for (const [locale, entries] of generated.entries) {
|
|
244
|
+
const parts = inAreas(entries, membersOf(groups, lead));
|
|
245
|
+
if (parts.length > 0 && locale !== config.source && !config.bundled.includes(locale))
|
|
246
|
+
files.set(join(dir, areaFile(lead, locale)), prunedModule(parts, kept));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
writeFiles(files);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* In the browser: the index of a split catalog after `prepend`, and a rule per kind of source file
|
|
254
|
+
* under `scan.dirs` that names an area's keys, which appends its imports of those areas.
|
|
255
|
+
*/
|
|
256
|
+
function splitRules(
|
|
257
|
+
config: ResolvedConfig,
|
|
258
|
+
generated: Generated,
|
|
259
|
+
options: ProsebookNextOptions,
|
|
260
|
+
prepend: string,
|
|
261
|
+
kept?: (key: string) => boolean,
|
|
262
|
+
) {
|
|
263
|
+
writeAreas(config, generated, kept, options.keep);
|
|
264
|
+
const index = rule(
|
|
265
|
+
config,
|
|
266
|
+
'index',
|
|
267
|
+
{ code: prepend + splitIndex(config, generated, options.keep, fileSpecifiers(config.out, areasDir(config))) },
|
|
268
|
+
true,
|
|
269
|
+
);
|
|
270
|
+
const areas = [...lazyAreas(generated, options.keep)];
|
|
271
|
+
if (areas.length === 0) return [index];
|
|
272
|
+
const under = (dir: string) => escape(relative(config.root, dir).replaceAll('\\', '/'));
|
|
273
|
+
const dirs = config.scan.dirs.map(under).filter((d) => d !== '');
|
|
274
|
+
const condition = {
|
|
275
|
+
all: [
|
|
276
|
+
'browser',
|
|
277
|
+
{ not: 'foreign' },
|
|
278
|
+
...(dirs.length > 0 && dirs.length === config.scan.dirs.length ? [{ path: new RegExp(`(?:^|/)(?:${dirs.join('|')})/`) }] : []),
|
|
279
|
+
{ not: { path: new RegExp(`(?:^|/)${under(config.out)}/`) } },
|
|
280
|
+
// As `keyRefs` reads a key: a quote, then the area.
|
|
281
|
+
{ content: new RegExp(`['"\`](?:${areas.map(escape).join('|')})\\.`) },
|
|
282
|
+
],
|
|
283
|
+
};
|
|
284
|
+
const loaderOptions: LoaderOptions = { areas, dir: areasDir(config) };
|
|
285
|
+
const sources = ['*.ts', '*.tsx', '*.js', '*.jsx'].map((glob): [string, unknown] => [
|
|
286
|
+
glob,
|
|
287
|
+
{ condition, loaders: [{ loader: loaderPath, options: loaderOptions }], as: glob },
|
|
288
|
+
]);
|
|
289
|
+
return [index, ...sources];
|
|
186
290
|
}
|
|
187
291
|
|
|
188
292
|
export default withProsebook;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook/preact`: the catalog in Preact components, as `prosebook/react` has it in React's.
|
|
3
|
+
*
|
|
4
|
+
* Written with `preact/hooks` alone, so it needs no `preact/compat`.
|
|
5
|
+
*/
|
|
6
|
+
import { createContext, Fragment, h, isValidElement, type ComponentChildren, type VNode } from 'preact';
|
|
7
|
+
import { useContext, useEffect, useMemo, useState } from 'preact/hooks';
|
|
8
|
+
|
|
9
|
+
import { bindCopy, type BoundCopy, type TextProps as Props } from '#src/runtime/bind.ts';
|
|
10
|
+
import type { Copy } from '#src/runtime/copy.ts';
|
|
11
|
+
import type { RegisteredLocale, RegisteredMessages, TagFn } from '#src/runtime/types.ts';
|
|
12
|
+
|
|
13
|
+
type M = RegisteredMessages;
|
|
14
|
+
|
|
15
|
+
/** `Copy`, with `rich` returning a `VNode`. */
|
|
16
|
+
export type PreactCopy = BoundCopy<ComponentChildren, VNode>;
|
|
17
|
+
|
|
18
|
+
type Tags = { readonly [tag: string]: TagFn<ComponentChildren> };
|
|
19
|
+
|
|
20
|
+
const CopyContext = createContext<{ copy: Copy; tags: Tags } | null>(null);
|
|
21
|
+
|
|
22
|
+
export interface CopyProviderProps {
|
|
23
|
+
copy: Copy;
|
|
24
|
+
/** Tag renderers every rich read under this provider gets, beside the copy's own. */
|
|
25
|
+
tags?: Tags;
|
|
26
|
+
children?: ComponentChildren;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function CopyProvider({ copy, tags = {}, children }: CopyProviderProps) {
|
|
30
|
+
const value = useMemo(() => ({ copy, tags }), [copy, tags]);
|
|
31
|
+
return h(CopyContext.Provider, { value }, children);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function useContextOf(hook: string) {
|
|
35
|
+
const context = useContext(CopyContext);
|
|
36
|
+
if (context === null) throw new Error(`prosebook: ${hook}() needs a <CopyProvider> above it`);
|
|
37
|
+
return context;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Renders the component again after each change to what the copy reads: a locale switch, or in dev a catalog edit. */
|
|
41
|
+
function useVersion(copy: Copy): number {
|
|
42
|
+
const [, rerender] = useState(0);
|
|
43
|
+
const version = copy.version;
|
|
44
|
+
// Preact has no useSyncExternalStore outside compat: the effect subscribes once the component mounts.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const update = () => rerender((n) => n + 1);
|
|
47
|
+
const stop = copy.subscribe(update);
|
|
48
|
+
// A change between this render and the subscription would otherwise go unseen.
|
|
49
|
+
if (copy.version !== version) update();
|
|
50
|
+
return stop;
|
|
51
|
+
}, [copy]);
|
|
52
|
+
return version;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The catalog, in the current locale. Renders the component again when the locale changes, or in dev the catalog. */
|
|
56
|
+
export function useCopy(): PreactCopy {
|
|
57
|
+
const { copy, tags } = useContextOf('useCopy');
|
|
58
|
+
const version = useVersion(copy);
|
|
59
|
+
// Rebuilt on each version, so its identity changes when what it reads does.
|
|
60
|
+
return useMemo(() => bindCopy(copy, tags, { tag, whole }), [copy, tags, version]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The current locale alone, for components that only format or switch. */
|
|
64
|
+
export function useLocale(): RegisteredLocale {
|
|
65
|
+
const { copy } = useContextOf('useLocale');
|
|
66
|
+
useVersion(copy);
|
|
67
|
+
return copy.locale;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tag = (render: (chunks: never) => unknown, chunks: unknown[]) => render(keyed(chunks) as never);
|
|
71
|
+
const whole = (parts: unknown[]): VNode => h(Fragment, null, keyed(parts));
|
|
72
|
+
|
|
73
|
+
/** Text joined where it can be, and each element under its own key, as `prosebook/react` does. */
|
|
74
|
+
function keyed(parts: unknown[]): ComponentChildren[] {
|
|
75
|
+
if (parts.every((p) => typeof p === 'string')) return [parts.join('')];
|
|
76
|
+
return parts.map((p, i) =>
|
|
77
|
+
isValidElement(p) || typeof p === 'object' ? h(Fragment, { key: i }, p as ComponentChildren) : (p as ComponentChildren),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type TextProps<K extends Extract<keyof M, string>> = Props<K, ComponentChildren>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A message as an element: `<Text id="cart.items" values={{ count }} />`. Rich messages render their
|
|
85
|
+
* tags through the provider's renderers and any given in `values`.
|
|
86
|
+
*/
|
|
87
|
+
export function Text<K extends Extract<keyof M, string>>(props: TextProps<K>): VNode {
|
|
88
|
+
const copy = useCopy();
|
|
89
|
+
const values = (props as { values?: object }).values;
|
|
90
|
+
return copy.rich(props.id as never, values as never);
|
|
91
|
+
}
|