prosebook 0.1.0 → 0.3.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 (114) hide show
  1. package/README.md +311 -57
  2. package/dist/bun.d.ts +34 -0
  3. package/dist/bun.d.ts.map +1 -0
  4. package/dist/bun.js +98 -0
  5. package/dist/bun.js.map +1 -0
  6. package/dist/bundle-DboItAre.js +111 -0
  7. package/dist/bundle-DboItAre.js.map +1 -0
  8. package/dist/bundle-KD-DiiFa.d.ts +6 -0
  9. package/dist/bundle-KD-DiiFa.d.ts.map +1 -0
  10. package/dist/cli.js +51 -56
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compile-xF3F4oIp.d.ts +16 -0
  13. package/dist/compile-xF3F4oIp.d.ts.map +1 -0
  14. package/dist/{config-CxVMn19f.js → config-BPSmoYel.js} +82 -196
  15. package/dist/config-BPSmoYel.js.map +1 -0
  16. package/dist/{config-CF7HziDa.d.ts → config-DdMkKt_N.d.ts} +85 -7
  17. package/dist/config-DdMkKt_N.d.ts.map +1 -0
  18. package/dist/config.d.ts +2 -2
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
  21. package/dist/copy-B3V_QRkT.d.ts.map +1 -0
  22. package/dist/copy-Bo_FIoLy.js +154 -0
  23. package/dist/copy-Bo_FIoLy.js.map +1 -0
  24. package/dist/endpoints-CnUfCkIk.js +96 -0
  25. package/dist/endpoints-CnUfCkIk.js.map +1 -0
  26. package/dist/generate-9pR3i5Bj.js +5173 -0
  27. package/dist/generate-9pR3i5Bj.js.map +1 -0
  28. package/dist/helpers-BWGcD0kS.js +96 -0
  29. package/dist/helpers-BWGcD0kS.js.map +1 -0
  30. package/dist/icu-Dej0mNsF.js +218 -0
  31. package/dist/icu-Dej0mNsF.js.map +1 -0
  32. package/dist/index.d.ts +12 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -188
  35. package/dist/inspector.d.ts +4 -1
  36. package/dist/inspector.d.ts.map +1 -1
  37. package/dist/inspector.js +7 -2
  38. package/dist/inspector.js.map +1 -1
  39. package/dist/locale-DpQA1pqH.js +70 -0
  40. package/dist/locale-DpQA1pqH.js.map +1 -0
  41. package/dist/next-loader.d.ts +11 -0
  42. package/dist/next-loader.d.ts.map +1 -0
  43. package/dist/next-loader.js +17 -0
  44. package/dist/next-loader.js.map +1 -0
  45. package/dist/next.d.ts +30 -0
  46. package/dist/next.d.ts.map +1 -0
  47. package/dist/next.js +160 -0
  48. package/dist/next.js.map +1 -0
  49. package/dist/react/index.d.ts +3 -3
  50. package/dist/react/index.d.ts.map +1 -1
  51. package/dist/react/index.js.map +1 -1
  52. package/dist/react/server.d.ts +20 -0
  53. package/dist/react/server.d.ts.map +1 -0
  54. package/dist/react/server.js +34 -0
  55. package/dist/react/server.js.map +1 -0
  56. package/dist/{sarif-PkPvsB07.js → sarif-CWyTAQ0y.js} +103 -87
  57. package/dist/sarif-CWyTAQ0y.js.map +1 -0
  58. package/dist/scan-Bx0_yDSi.js +387 -0
  59. package/dist/scan-Bx0_yDSi.js.map +1 -0
  60. package/dist/tools.d.ts +83 -64
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/tools.js +5 -4
  63. package/dist/vite.d.ts +14 -4
  64. package/dist/vite.d.ts.map +1 -1
  65. package/dist/vite.js +60 -114
  66. package/dist/vite.js.map +1 -1
  67. package/package.json +37 -4
  68. package/src/bun.ts +132 -0
  69. package/src/cli.ts +60 -58
  70. package/src/index.ts +1 -1
  71. package/src/inspector.ts +8 -3
  72. package/src/next-loader.ts +16 -0
  73. package/src/next.ts +188 -0
  74. package/src/react/index.tsx +6 -6
  75. package/src/react/server.ts +37 -0
  76. package/src/runtime/copy.ts +22 -3
  77. package/src/runtime/helpers.ts +54 -18
  78. package/src/runtime/locale.ts +19 -0
  79. package/src/runtime/types.ts +21 -6
  80. package/src/tools/bundle.ts +131 -0
  81. package/src/tools/catalog.ts +2 -2
  82. package/src/tools/compile.ts +61 -13
  83. package/src/tools/config.ts +58 -3
  84. package/src/tools/endpoints.ts +121 -0
  85. package/src/tools/entry.ts +77 -0
  86. package/src/tools/generate.ts +32 -32
  87. package/src/tools/icu.ts +93 -8
  88. package/src/tools/index.ts +20 -4
  89. package/src/tools/lint.ts +49 -12
  90. package/src/tools/lock.ts +7 -0
  91. package/src/tools/mf1.ts +21 -7
  92. package/src/tools/mf2.ts +249 -57
  93. package/src/tools/migrate.ts +3 -2
  94. package/src/tools/native/emit.ts +87 -0
  95. package/src/tools/native/index.ts +36 -0
  96. package/src/tools/native/kotlin.ts +269 -0
  97. package/src/tools/native/model.ts +280 -0
  98. package/src/tools/native/number.ts +210 -0
  99. package/src/tools/native/plurals.ts +103 -0
  100. package/src/tools/native/rust.ts +367 -0
  101. package/src/tools/native/swift.ts +326 -0
  102. package/src/tools/scan.ts +134 -4
  103. package/src/tools/syntax.ts +3 -3
  104. package/src/tools/todo.ts +118 -0
  105. package/src/vite.ts +74 -116
  106. package/dist/config-CF7HziDa.d.ts.map +0 -1
  107. package/dist/config-CxVMn19f.js.map +0 -1
  108. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  109. package/dist/helpers-BPuPGjQP.js +0 -61
  110. package/dist/helpers-BPuPGjQP.js.map +0 -1
  111. package/dist/index.js.map +0 -1
  112. package/dist/sarif-PkPvsB07.js.map +0 -1
  113. package/dist/scan-B0tZ7y-D.js +0 -2447
  114. package/dist/scan-B0tZ7y-D.js.map +0 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The dev server's half of the inspector (`inspector.ts`, `inspector-dialog.ts`): `__prosebook/open`,
