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.
Files changed (125) hide show
  1. package/README.md +357 -26
  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 +12 -3
  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-KD-DiiFa.d.ts → bundle-CLHY_97D.d.ts} +3 -3
  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-BPSmoYel.js → config-7T4hX_f7.js} +13 -5
  17. package/dist/{config-BPSmoYel.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
  19. package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
  20. package/dist/config.d.ts +1 -1
  21. package/dist/config.js +1 -1
  22. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  23. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  24. package/dist/copy-Bv6S-Hr2.js +235 -0
  25. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  26. package/dist/endpoints-Dhgowovz.js +217 -0
  27. package/dist/endpoints-Dhgowovz.js.map +1 -0
  28. package/dist/generate-1BeQAd1O.d.ts +72 -0
  29. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  30. package/dist/{generate-9pR3i5Bj.js → generate-CKv86Iki.js} +1724 -627
  31. package/dist/generate-CKv86Iki.js.map +1 -0
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/next-loader.d.ts +9 -2
  35. package/dist/next-loader.d.ts.map +1 -1
  36. package/dist/next-loader.js +12 -3
  37. package/dist/next-loader.js.map +1 -1
  38. package/dist/next.d.ts +11 -3
  39. package/dist/next.d.ts.map +1 -1
  40. package/dist/next.js +94 -21
  41. package/dist/next.js.map +1 -1
  42. package/dist/preact/index.d.ts +34 -0
  43. package/dist/preact/index.d.ts.map +1 -0
  44. package/dist/preact/index.js +72 -0
  45. package/dist/preact/index.js.map +1 -0
  46. package/dist/react/index.d.ts +5 -14
  47. package/dist/react/index.d.ts.map +1 -1
  48. package/dist/react/index.js +9 -33
  49. package/dist/react/index.js.map +1 -1
  50. package/dist/react/server.d.ts +1 -1
  51. package/dist/react/server.js +1 -1
  52. package/dist/{sarif-CWyTAQ0y.js → sarif-BnwYC78_.js} +8 -98
  53. package/dist/sarif-BnwYC78_.js.map +1 -0
  54. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  55. package/dist/scan-C0ap4Mpp.js.map +1 -0
  56. package/dist/solid/index.d.ts +29 -0
  57. package/dist/solid/index.d.ts.map +1 -0
  58. package/dist/solid/index.js +48 -0
  59. package/dist/solid/index.js.map +1 -0
  60. package/dist/svelte/Text.svelte +13 -0
  61. package/dist/svelte/Text.svelte.d.ts +20 -0
  62. package/dist/svelte/index.d.ts +31 -0
  63. package/dist/svelte/index.d.ts.map +1 -0
  64. package/dist/svelte/index.js +35 -0
  65. package/dist/svelte/index.js.map +1 -0
  66. package/dist/tools.d.ts +44 -65
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +5 -5
  69. package/dist/vite.d.ts +13 -3
  70. package/dist/vite.d.ts.map +1 -1
  71. package/dist/vite.js +82 -25
  72. package/dist/vite.js.map +1 -1
  73. package/dist/vue/index.d.ts +43 -0
  74. package/dist/vue/index.d.ts.map +1 -0
  75. package/dist/vue/index.js +76 -0
  76. package/dist/vue/index.js.map +1 -0
  77. package/package.json +63 -7
  78. package/src/bun.ts +77 -20
  79. package/src/cli.ts +62 -8
  80. package/src/index.ts +1 -1
  81. package/src/next-loader.ts +17 -5
  82. package/src/next.ts +120 -16
  83. package/src/preact/index.ts +91 -0
  84. package/src/react/index.tsx +12 -42
  85. package/src/runtime/bind.ts +96 -0
  86. package/src/runtime/copy.ts +148 -24
  87. package/src/solid/index.ts +67 -0
  88. package/src/svelte/Text.svelte +13 -0
  89. package/src/svelte/Text.svelte.d.ts +20 -0
  90. package/src/svelte/index.ts +65 -0
  91. package/src/tools/bundle.ts +143 -2
  92. package/src/tools/catalog.ts +10 -1
  93. package/src/tools/chunks.ts +142 -0
  94. package/src/tools/config.ts +25 -7
  95. package/src/tools/entry.ts +19 -20
  96. package/src/tools/exchange.ts +275 -0
  97. package/src/tools/extract.ts +2 -2
  98. package/src/tools/generate.ts +7 -1
  99. package/src/tools/index.ts +2 -1
  100. package/src/tools/native/android.ts +186 -0
  101. package/src/tools/native/apple.ts +201 -0
  102. package/src/tools/native/date.ts +219 -0
  103. package/src/tools/native/index.ts +17 -5
  104. package/src/tools/native/kotlin.ts +47 -6
  105. package/src/tools/native/model.ts +67 -29
  106. package/src/tools/native/printf.ts +137 -0
  107. package/src/tools/native/rust.ts +86 -5
  108. package/src/tools/native/swift.ts +62 -6
  109. package/src/tools/scan.ts +146 -25
  110. package/src/tools/todo.ts +6 -1
  111. package/src/vite.ts +121 -28
  112. package/src/vue/index.ts +117 -0
  113. package/dist/bundle-DboItAre.js +0 -111
  114. package/dist/bundle-DboItAre.js.map +0 -1
  115. package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
  116. package/dist/compile-xF3F4oIp.d.ts +0 -16
  117. package/dist/compile-xF3F4oIp.d.ts.map +0 -1
  118. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  119. package/dist/copy-Bo_FIoLy.js +0 -154
  120. package/dist/copy-Bo_FIoLy.js.map +0 -1
  121. package/dist/endpoints-CnUfCkIk.js +0 -96
  122. package/dist/endpoints-CnUfCkIk.js.map +0 -1
  123. package/dist/generate-9pR3i5Bj.js.map +0 -1
  124. package/dist/sarif-CWyTAQ0y.js.map +0 -1
  125. package/dist/scan-Bx0_yDSi.js.map +0 -1
@@ -7,12 +7,13 @@
7
7
  * so they read the source under `scan.dirs` first (`scanSources`) and load the locale already pruned.
8
8
  */
9
9
  import { existsSync, readFileSync, watch } from 'node:fs';
10
- import { relative } from 'node:path';
10
+ import { join, relative } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
 
13
13
  import { compileLocale, localeModule, type Entry } from './compile.ts';
14
14
  import type { ResolvedConfig } from './config.ts';
15
- import { isReferenced, keyRefs, READERS, sourceFiles, unseenReads, type KeyRefs, type Reads, type UnseenRead } from './scan.ts';
15
+ import type { Generated } from './generate.ts';
16
+ import { isReferenced, keyRefs, READERS, SFC, sourceFiles, unseenReads, type KeyRefs, type Reads, type UnseenRead } from './scan.ts';
16
17
 
17
18
  /** How a build prunes: `strict` also fails on a read whose key it can't see. */
18
19
  export type Prune = 'strict' | 'loose' | false;
@@ -77,6 +78,146 @@ export function watchCatalog(config: ResolvedConfig, change: () => void): () =>
77
78
  };