3
+ * which opens an entry in the editor, and `__prosebook/entry`, which reads and edits one. Written over
4
+ * `Request` and `Response`, so each integration mounts them its own way: Vite as middleware, Bun as
5
+ * `Bun.serve` routes, Next behind a rewrite to a server of their own. Each opens the editor its dev
6
+ * server already launches.
7
+ */
8
+ import type { IncomingMessage, ServerResponse } from 'node:http';
9
+
10
+ import { CatalogError, entryLocation, loadCatalog, type EntryLocation } from './catalog.ts';
11
+ import type { ResolvedConfig } from './config.ts';
12
+ import { editEntry, readEntry, type EntryEdit } from './entry.ts';
13
+
14
+ export interface EndpointOptions {
15
+ /** The path the endpoints sit under, ending in `/`: `/` by default. */
16
+ base?: string;
17
+ /** Opens an entry in the editor: a redirect to the dev server's own launcher, or a reply once it has. */
18
+ open: (at: EntryLocation) => Response | Promise<Response>;
19
+ }
20
+
21
+ /** Answers the inspector's requests, and nothing for any other path. */
22
+ export function inspectorEndpoints(
23
+ config: ResolvedConfig | Promise<ResolvedConfig>,
24
+ { base = '/', open }: EndpointOptions,
25
+ ): (req: Request) => Promise<Response | undefined> {
26
+ return async (req) => {
27
+ const url = new URL(req.url);
28
+ if (url.pathname === `${base}__prosebook/open`) return openEntry(await config, url, open);
29
+ if (url.pathname === `${base}__prosebook/entry`) return entry(await config, req, url);
30
+ return undefined;
31
+ };
32
+ }
33
+
34
+ /**
35
+ * `?key=cart.items&locale=de`: the translation's entry, or the catalog's with `catalog` or when the
36
+ * locale has none. Read from disk, so a key added since the page loaded is found.
37
+ */
38
+ async function openEntry(config: ResolvedConfig, url: URL, open: EndpointOptions['open']): Promise<Response> {
39
+ const key = url.searchParams.get('key') ?? '';
40
+ let message;
41
+ try {
42
+ message = loadCatalog(config).messages.find((m) => m.key === key);
43
+ } catch (e) {
44
+ return failed(e);
45
+ }
46
+ if (message === undefined)
47
+ return new Response(`no entry for "${key}" in the catalog: has it been renamed since the page loaded?`, { status: 404 });
48
+ const locale = url.searchParams.has('catalog') ? undefined : (url.searchParams.get('locale') ?? undefined);
49
+ return open(entryLocation(config, message, locale));
50
+ }
51
+
52
+ /**
53
+ * The inspector's dialog: GET `?key=…` reads an entry in every locale, POST `{ key, edit, write }`
54
+ * applies an edit (`entry.ts`). A POST writes files, so only the page itself may send one: same origin,
55
+ * and JSON, which a form on another site can't send without asking first.
56
+ */
57
+ async function entry(config: ResolvedConfig, req: Request, url: URL): Promise<Response> {
58
+ if (req.method === 'GET') {
59
+ try {
60
+ return Response.json(readEntry(config, url.searchParams.get('key') ?? ''));
61
+ } catch (e) {
62
+ return failed(e);
63
+ }
64
+ }
65
+ const origin = req.headers.get('origin');
66
+ const sameOrigin = origin === null || new URL(origin).host === url.host;
67
+ if (req.method !== 'POST' || !sameOrigin || req.headers.get('content-type')?.startsWith('application/json') !== true)
68
+ return Response.json({ problems: [{ severity: 'error', message: 'edits come from the page, as JSON' }] }, { status: 403 });
69
+ try {
70
+ const { key, edit, write } = (await req.json()) as { key: string; edit: EntryEdit; write?: boolean };
71
+ const result = editEntry(config, key, edit, write === true);
72
+ return Response.json(result, { status: result.problems.length > 0 ? 422 : 200 });
73
+ } catch (e) {
74
+ return failed(e);
75
+ }
76
+ }
77
+
78
+ /** A redirect within the page's origin, to a launcher the dev server answers itself. */
79
+ export const redirect = (path: string) => new Response(null, { status: 307, headers: { Location: path } });
80
+
81
+ function failed(e: unknown): Response {
82
+ return Response.json(
83
+ { problems: e instanceof CatalogError ? e.problems : [{ severity: 'error', message: e instanceof Error ? e.message : String(e) }] },
84
+ { status: e instanceof CatalogError ? 409 : 400 },
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The endpoints as Node middleware, for Vite's server and Next's. The request's host is the one the
90
+ * page was served from: a proxy's `x-forwarded-host`, as Next's rewrite sets, before its own.
91
+ */
92
+ export function nodeMiddleware(
93
+ handle: (req: Request) => Promise<Response | undefined>,
94
+ ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void {
95
+ return (req, res, next) => {
96
+ // Before reading the body, which a request for anything else leaves to whoever answers it.
97
+ if (req.url?.includes('/__prosebook/') !== true) return next();
98
+ const forwarded = req.headers['x-forwarded-host'];
99
+ const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host ?? 'localhost';
100
+ const headers = new Headers();
101
+ for (const [name, value] of Object.entries(req.headers))
102
+ for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value]) headers.append(name, v);
103
+
104
+ const chunks: Buffer[] = [];
105
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
106
+ req.on('end', () => {
107
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks);
108
+ handle(new Request(`http://${host}${req.url}`, { method: req.method, headers, body }))
109
+ .then(async (response) => {
110
+ if (response === undefined) return next();
111
+ res.statusCode = response.status;
112
+ response.headers.forEach((value, name) => res.setHeader(name, value));
113
+ res.end(Buffer.from(await response.arrayBuffer()));
114
+ })
115
+ .catch((e: unknown) => {
116
+ res.statusCode = 500;
117
+ res.end(e instanceof Error ? e.message : String(e));
118
+ });
119
+ });
120
+ };
121
+ }
@@ -168,6 +168,83 @@ export function applyFixes(config: ResolvedConfig, catalog: Catalog, findings: F
168
168
  return { fixed, problems: [] };
169
169
  }
170
170
 
171
+ export interface Imported {
172
+ /** Keys whose translation was written. */
173
+ written: string[];
174
+ /** Keys given back as they were: each still holds, so a stale one can be accepted. */
175
+ unchanged: string[];
176
+ /** What was left out, and why. The rest was written regardless. */
177
+ problems: Problem[];
178
+ }
179
+
180
+ /**
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.
185
+ */
186
+ export function importTranslations(config: ResolvedConfig, locale: string, reply: string): Imported {
187
+ const failed = (message: string, key?: string): Problem => ({ severity: 'error', key, message });
188
+ if (locale === config.source)
189
+ 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
+ }
200
+
201
+ const catalog = loadCatalog(config);
202
+ const problems: Problem[] = [];
203
+ const unchanged: string[] = [];
204
+ 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);
207
+ const message = catalog.messages.find((m) => m.key === key);
208
+ if (message === undefined) {
209
+ problems.push(failed('no entry has this key: translate only the keys `prosebook todo` printed', key));
210
+ continue;
211
+ }
212
+ const value = isScalar(pair.value) ? pair.value.value : pair.value;
213
+ if (value === null || value === '') continue;
214
+ if (typeof value !== 'string' && typeof value !== 'number') {
215
+ problems.push(failed('the translation is not text: give the message as one string', key));
216
+ continue;
217
+ }
218
+ 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 });
221
+ }
222
+
223
+ // An entry whose own file reports it is left out, until what is left loads clean. A problem
224
+ // anywhere else means the catalog was broken before the reply, so nothing is written.
225
+ for (;;) {
226
+ const unsaved = new Map<string, string>();
227
+ for (const [key, { file, text }] of pending)
228
+ unsaved.set(resolve(config.root, file), withTranslation(contentOf(config, file, unsaved), key, text));
229
+ try {
230
+ loadCatalog(config, unsaved);
231
+ } catch (e) {
232
+ if (!(e instanceof CatalogError)) throw e;
233
+ const errors = e.problems.filter((p) => p.severity === 'error');
234
+ const own = errors.filter((p) => p.key !== undefined && pending.get(p.key)?.file === p.file);
235
+ if (own.length === 0) return { written: [], unchanged: [], problems: [...problems, ...errors] };
236
+ for (const p of own) if (p.key !== undefined) pending.delete(p.key);
237
+ problems.push(...own);
238
+ continue;
239
+ }
240
+ for (const [path, content] of unsaved) {
241
+ mkdirSync(dirname(path), { recursive: true });
242
+ writeFileSync(path, content);
243
+ }
244
+ return { written: [...pending.keys()], unchanged, problems };
245
+ }
246
+ }
247
+
171
248
  /** A file as the edit has it, as it is on disk, or empty when it is still to be made. */