78
79
  }
79
80
 
81
+ /** `virtual:prosebook/area/cart`, and in a build `…/cart/de`, a lazy locale's part of the group `cart` leads. */
82
+ export const AREA = 'virtual:prosebook/area/';
83
+
84
+ /**
85
+ * How a split catalog's modules are imported: an area (`cart`), a lazy locale's part of a group of
86
+ * them, by the group's first area (`cart`, `de`), and a generated module, from its path. Vite's and Bun's are virtual modules (`VIRTUAL`);
87
+ * Turbopack has none, so Next's are files.
88
+ */
89
+ export interface Specifiers {
90
+ area: (area: string, locale?: string) => string;
91
+ generated: (path: string) => string;
92
+ }
93
+
94
+ export const VIRTUAL: Specifiers = {
95
+ area: (area, locale) => AREA + area + (locale === undefined ? '' : `/${locale}`),
96
+ generated: (path) => path,
97
+ };
98
+
99
+ /** `path` imported from a module in the folder `from`, as a relative specifier. */
100
+ export function relativeImport(from: string, path: string): string {
101
+ const to = relative(from, path).replaceAll('\\', '/');
102
+ return to.startsWith('.') ? to : `./${to}`;
103
+ }
104
+
105
+ /** An area's file, or a lazy locale's part of a group, where a bundler without virtual modules finds them. */
106
+ export const areaFile = (area: string, locale?: string) => (locale === undefined ? `${area}.js` : `${area}.${locale}.js`);
107
+
108
+ /** The specifiers of area files in `dir`, from a module in the folder `from`. */
109
+ export const fileSpecifiers = (from: string, dir: string): Specifiers => ({
110
+ area: (area, locale) => relativeImport(from, join(dir, areaFile(area, locale))),
111
+ generated: (path) => relativeImport(from, path),
112
+ });
113
+
114
+ export const areaOf = (key: string) => key.slice(0, key.indexOf('.'));
115
+
116
+ /** The entries of some areas: their keys start with an area's name. */
117
+ export const inAreas = (entries: Entry[], areas: readonly string[]) => entries.filter(([key]) => areas.includes(areaOf(key)));
118
+
119
+ /**
120
+ * Each area to the group whose file its lazy locales load from, named by the group's first area: the
121
+ * areas a bundler puts in one chunk (`areaGroups` in `chunks.ts`), so they load in one request.
122
+ */
123
+ export type Groups = ReadonlyMap<string, string>;
124
+
125
+ /** The areas whose lazy locales load from the group `lead` names. */
126
+ export const membersOf = (groups: Groups, lead: string) => [...groups].filter(([, g]) => g === lead).map(([area]) => area);
127
+
128
+ export const areasOf = (generated: Generated) => new Set(generated.catalog.messages.map((m) => m.area));
129
+
130
+ /** Areas `keep` names, which load with the index of a split catalog, as no module may name their keys. */
131
+ export const eagerAreas = (generated: Generated, keep: string[] = []) => {
132
+ const known = areasOf(generated);
133
+ return new Set(keep.map(areaOf).filter((a) => known.has(a)));
134
+ };
135
+
136
+ /** The areas that load with the code naming their keys: all but those `keep` loads with the index. */
137
+ export const lazyAreas = (generated: Generated, keep?: string[]) => {
138
+ const eager = eagerAreas(generated, keep);
139
+ return new Set([...areasOf(generated)].filter((a) => !eager.has(a)));
140
+ };
141
+
142
+ /** Which of `areas` the keys `code` names are in. */
143
+ export function areasNamed(code: string, areas: ReadonlySet<string>): string[] {
144
+ const named = keyRefs(code);
145
+ return [...new Set([...named.exact, ...named.prefixes].map(areaOf))].filter((a) => areas.has(a)).toSorted();
146
+ }
147
+
148
+ /**
149
+ * `code` importing each of `areas`. At the end, as imports run first wherever they are, so no line
150
+ * moves; in a component file, at the end of its first script.
151
+ */
152
+ export function importingAreas(code: string, path: string, areas: string[], specifier = VIRTUAL.area): string {
153
+ const imports = areas.map((a) => `import ${JSON.stringify(specifier(a))};`).join('');
154
+ if (!SFC.test(path)) return `${code}\n${imports}\n`;
155
+ const close = code.indexOf('</script>');
156
+ return close === -1 ? `${code}\n<script>${imports}</script>\n` : `${code.slice(0, close)};${imports}${code.slice(close)}`;
157
+ }
158
+
159
+ /** What a split catalog's areas register under: the generated folder, from the config's. */
160
+ const splitId = (config: ResolvedConfig) => relative(config.root, config.out).replaceAll('\\', '/');
161
+
162
+ /** A generated module's path, with forward slashes. */
163
+ const generatedPath = (config: ResolvedConfig, name: string) =>
164
+ join(config.out, `${name}.${config.language === 'ts' ? 'ts' : 'js'}`).replaceAll('\\', '/');
165
+
166
+ /** The index of a split catalog, in the browser: no messages of its own, only the areas `keep` names. */
167
+ export function splitIndex(config: ResolvedConfig, generated: Generated, keep?: string[], where = VIRTUAL): string {
168
+ const locales = [...generated.entries.keys()];
169
+ const split = { id: splitId(config), areas: [...areasOf(generated)] };
170
+ return [
171
+ "import { defineCatalog } from 'prosebook';",
172
+ ...[...eagerAreas(generated, keep)].map((a) => `import ${JSON.stringify(where.area(a))};`),
173
+ `export const catalog = defineCatalog(${JSON.stringify({ source: config.source, locales, messages: {}, loaders: {}, split })});`,
174
+ '',
175
+ ].join('\n');
176
+ }
177
+
178
+ /** How an area's module holds a locale that ships with it in a build: as an expression, from its entries. */
179
+ export type Inline = (locale: string, entries: Entry[]) => string;
180
+
181
+ /**
182
+ * An area's module: registers its messages in the locales that ship with it, and loaders for the
183
+ * rest, and waits until every copy holds it. In a build, `inline` writes the first, and each of the
184
+ * rest loads its part of the area's group, shared with the areas `groups` puts beside it, which the
185
+ * copy takes only this area's keys from; in dev, without it, each locale is its generated module
186
+ * whole, so an edit updates it as it would without splitting.
187
+ */
188
+ export function areaModule(
189
+ config: ResolvedConfig,
190
+ generated: Generated,
191
+ area: string,
192
+ inline?: Inline,
193
+ where = VIRTUAL,
194
+ groups: Groups = new Map(),
195
+ ): string {
196
+ const lines = ["import { addArea } from 'prosebook';"];
197
+ const messages: string[] = [];
198
+ const loaders: string[] = [];
199
+ for (const [locale, entries] of generated.entries) {
200
+ const name = JSON.stringify(locale);
201
+ const own = inAreas(entries, [area]);
202
+ const whole = JSON.stringify(where.generated(generatedPath(config, locale)));
203
+ if (locale === config.source || config.bundled.includes(locale)) {
204
+ if (inline !== undefined) {
205
+ messages.push(`${name}: ${inline(locale, own)}`);
206
+ } else {
207
+ lines.push(`import m${messages.length} from ${whole};`);
208
+ messages.push(`${name}: m${messages.length}`);
209
+ }
210
+ } else if (inline === undefined) {
211
+ loaders.push(`${name}: () => import(${whole})`);
212
+ } else if (own.length > 0) {
213
+ loaders.push(`${name}: () => import(${JSON.stringify(where.area(groups.get(area) ?? area, locale))})`);
214
+ }
215
+ }
216
+ const args = [JSON.stringify(splitId(config)), JSON.stringify(area), `{ ${messages.join(', ')} }`, `{ ${loaders.join(', ')} }`];
217
+ lines.push(`await addArea(${args.join(', ')});`, '');
218
+ return lines.join('\n');
219
+ }
220
+
80
221
  /** A locale's module holding only the keys `kept`, as JavaScript, for a bundler to load in its place. */