172
249
  function contentOf(config: ResolvedConfig, file: string, unsaved: Map<string, string>): string {
173
250
  const path = resolve(config.root, file);
@@ -12,6 +12,7 @@ import { loadCatalog, type Catalog, type Message } from './catalog.ts';
12
12
  import { compileLocale, localeModule, type Entry } from './compile.ts';
13
13
  import type { ResolvedConfig } from './config.ts';
14
14
  import { readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
15
+ import { nativeFiles } from './native/index.ts';
15
16
  import { pseudoAst } from './pseudo.ts';
16
17
  import { SYNTAX_NAME } from './syntax.ts';
17
18
 
@@ -127,44 +128,42 @@ function catalogLiteral(config: ResolvedConfig, locales: string[], ext: string):
127
128
 
128
129
  /** The JSON Schema an editor reads to complete and explain catalog entries. */
129
130
  export function catalogSchema(config: ResolvedConfig): object {
131
+ // The shorthand satisfies no required field, so it's offered only where none is.
132
+ const shorthand = { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}, with nothing said about it.` };
133
+ const entry = {
134
+ type: 'object',
135
+ required: [config.source, ...config.require],
136
+ additionalProperties: false,
137
+ properties: {
138
+ [config.source]: { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}.` },
139
+ kind: {
140
+ description: 'What sort of string it is.',
141
+ oneOf: Object.entries(config.kinds).map(([k, r]) => ({ const: k, description: r.doc })),
142
+ },
143
+ surface: {
144
+ description: 'Where the string sits.',
145
+ oneOf: Object.entries(config.surfaces).map(([k, d]) => ({ const: k, description: d })),
146
+ },
147
+ context: { type: 'string', description: 'One sentence telling a translator where the string appears and what it answers to.' },
148
+ args: { type: 'object', description: 'What each placeholder holds.', additionalProperties: { type: 'string' } },
149
+ fragment: { type: 'boolean', description: 'Completes something beside it rather than standing as a sentence.' },
150
+ comment: { type: 'string', description: 'A change asked of whoever edits the entry next. Make it, then delete the comment.' },
151
+ remove: { type: 'boolean', description: 'Asks for the entry to go: delete it, its translations and the code that reads it.' },
152
+ allow: {
153
+ type: 'object',
154
+ description: 'Lint rules this entry is exempt from, each with the reason.',
155
+ propertyNames: { enum: Object.keys(config.lint.rules) },
156
+ additionalProperties: { type: 'string' },
157
+ },
158
+ },
159
+ };
130
160
  return {
131
161
  $schema: 'https://json-schema.org/draft/2020-12/schema',
132
162
  title: 'prosebook catalog',
133
163
  description: 'Generated by `prosebook generate` from the config. Do not edit.',
134
164
  type: 'object',
135
165
  propertyNames: { pattern: '^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$' },
136
- additionalProperties: {
137
- oneOf: [
138
- { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}, with nothing said about it.` },
139
- {
140
- type: 'object',
141
- required: [config.source, ...config.require],
142
- additionalProperties: false,
143
- properties: {
144
- [config.source]: { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}.` },
145
- kind: {
146
- description: 'What sort of string it is.',
147
- oneOf: Object.entries(config.kinds).map(([k, r]) => ({ const: k, description: r.doc })),
148
- },
149
- surface: {
150
- description: 'Where the string sits.',
151
- oneOf: Object.entries(config.surfaces).map(([k, d]) => ({ const: k, description: d })),
152
- },
153
- context: { type: 'string', description: 'One sentence telling a translator where the string appears and what it answers to.' },
154
- args: { type: 'object', description: 'What each placeholder holds.', additionalProperties: { type: 'string' } },
155
- fragment: { type: 'boolean', description: 'Completes something beside it rather than standing as a sentence.' },
156
- comment: { type: 'string', description: 'A change asked of whoever edits the entry next. Make it, then delete the comment.' },
157
- remove: { type: 'boolean', description: 'Asks for the entry to go: delete it, its translations and the code that reads it.' },
158
- allow: {
159
- type: 'object',
160
- description: 'Lint rules this entry is exempt from, each with the reason.',
161
- propertyNames: { enum: Object.keys(config.lint.rules) },
162
- additionalProperties: { type: 'string' },
163
- },
164
- },
165
- },
166
- ],
167
- },
166
+ additionalProperties: config.require.length === 0 ? { oneOf: [shorthand, entry] } : entry,
168
167
  };
169
168
  }
170
169
 
@@ -231,6 +230,7 @@ export function render(config: ResolvedConfig, accept?: Map<string, Set<string>
231
230
  );
232
231
  }
233
232
 
233
+ for (const [path, content] of nativeFiles(config, catalog, locales, entries, head)) files.set(path, content);
234
234
  files.set(join(config.catalog, 'catalog.schema.json'), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
235
235
  if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
236
236
  return { catalog, files, lock, stale, entries };
package/src/tools/icu.ts CHANGED
@@ -9,7 +9,33 @@ export type { MessageFormatElement };
9
9
  export { TYPE };
10
10
 
11
11
  export function parseMessage(text: string): MessageFormatElement[] {
12
- return parse(text, { shouldParseSkeletons: true, requiresOtherClause: true });
12
+ const ast = parse(text, { shouldParseSkeletons: true, requiresOtherClause: true });
13
+ walk(ast, (el) => {
14
+ if ((isDateElement(el) || isTimeElement(el)) && typeof el.style === 'object' && el.style !== null && el.style.pattern.includes('j'))
15
+ el.style.parsedOptions = localHour(el.style.pattern);
16
+ });
17
+ return ast;
18
+ }
19
+
20
+ /**
21
+ * A skeleton's `j` is the hour as the locale writes it, 12 or 24. The parser resolves it only when
22
+ * given a locale and drops it otherwise, so `::yMMMdjmm` showed the minute alone. Read here as an
23
+ * hour with no cycle, which `Intl` gives the cycle of whatever locale formats it: "2:05 PM", "14:05".
24
+ */
25
+ function localHour(pattern: string): Intl.DateTimeFormatOptions {
26
+ const run = /j+/.exec(pattern)![0];
27
+ const rest = pattern.replaceAll('j', '');
28
+ return { ...(rest === '' ? {} : dateFields(rest)), hour: run.length % 2 === 0 ? '2-digit' : 'numeric' };
29
+ }
30
+
31
+ /** What the parser reads from a date skeleton on its own; nothing where it can't parse it. */
32
+ function dateFields(skeleton: string): Intl.DateTimeFormatOptions {
33
+ try {
34
+ const el = parse(`{d, date, ::${skeleton}}`, { shouldParseSkeletons: true })[0];
35
+ return el !== undefined && isDateElement(el) && typeof el.style === 'object' && el.style !== null ? el.style.parsedOptions : {};
36
+ } catch {
37
+ return {};
38
+ }
13
39
  }
14
40
 
15
41
  /**
@@ -39,6 +65,50 @@ function argType(el: MessageFormatElement): string | undefined {
39
65
  }
40
66
  }
41
67
 
68
+ /**
69
+ * Formatting options read from arguments as the message renders, by `Intl` option: MF2's
70
+ * `{$price :currency currency=$code}`. MF1 has no way to say this, so only a tree read from MF2 has them.
71
+ */
72
+ export type OptionArgs = Record<string, { arg: string; type: string }>;
73
+
74
+ export function optionArgsOf(el: MessageFormatElement): OptionArgs | undefined {
75
+ if (el.type === TYPE.plural) return selectionOf(el)?.args;
76
+ if (!isNumberElement(el) && !isDateElement(el) && !isTimeElement(el)) return undefined;
77
+ const style = el.style;
78
+ return typeof style === 'object' && style !== null && 'args' in style ? (style as { args: OptionArgs }).args : undefined;
79
+ }
80
+
81
+ /**
82
+ * How an MF2 `.match` selects on a number, beyond its plural type: on the number as formatted, so
83
+ * the selector's digit options (`Intl.PluralRules` takes the same ones, some from arguments) decide
84
+ * the category, and on the value `:integer` rounds or `:percent` multiplies by 100. Kept with the
85
+ * selector's function as written, to write it back. MF1 selects on the bare value.
86
+ */
87
+ export interface Selection {
88
+ fn: { name: string; options: Record<string, string>; args?: Record<string, string> };
89
+ options: Intl.PluralRulesOptions;
90
+ args?: OptionArgs;
91
+ value?: 'round' | 'percent';
92
+ }
93
+
94
+ export function selectionOf(el: MessageFormatElement): Selection | undefined {
95
+ return el.type === TYPE.plural ? (el as { selection?: Selection }).selection : undefined;
96
+ }
97
+
98
+ /**
99
+ * How a placeholder renders around its value: the text it shows when it has none, or one `Intl`
100
+ * can't format, and the marks isolating its direction from the text around it. MF2 sets these
101
+ * (`{$name}`, and isolation in a right-to-left locale); an MF1 placeholder shows `{name}`.
102
+ */
103
+ export interface Placeholder {
104
+ fallback: string;
105
+ isolate?: readonly [open: string, close: string];
106
+ }
107
+
108
+ export function placeholderOf(el: MessageFormatElement, name: string): Placeholder {
109
+ return (el as { placeholder?: Placeholder }).placeholder ?? { fallback: `{${name}}` };
110
+ }
111
+
42
112
  /** Calls `visit` on every element, inside plural and select arms and tags too. */
43
113
  export function walk(elements: MessageFormatElement[], visit: (el: MessageFormatElement) => void): void {
44
114
  for (const el of elements) {
@@ -50,12 +120,15 @@ export function walk(elements: MessageFormatElement[], visit: (el: MessageFormat
50
120
 
51
121
  export function argsOf(elements: MessageFormatElement[], clash: (name: string) => void = () => {}): Map<string, string> {
52
122
  const args = new Map<string, string>();
123
+ const add = (name: string, type: string) => {
124
+ const seen = args.get(name);
125
+ if (seen !== undefined && seen !== type) clash(name);
126
+ else args.set(name, type);
127
+ };
53
128
  walk(elements, (el) => {
129
+ for (const { arg, type } of Object.values(optionArgsOf(el) ?? {})) add(arg, type);
54
130
  const type = argType(el);
55
- if (type === undefined || !('value' in el) || typeof el.value !== 'string') return;
56
- const seen = args.get(el.value);
57
- if (seen !== undefined && seen !== type) clash(el.value);
58
- else args.set(el.value, type);
131
+ if (type !== undefined && 'value' in el && typeof el.value === 'string') add(el.value, type);
59
132
  });
60
133
  return args;
61
134
  }
@@ -95,8 +168,12 @@ export function numberOptions(el: NumberElement): Intl.NumberFormatOptions | und
95
168
  const unknown = style.tokens.filter((t) => !stemIsKnown(t.stem, t.options)).map((t) => t.stem);
96
169
  if (unknown.length > 0) throw new Error(`{${el.value}} has skeleton stems the formatter drops without a word: ${unknown.join(', ')}`);
97
170
  const options = style.parsedOptions as Intl.NumberFormatOptions;
171
+ // An option an argument gives is checked with a stand-in: `Intl` refuses a currency style without
172
+ // its currency. Whether the argument's value suits it is known only as the message renders.
173
+ const args = optionArgsOf(el) ?? {};
174
+ const given = { ...(args.currency && { currency: 'EUR' }), ...(args.unit && { unit: 'meter' }) };
98
175
  try {
99
- new Intl.NumberFormat('en', options).resolvedOptions();
176
+ new Intl.NumberFormat('en', { ...options, ...given }).resolvedOptions();
100
177
  } catch (e) {
101
178
  throw new Error(`{${el.value}} has a skeleton Intl refuses: ${(e as Error).message}`, { cause: e });
102
179
  }
@@ -115,6 +192,9 @@ export function dateOptions(el: DateElement | TimeElement): Intl.DateTimeFormatO
115
192
  }
116
193
  return { [field]: style };
117
194
  }
195
+ // Every field the parser understands sets an option, and one it doesn't is dropped without a word.
196
+ const dropped = (style.pattern.match(/([A-Za-z])*/g) ?? []).filter((f) => !f.startsWith('j') && Object.keys(dateFields(f)).length === 0);
197
+ if (dropped.length > 0) throw new Error(`{${el.value}} has date fields the formatter drops without a word: ${dropped.join(', ')}`);
118
198
  if (Object.keys(style.parsedOptions).length === 0)
119
199
  throw new Error(`{${el.value}} has a date skeleton the formatter reads nothing from: "${style.pattern}"`);
120
200
  try {
@@ -167,8 +247,13 @@ export function formatsOf(ast: MessageFormatElement[]): Map<string, string> {
167
247
  said = `${kind} \`${style}\``;
168
248
  } else if ('mf2' in style) {
169
249
  // Read from MF2: the function as written.
170
- const { name, options } = (style as { mf2: { name: string; options: Record<string, string> } }).mf2;
171
- said = `\`${[`:${name}`, ...Object.entries(options).map(([k, v]) => `${k}=${v}`)].join(' ')}\``;
250
+ const {
251
+ name,
252
+ options,
253
+ args = {},
254
+ } = (style as { mf2: { name: string; options: Record<string, string>; args?: Record<string, string> } }).mf2;
255
+ const written = [...Object.entries(options), ...Object.entries(args).map(([k, v]) => [k, `$${v}`])];
256
+ said = `\`${[`:${name}`, ...written.map(([k, v]) => `${k}=${v}`)].join(' ')}\``;
172
257
  } else if ('tokens' in style) {
173
258
  said = `${kind} \`::${style.tokens.map((t) => [t.stem, ...t.options].join('/')).join(' ')}\``;
174
259
  } else {
@@ -30,14 +30,16 @@ export {
30
30
  export {
31
31
  applyFixes,
32
32
  editEntry,
33
+ importTranslations,
33
34
  readEntry,
35
+ type Imported,
34
36
  type EntryEdit,
35
37
  type EntryFields,
36
38
  type EntryFinding,
37
39
  type EntryView,
38
40
  type EntryYaml,
39
41
  } from './entry.ts';
40
- export { defineConfig, loadConfig, resolveConfig, type ProsebookConfig, type ResolvedConfig } from './config.ts';
42
+ export { defineConfig, loadConfig, resolveConfig, type NativeTarget, type ProsebookConfig, type ResolvedConfig } from './config.ts';
41
43
  export { catalogSchema, generate, localeEntries, localesOf, render, writeFiles, type Generated } from './generate.ts';
42
44
  export {
43
45
  applyEdits,
@@ -66,13 +68,27 @@ export {
66
68
  type SubstitutionRule,
67
69
  } from './lint.ts';
68
70
  export { formatDiff, wordDiff, type DiffPart } from './diff.ts';
69
- export { emptyLock, hash, readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
71
+ export { changeOf, emptyLock, hash, readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
70
72
  export { DEFAULT_KINDS, DEFAULT_SURFACES, STRICT_LISTS, type KindRule, type Severity } from './presets.ts';
71
73
  export { pseudoAst, pseudoText } from './pseudo.ts';
72
- export { intlDate, intlNumber, parseMf2, toMf2, type Mf2Function } from './mf2.ts';
74
+ export { intlDate, intlNumber, parseMf2, toMf2, type Isolate, type Mf2Function } from './mf2.ts';
73
75
  export { toMf1 } from './mf1.ts';
74
76
  export { applyMigrate, planMigrate, type MigratePlan } from './migrate.ts';
75
77
  export { readMessage, SYNTAX_NAME, writeMessage, type MessageFormatVersion } from './syntax.ts';
76
78
  export { syntaxKind, typescriptOracle, type Span, type TypeOracle, type ValueKind } from './types.ts';
79
+ export { todoOf, type Todo } from './todo.ts';
77
80
  export { SarifReport, type SarifResult, type SarifTool } from './sarif.ts';
78
- export { hardcodedText, isReferenced, keyRefs, looksLikeCode, scan, sourceFiles, type Hardcoded, type KeyRefs } from './scan.ts';
81
+ export {
82
+ hardcodedText,
83
+ isReferenced,
84
+ keyRefs,
85
+ looksLikeCode,
86
+ READERS,
87
+ scan,
88
+ sourceFiles,
89
+ unseenReads,
90
+ type Hardcoded,
91
+ type KeyRefs,
92
+ type Reads,
93
+ type UnseenRead,
94
+ } from './scan.ts';
package/src/tools/lint.ts CHANGED
@@ -37,11 +37,12 @@ interface RuleBase {
37
37
  /** Its severity under `extends: 'strict'`; `error` when unset. */
38
38
  strict?: Severity;
39
39
  /**
40
- * Only entries of these kinds, or on these surfaces; an entry that says neither is skipped. Only
41
- * the source unless `locale` says otherwise, since most writing rules are written for one language:
42
- * `all` reads every locale, and a list those languages and their regions (`de` covers `de-CH`).
40
+ * Only entries of these kinds, on these surfaces, or in these areas (a key's first segment); an
41
+ * entry that says no kind or surface is skipped by a rule that names some. Only the source unless
42
+ * `locale` says otherwise, since most writing rules are written for one language: `all` reads
43
+ * every locale, and a list those languages and their regions (`de` covers `de-CH`).
43
44
  */
44
- on?: { kind?: string[]; surface?: string[]; locale?: 'all' | string[] };
45
+ on?: { kind?: string[]; surface?: string[]; area?: string[]; locale?: 'all' | string[] };
45
46
  }
46
47
 
47
48
  /** A rule written as code. */
@@ -273,10 +274,14 @@ const NOT_PLURAL = new Set([
273
274
  'series',
274
275
  ]);
275
276
 
276
- /** The singular of an English plural noun, or nothing where it doesn't look like one. */
277
+ /**
278
+ * The singular of an English plural noun, or nothing where it doesn't look like one. A word with no
279
+ * vowel is a unit or an abbreviation (`kbps`, `fps`, `hrs`), which reads the same after 1.
280
+ */
277
281
  export function singularOf(word: string): string | undefined {
278
282
  const lower = word.toLowerCase();
279
283
  if (word.length < 3 || !lower.endsWith('s') || lower.endsWith('ss') || NOT_PLURAL.has(lower)) return undefined;
284
+ if (!/[aeiouy]/.test(lower)) return undefined;
280
285
  if (/[^aeiou]ies$/.test(lower)) return `${word.slice(0, -3)}y`;
281
286
  if (/(?:s|x|z|ch|sh)es$/.test(lower)) return word.slice(0, -2);
282
287
  return word.slice(0, -1);
@@ -323,12 +328,14 @@ function eachRun(
323
328
  const COUNT_NAME = /^(?:n|num|count|total|amount|quantity)$|(?:count|total)$/i;
324
329
 
325
330
  /**
326
- * Whether an arm ends with a stop; nothing for an arm with no words. Which stop is the arm's own: a
327
- * question in `one` and a second sentence after it in `other` both end well.
331
+ * Whether an arm ends with a stop; nothing for an arm with no words, or one that trails off in an
332
+ * ellipsis (`Saving…`, `Save as…`), which is neither. Which stop is the arm's own: a question in
333
+ * `one` and a second sentence after it in `other` both end well.
328
334
  */
329
335
  function endsWithStop(arm: MessageFormatElement[]): boolean | undefined {
330
336
  const text = prose(renderAs(arm, 'other')).trim();
331
- return text.replaceAll(HOLE, '').trim() === '' ? undefined : hasStop(text);
337
+ if (text.replaceAll(HOLE, '').trim() === '' || /(?:…|\.{3})["')”»」』]*$/u.test(text)) return undefined;
338
+ return hasStop(text);
332
339
  }
333
340
 
334
341
  /** Ends with a stop in any script (`。`, `؟`, `;` as Greek writes `?`), though not a comma. */
@@ -336,6 +343,17 @@ const hasStop = (text: string) => /(?![,,、،])[\p{Terminal_Punctuation}…][
336
343
 
337
344
  const CURRENCY_BEFORE = /[$€£¥₹]\s?$/;
338
345
 
346
+ /**
347
+ * The ways a path through menus is written, the separator in a group: an arrow or `›` however
348
+ * spaced, and `>` or `/` only spaced between words or placeholders, with a capital after a slash,
349
+ * since "2 > 1" and "and / or" are no paths. The match takes the spaces around, so the fix spaces
350
+ * the separator.
351
+ */
352
+ const PATH_SEPARATORS = new RegExp(
353
+ String.raw`\s*(→|->|›)\s*|(?<=[\p{L}${HOLE}])\s+(>)\s+(?=[\p{L}${HOLE}])|(?<=\p{L})\s+(\/)\s+(?=\p{Lu})`,
354
+ 'gu',
355
+ );
356
+
339
357
  const OFF_SCREEN = new Set(['aria']);
340
358
 
341
359
  const an = (word = 'string') => `${/^[aeiou]/i.test(word) ? 'an' : 'a'} ${word}`;
@@ -447,6 +465,19 @@ export const CORE_RULES = {
447
465
  return match === undefined ? [] : [{ message: 'no em-dash: a comma, a period or a colon does the job', match }];
448
466
  },
449
467
  },
468
+ path: {
469
+ description: 'A path through menus or settings is written with `lint.pathSeparator`, spaced: Settings › General.',
470
+ check: ({ text, config }) => {
471
+ const use = config.lint.pathSeparator;
472
+ for (const v of text.variants) {
473
+ for (const m of v.matchAll(PATH_SEPARATORS)) {
474
+ if ((m[1] ?? m[2] ?? m[3]) === use) continue;
475
+ return [{ message: `a path is written with a spaced ${use}`, match: m[0], fix: ` ${use} ` }];
476
+ }
477
+ }
478
+ return [];
479
+ },
480
+ },
450
481
  contractions: {
451
482
  description: '`could not`, `is not` and `cannot` are written as contractions.',
452
483
  check: ({ text }) =>
@@ -704,14 +735,19 @@ const readsLocale = (locales: 'all' | string[] | undefined, locale: string, sour
704
735
  locales.some((l) => locale.toLowerCase() === l.toLowerCase() || locale.toLowerCase().startsWith(`${l.toLowerCase()}-`));
705
736
 
706
737
  function applies(rule: RuleBase, message: Message, locale: string, sourceLocale: string): boolean {
707
- const { kind, surface, locale: locales } = rule.on ?? {};
738
+ const { kind, surface, area, locale: locales } = rule.on ?? {};
708
739
  return (
709
740
  readsLocale(locales, locale, sourceLocale) &&
710
741
  (kind === undefined || (message.kind !== undefined && kind.includes(message.kind))) &&
711
- (surface === undefined || (message.surface !== undefined && surface.includes(message.surface)))
742
+ (surface === undefined || (message.surface !== undefined && surface.includes(message.surface))) &&
743
+ (area === undefined || area.includes(message.area))
712
744
  );
713
745
  }
714
746
 
747
+ /** A rule's severity for an entry: its area's, where `lint.areas` sets one. */
748
+ export const severityOf = (config: ResolvedConfig, name: string, message: Message): Severity | undefined =>
749
+ config.lint.areas[message.area]?.[name] ?? config.lint.rules[name]?.severity;
750
+
715
751
  interface Literal {
716
752
  start: number;
717
753
  value: string;
@@ -784,7 +820,8 @@ export function lint(messages: Message[], config: ResolvedConfig): Finding[] {
784
820
  const text = locale === config.source ? source : readings(ast);
785
821
  const context: RuleContext = { message, locale, ast, kind, text, source, config };
786
822
  let literals: Literal[] | undefined;
787
- for (const [name, { rule, severity }] of rules) {
823
+ for (const [name, { rule }] of rules) {
824
+ const severity = severityOf(config, name, message)!;
788
825
  if (severity === 'off' || !applies(rule, message, locale, config.source)) continue;
789
826
  let said: (string | Report)[];
790
827
  try {
@@ -814,7 +851,7 @@ export function lint(messages: Message[], config: ResolvedConfig): Finding[] {
814
851
  const rule = config.lint.rules[name];
815
852
  if (rule === undefined) {
816
853
  findings.push({ ...at, message: `allow.${name} is not a rule: ${rules.map(([n]) => n).join(', ')}` });
817
- } else if (rule.severity === 'off') {
854
+ } else if (severityOf(config, name, message) === 'off') {
818
855
  continue;
819
856
  } else if (!fired.has(name)) {
820
857
  findings.push({ ...at, message: `allow.${name} exempts the entry from a rule it passes: remove it` });
package/src/tools/lock.ts CHANGED
@@ -14,6 +14,7 @@ import { createHash } from 'node:crypto';
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
 
16
16
  import type { Catalog } from './catalog.ts';
17
+ import { formatDiff, wordDiff } from './diff.ts';
17
18
 
18
19
  export interface Lock {
19
20
  version: 2;
@@ -59,6 +60,12 @@ export interface StaleTranslation {
59
60
  translation: string;
60
61
  }
61
62
 
63
+ /** What changed in the source of a stale translation, as a word diff, or why that isn't known. */
64
+ export function changeOf(stale: StaleTranslation): string {
65
+ if (stale.was === undefined) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
66
+ return formatDiff(wordDiff(stale.was, stale.now)).replaceAll('\n', ' ');
67
+ }
68
+
62
69
  export interface Reconciled {
63
70
  lock: Lock;
64
71
  /** Per locale, per key: the translations made from a source that has changed since. */