81
222
  export function prunedModule(entries: Entry[], kept: (key: string) => boolean): string {
82
223
  return localeModule(compileLocale(entries, kept), '// Pruned by prosebook: only the messages the code names.', false);
@@ -312,7 +312,16 @@ function icuError(text: string, e: unknown): string {
312
312
  return `is not valid ICU MessageFormat: ${reason}`;
313
313
  }
314
314
 
315
- function yamlFiles(dir: string): string[] {
315
+ /** The first line of a catalog file: the YAML language server finds the schema `generate` writes only through it. */
316
+ export const SCHEMA_LINE = '# yaml-language-server: $schema=./catalog.schema.json';
317
+
318
+ /** `text` opening with the schema line, unless it opens with a schema line of its own already. */
319
+ export function withSchemaLine(text: string): string {
320
+ if (text.startsWith('# yaml-language-server:')) return text;
321
+ return `${SCHEMA_LINE}${text.includes('\r\n') ? '\r\n' : '\n'}${text}`;
322
+ }
323
+
324
+ export function yamlFiles(dir: string): string[] {
316
325
  return readdirSync(dir)
317
326
  .filter((f) => f.endsWith('.yaml'))
318
327
  .sort();
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Which areas of a split catalog a bundler puts in one chunk, so a lazy locale's messages for them
3
+ * can be one file: a route reading three areas loads German in one request, not three.
4
+ *
5
+ * Read from the source's imports ahead of the build, as Bun and Next give a plugin no say in chunks
6
+ * and Vite loads an area before it has seen every module naming it. Bundlers place a module by the
7
+ * entries that load it: the app's own, and each dynamic import, less those that only ever load once
8
+ * it is already in memory, as a route its shell imports. This reads the same from the files under
9
+ * `scan.dirs`, with relative imports resolved. It is a guess, and only a request rides on it: an
10
+ * import it can't resolve (an alias, a package) leaves apart areas that may share a chunk, and two
11
+ * areas grouped in different chunks load each other's messages, which the copy leaves unread.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { dirname, join, resolve } from 'node:path';
15
+
16
+ import { parseSync } from 'oxc-parser';
17
+
18
+ import { areasNamed, areasOf, eagerAreas, type Groups } from './bundle.ts';
19
+ import type { ResolvedConfig } from './config.ts';
20
+ import type { Generated } from './generate.ts';
21
+ import { scriptBlocks, SFC, sourceFiles } from './scan.ts';
22
+
23
+ /** Tests and stories import components but load in no page, and as entries of their own they would part the areas they reach. */
24
+ const NOT_IN_APP = /\.(?:test|spec|stories)\.[^./\\]+$|[\\/]__tests__[\\/]/;
25
+
26
+ interface Imports {
27
+ static: string[];
28
+ dynamic: string[];
29
+ }
30
+
31
+ /** A module's imports that load code: `import type` and `export type … from` load none. */
32
+ function importsOf(file: string, code: string): Imports {
33
+ const pieces = SFC.test(file) ? scriptBlocks(code) : [{ code, at: 0 }];
34
+ const found: Imports = { static: [], dynamic: [] };
35
+ for (const piece of pieces) {
36
+ const { module } = parseSync(SFC.test(file) ? 'component.ts' : file, piece.code);
37
+ for (const i of module.staticImports)
38
+ if (i.entries.length === 0 || i.entries.some((e) => !e.isType)) found.static.push(i.moduleRequest.value);
39
+ for (const e of module.staticExports.flatMap((s) => s.entries))
40
+ if (e.moduleRequest !== null && !e.isType) found.static.push(e.moduleRequest.value);
41
+ for (const d of module.dynamicImports) {
42
+ // Only a literal names a module; `import(\`./pages/${name}.tsx\`)` could be any.
43
+ const literal = /^(['"`])([^'"`$]*)\1$/.exec(piece.code.slice(d.moduleRequest.start, d.moduleRequest.end).trim());
44
+ if (literal !== null) found.dynamic.push(literal[2]!);
45
+ }
46
+ }
47
+ return found;
48
+ }
49
+
50
+ /** The file a relative specifier names, as a bundler finds it: as written, with an extension, as `./x.js` for `x.ts`, or as a folder's index. */
51
+ function resolver(files: ReadonlySet<string>, extensions: string[]): (from: string, specifier: string) => string | undefined {
52
+ return (from, specifier) => {
53
+ if (!specifier.startsWith('.')) return undefined;
54
+ const base = resolve(dirname(from), specifier);
55
+ const stem = base.replace(/\.[cm]?jsx?$/, '');
56
+ const candidates = [
57
+ base,
58
+ ...extensions.map((e) => base + e),
59
+ ...extensions.map((e) => stem + e),
60
+ ...extensions.map((e) => join(base, `index${e}`)),
61
+ ];
62
+ return candidates.find((c) => files.has(c));
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The groups of a split catalog's areas. Areas `keep` names load with the index, together; one no
68
+ * file under `scan.dirs` names is a group of its own.
69
+ */
70
+ export function areaGroups(config: ResolvedConfig, generated: Generated, keep?: string[]): Groups {
71
+ const eager = eagerAreas(generated, keep);
72
+ const lazy = new Set([...areasOf(generated)].filter((a) => !eager.has(a)));
73
+ const files = sourceFiles(config.scan.dirs, config.scan.extensions, [config.out])
74
+ .filter((f) => !NOT_IN_APP.test(f))
75
+ .map((f) => resolve(f));
76
+ const find = resolver(new Set(files), config.scan.extensions);
77
+
78
+ // The module graph, an area a node of its own that each file naming its keys imports.
79
+ const node = (area: string) => `\0${area}`;
80
+ const statics = new Map<string, string[]>();
81
+ const dynamics = new Map<string, Set<string>>();
82
+ const imported = new Set<string>();
83
+ for (const file of files) {
84
+ const code = readFileSync(file, 'utf8');
85
+ const { static: s, dynamic: d } = importsOf(file, code);
86
+ const to = (specifiers: string[]) => specifiers.map((spec) => find(file, spec)).filter((f) => f !== undefined);
87
+ statics.set(file, [...to(s), ...areasNamed(code, lazy).map(node)]);
88
+ for (const target of to(d)) {
89
+ if (!dynamics.has(target)) dynamics.set(target, new Set());
90
+ dynamics.get(target)!.add(file);
91
+ }
92
+ for (const f of [...to(s), ...to(d)]) imported.add(f);
93
+ }
94
+
95
+ // Entries: each dynamic import, and each file nothing imports, as a page or the app's own entry.
96
+ const entries = [...new Set([...files.filter((f) => !imported.has(f)), ...dynamics.keys()])];
97
+ const closure = new Map<string, Set<string>>();
98
+ const loadedBy = new Map<string, Set<string>>();
99
+ for (const entry of entries) {
100
+ const seen = new Set<string>();
101
+ const visit = (n: string) => {
102
+ if (seen.has(n)) return;
103
+ seen.add(n);
104
+ if (!loadedBy.has(n)) loadedBy.set(n, new Set());
105
+ loadedBy.get(n)!.add(entry);
106
+ for (const next of statics.get(n) ?? []) visit(next);
107
+ };
108
+ visit(entry);
109
+ closure.set(entry, seen);
110
+ }
111
+
112
+ // What is in memory whenever a dynamic entry loads: what every module importing it brought, by
113
+ // every entry that loads that module. It only grows, so it settles.
114
+ const inMemory = new Map<string, Set<string>>([...dynamics.keys()].map((d) => [d, new Set()]));
115
+ const nodes = [...loadedBy.keys()];
116
+ for (let changed = true; changed;) {
117
+ changed = false;
118
+ for (const [entry, importers] of dynamics) {
119
+ const by = [...importers].map((i) => [...(loadedBy.get(i) ?? [])]);
120
+ if (by.some((e) => e.length === 0)) continue;
121
+ const held = (n: string) => by.every((e) => e.every((root) => closure.get(root)!.has(n) || inMemory.get(root)?.has(n) === true));
122
+ const next = new Set(nodes.filter(held));
123
+ if (next.size > inMemory.get(entry)!.size) {
124
+ inMemory.set(entry, next);
125
+ changed = true;
126
+ }
127
+ }
128
+ }
129
+
130
+ const groups = new Map<string, string>();
131
+ const byChunk = new Map<string, string[]>();
132
+ const sorted = [...lazy].toSorted();
133
+ for (const area of sorted) {
134
+ const loading = [...(loadedBy.get(node(area)) ?? [])].filter((e) => inMemory.get(e)?.has(node(area)) !== true).toSorted();
135
+ // Named by no file read here, so where it lands is unknown: a group of its own.
136
+ const chunk = loading.length === 0 ? node(area) : loading.join('\n');
137
+ if (!byChunk.has(chunk)) byChunk.set(chunk, []);
138
+ byChunk.get(chunk)!.push(area);
139
+ }
140
+ for (const members of [...byChunk.values(), [...eager].toSorted()]) for (const area of members) groups.set(area, members[0]!);
141
+ return groups;
142
+ }
@@ -57,21 +57,35 @@ export interface LintConfig extends WordLists {
57
57
 
58
58
  /**
59
59
  * Native code generated from the catalog, for the parts of an app outside the browser: a tray, a
60
- * menu bar, a CLI. One file, holding every locale, with a typed function per message.
60
+ * menu bar, a CLI. One file, holding every locale, with a typed function per message; for Android and
61
+ * Apple, string resources, a file per locale.
61
62
  */
62
63
  export interface NativeTarget {
63
- language: 'rust' | 'swift' | 'kotlin';
64
- /** The file written. */
64
+ language: 'rust' | 'swift' | 'kotlin' | 'android' | 'apple';
65
+ /**
66
+ * The file written; for Android, the `res` folder its `values*` folders go in, and for Apple, the
67
+ * folder its `.lproj` folders go in.
68
+ */
65
69
  out: string;
66
70
  /**
67
71
  * Key prefixes the file holds: `['native']` takes every `native.*` key, which it names without the
68
72
  * prefix (`native.tray.quit` is `tray_quit`). Every key, named in full, by default.
69
73
  */
70
74
  keys?: string[];
71
- /** The type holding the locales and messages: `Locale` in Rust, `Copy` in Swift and Kotlin. */
75
+ /**
76
+ * The type holding the locales and messages: `Locale` in Rust, `Copy` in Swift and Kotlin. For
77
+ * Android, the resource file's name: `strings`, for `values/strings.xml`. For Apple, the table's:
78
+ * `Localizable`, or `InfoPlist` with `plist`.
79
+ */
72
80
  name?: string;
73
81
  /** Kotlin's package. */
74
82
  package?: string;
83
+ /**
84
+ * For Apple, the Info.plist the table translates: each key names one of its strings, spelled in
85
+ * words (`ns-camera-usage-description` is `NSCameraUsageDescription`), and the source must say
86
+ * what the plist says.
87
+ */
88
+ plist?: string;
75
89
  }
76
90
 
77
91
  export interface ProsebookConfig {
@@ -150,7 +164,7 @@ export interface ResolvedConfig {
150
164
  scan: { dirs: string[]; extensions: string[]; attributes: string[] };
151
165
  }
152
166
 
153
- const NATIVE_NAMES = { rust: 'Locale', swift: 'Copy', kotlin: 'Copy' };
167
+ const NATIVE_NAMES = { rust: 'Locale', swift: 'Copy', kotlin: 'Copy', android: 'strings', apple: 'Localizable' };
154
168
 
155
169
  /** Typed config, for `prosebook.config.ts`. */
156
170
  export function defineConfig(config: ProsebookConfig): ProsebookConfig {
@@ -217,11 +231,15 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
217
231
  native: (config.native ?? []).map((target, i) => {
218
232
  if (!(target.language in NATIVE_NAMES))
219
233
  throw new Error(`native[${i}].language is "${target.language}": name one of ${Object.keys(NATIVE_NAMES).join(', ')}`);
220
- return { ...target, out: at(target.out), name: target.name ?? NATIVE_NAMES[target.language] };
234
+ if (target.plist !== undefined && target.language !== 'apple')
235
+ throw new Error(`native[${i}].plist names an Info.plist, which only the apple target reads: remove it, or set language to 'apple'`);
236
+ const plist = target.plist === undefined ? {} : { plist: at(target.plist) };
237
+ const name = target.name ?? (target.plist === undefined ? NATIVE_NAMES[target.language] : 'InfoPlist');
238
+ return { ...target, out: at(target.out), name, ...plist };
221
239
  }),
222
240
  scan: {
223
241
  dirs: (config.scan?.dirs ?? ['src']).map(at),
224
- extensions: config.scan?.extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'],
242
+ extensions: config.scan?.extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.vue', '.svelte'],
225
243
  attributes: config.scan?.attributes ?? ['alt', 'title', 'placeholder', 'label', 'aria-label', 'aria-description', 'aria-placeholder'],
226
244
  },
227
245
  };
@@ -15,6 +15,7 @@ import { isMap, isScalar, parseDocument, Scalar, type Document, type YAMLMap } f
15
15
 
16
16
  import { CatalogError, loadCatalog, type Catalog, type Message, type Problem } from './catalog.ts';
17
17
  import type { ResolvedConfig } from './config.ts';
18
+ import { readReturned } from './exchange.ts';
18
19
  import { applyEdits, lint, type Edit, type Finding } from './lint.ts';
19
20
 
20
21
  export interface EntryFields {
@@ -178,46 +179,44 @@ export interface Imported {
178
179
  }
179
180
 
180
181
  /**
181
- * Translations into `locale` from `prosebook todo`'s YAML filled in, by a person or a model, each
182
- * written to the file its locale and area put it in. Text around a fenced block is left aside, as a
183
- * model's reply has it, and an empty value is one left for later. An entry that would break the
184
- * catalog is left out with its problem, so a reply with one bad entry still lands the others.
182
+ * Translations into `locale` from `prosebook todo`'s YAML filled in, by a person or a model, or from
183
+ * a translation tool's XLIFF or JSON (`readReturned`), each written to the file its locale and area
184
+ * put it in. An empty value is one left for later. An entry that would break the catalog is left out
185
+ * with its problem, so a reply with one bad entry still lands the others.
185
186
  */
186
187
  export function importTranslations(config: ResolvedConfig, locale: string, reply: string): Imported {
187
188
  const failed = (message: string, key?: string): Problem => ({ severity: 'error', key, message });
188
189
  if (locale === config.source)
189
190
  return { written: [], unchanged: [], problems: [failed(`${locale} is the source: import takes translations`)] };
190
- const fenced = [...reply.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)].map((m) => m[1]);
191
- const doc = parseDocument(fenced.length > 0 ? fenced.join('\n') : reply);
192
- if (doc.errors.length > 0 || !(isMap(doc.contents) || doc.contents === null)) {
193
- const why = doc.errors[0]?.message.split('\n', 1)[0] ?? 'it is not a mapping';
194
- return {
195
- written: [],
196
- unchanged: [],
197
- problems: [failed(`the reply isn't YAML of \`key: translation\` (${why}): fix it, or ask for one YAML block`)],
198
- };
199
- }
191
+ const read = readReturned(reply, locale, config.source);
192
+ if ('problem' in read) return { written: [], unchanged: [], problems: [failed(read.problem)] };
200
193
 
201
194
  const catalog = loadCatalog(config);
202
195
  const problems: Problem[] = [];
203
196
  const unchanged: string[] = [];
204
197
  const pending = new Map<string, { file: string; text: string }>();
205
- for (const pair of isMap(doc.contents) ? doc.contents.items : []) {
206
- const key = isScalar(pair.key) ? String(pair.key.value) : String(pair.key);
198
+ for (const { key, value, checked, source, problem } of read.returned) {
207
199
  const message = catalog.messages.find((m) => m.key === key);
208
200
  if (message === undefined) {
209
- problems.push(failed('no entry has this key: translate only the keys `prosebook todo` printed', key));
201
+ problems.push(failed('no entry has this key: translate only the keys `prosebook todo` or `export` gave out', key));
202
+ continue;
203
+ }
204
+ if (problem !== undefined) {
205
+ problems.push(failed(problem, key));
210
206
  continue;
211
207
  }
212
- const value = isScalar(pair.value) ? pair.value.value : pair.value;
213
208
  if (value === null || value === '') continue;
214
209
  if (typeof value !== 'string' && typeof value !== 'number') {
215
210
  problems.push(failed('the translation is not text: give the message as one string', key));
216
211
  continue;
217
212
  }
213
+ if (source !== undefined && source !== message.text) {
214
+ problems.push(failed(`the source has changed since it was exported, to "${message.text}": export it again`, key));
215
+ continue;
216
+ }
218
217
  const text = String(value);
219
- if (text === message.translations.get(locale)?.text) unchanged.push(key);
220
- else pending.set(key, { file: translationFile(config, message, locale), text });
218
+ if (text !== message.translations.get(locale)?.text) pending.set(key, { file: translationFile(config, message, locale), text });
219
+ else if (checked) unchanged.push(key);
221
220
  }
222
221
 
223
222
  // An entry whose own file reports it is left out, until what is left loads clean. A problem