prosebook 0.2.0 → 0.4.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 +345 -29
- package/dist/bun.d.ts +34 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +98 -0
- package/dist/bun.js.map +1 -0
- package/dist/bundle-CpUIWb9j.d.ts +6 -0
- package/dist/bundle-CpUIWb9j.d.ts.map +1 -0
- package/dist/bundle-DboItAre.js +111 -0
- package/dist/bundle-DboItAre.js.map +1 -0
- package/dist/cli.js +51 -56
- package/dist/cli.js.map +1 -1
- package/dist/compile-ClK9htKr.d.ts +16 -0
- package/dist/compile-ClK9htKr.d.ts.map +1 -0
- package/dist/{config-_4HfnJQu.d.ts → config-CzaJJQ60.d.ts} +75 -2
- package/dist/config-CzaJJQ60.d.ts.map +1 -0
- package/dist/{config-D--RITk2.js → config-nWZf9QmF.js} +42 -212
- package/dist/config-nWZf9QmF.js.map +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
- package/dist/copy-B3V_QRkT.d.ts.map +1 -0
- package/dist/copy-Bo_FIoLy.js +154 -0
- package/dist/copy-Bo_FIoLy.js.map +1 -0
- package/dist/endpoints-BgG_5Vpm.js +96 -0
- package/dist/endpoints-BgG_5Vpm.js.map +1 -0
- package/dist/generate-BW3N8EGb.js +5488 -0
- package/dist/generate-BW3N8EGb.js.map +1 -0
- package/dist/helpers-BWGcD0kS.js +96 -0
- package/dist/helpers-BWGcD0kS.js.map +1 -0
- package/dist/icu-Dej0mNsF.js +218 -0
- package/dist/icu-Dej0mNsF.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -188
- package/dist/inspector.d.ts +4 -1
- package/dist/inspector.d.ts.map +1 -1
- package/dist/inspector.js +7 -2
- package/dist/inspector.js.map +1 -1
- package/dist/locale-DpQA1pqH.js +70 -0
- package/dist/locale-DpQA1pqH.js.map +1 -0
- package/dist/next-loader.d.ts +11 -0
- package/dist/next-loader.d.ts.map +1 -0
- package/dist/next-loader.js +17 -0
- package/dist/next-loader.js.map +1 -0
- package/dist/next.d.ts +30 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +160 -0
- package/dist/next.js.map +1 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +20 -0
- package/dist/react/server.d.ts.map +1 -0
- package/dist/react/server.js +34 -0
- package/dist/react/server.js.map +1 -0
- package/dist/{sarif-CI8HOokm.js → sarif-B5nrMqrT.js} +103 -87
- package/dist/sarif-B5nrMqrT.js.map +1 -0
- package/dist/scan-Bx0_yDSi.js +387 -0
- package/dist/scan-Bx0_yDSi.js.map +1 -0
- package/dist/tools.d.ts +83 -90
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/vite.d.ts +3 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +29 -109
- package/dist/vite.js.map +1 -1
- package/package.json +37 -4
- package/src/bun.ts +132 -0
- package/src/cli.ts +60 -58
- package/src/index.ts +1 -1
- package/src/inspector.ts +8 -3
- package/src/next-loader.ts +16 -0
- package/src/next.ts +188 -0
- package/src/react/index.tsx +6 -6
- package/src/react/server.ts +37 -0
- package/src/runtime/copy.ts +22 -3
- package/src/runtime/helpers.ts +54 -18
- package/src/runtime/locale.ts +19 -0
- package/src/runtime/types.ts +21 -6
- package/src/tools/bundle.ts +131 -0
- package/src/tools/catalog.ts +2 -2
- package/src/tools/compile.ts +60 -23
- package/src/tools/config.ts +57 -0
- package/src/tools/endpoints.ts +121 -0
- package/src/tools/entry.ts +77 -0
- package/src/tools/generate.ts +2 -0
- package/src/tools/icu.ts +63 -7
- package/src/tools/index.ts +6 -3
- package/src/tools/lock.ts +7 -0
- package/src/tools/mf1.ts +21 -7
- package/src/tools/mf2.ts +249 -57
- package/src/tools/migrate.ts +3 -2
- package/src/tools/native/android.ts +201 -0
- package/src/tools/native/apple.ts +139 -0
- package/src/tools/native/emit.ts +87 -0
- package/src/tools/native/index.ts +48 -0
- package/src/tools/native/kotlin.ts +269 -0
- package/src/tools/native/model.ts +292 -0
- package/src/tools/native/number.ts +210 -0
- package/src/tools/native/plurals.ts +103 -0
- package/src/tools/native/printf.ts +95 -0
- package/src/tools/native/rust.ts +367 -0
- package/src/tools/native/swift.ts +326 -0
- package/src/tools/syntax.ts +3 -3
- package/src/tools/todo.ts +118 -0
- package/src/vite.ts +28 -114
- package/dist/config-D--RITk2.js.map +0 -1
- package/dist/config-_4HfnJQu.d.ts.map +0 -1
- package/dist/copy-DXnzXsJa.d.ts.map +0 -1
- package/dist/helpers-BPuPGjQP.js +0 -61
- package/dist/helpers-BPuPGjQP.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sarif-CI8HOokm.js.map +0 -1
- package/dist/scan-Ch2BfIUD.js +0 -2561
- package/dist/scan-Ch2BfIUD.js.map +0 -1
package/src/tools/config.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
8
|
|
|
9
9
|
import { compileRule, CORE_RULES, type CheckRule, type CoreRule, type Plugin } from './lint.ts';
|
|
10
|
+
import type { Isolate } from './mf2.ts';
|
|
10
11
|
import { DEFAULT_KINDS, DEFAULT_SURFACES, STRICT_LISTS, type KindRule, type Severity } from './presets.ts';
|
|
11
12
|
|
|
12
13
|
export type { KindRule, Severity };
|
|
@@ -54,6 +55,39 @@ export interface LintConfig extends WordLists {
|
|
|
54
55
|
areas?: Record<string, RuleSeverities>;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
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; for Android and
|
|
61
|
+
* Apple, string resources, a file per locale.
|
|
62
|
+
*/
|
|
63
|
+
export interface NativeTarget {
|
|
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
|
+
*/
|
|
69
|
+
out: string;
|
|
70
|
+
/**
|
|
71
|
+
* Key prefixes the file holds: `['native']` takes every `native.*` key, which it names without the
|
|
72
|
+
* prefix (`native.tray.quit` is `tray_quit`). Every key, named in full, by default.
|
|
73
|
+
*/
|
|
74
|
+
keys?: string[];
|
|
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
|
+
*/
|
|
80
|
+
name?: string;
|
|
81
|
+
/** Kotlin's package. */
|
|
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;
|
|
89
|
+
}
|
|
90
|
+
|
|
57
91
|
export interface ProsebookConfig {
|
|
58
92
|
/** The source-language catalog: one `<area>.yaml` per area. */
|
|
59
93
|
catalog?: string;
|
|
@@ -70,6 +104,13 @@ export interface ProsebookConfig {
|
|
|
70
104
|
* translations alike; `prosebook migrate` rewrites a catalog from one to the other.
|
|
71
105
|
*/
|
|
72
106
|
messageFormat?: 1 | 2;
|
|
107
|
+
/**
|
|
108
|
+
* In MF2, when a placeholder is wrapped in Unicode isolation marks, so a value written the other
|
|
109
|
+
* way (a Latin name in Hebrew) can't reorder the text around it. `'rtl'`, the default: in locales
|
|
110
|
+
* written right to left, and wherever `u:dir` asks. `'always'`: also every string in any locale,
|
|
111
|
+
* as the spec's default has it. `false`: never.
|
|
112
|
+
*/
|
|
113
|
+
isolate?: Isolate;
|
|
73
114
|
/** Locales that ship in the main bundle beside the source, rather than loading when first used. */
|
|
74
115
|
bundled?: string[];
|
|
75
116
|
/** A pseudo-locale generated from the source (accented and lengthened) to find layout bugs and hardcoded text. */
|
|
@@ -87,6 +128,8 @@ export interface ProsebookConfig {
|
|
|
87
128
|
/** Where a string may sit, with what each surface means. */
|
|
88
129
|
surfaces?: Record<string, string>;
|
|
89
130
|
lint?: LintConfig;
|
|
131
|
+
/** Rust, Swift or Kotlin files `generate` writes beside the TypeScript. */
|
|
132
|
+
native?: NativeTarget[];
|
|
90
133
|
/** Where `prosebook scan` looks for hardcoded text and catalog keys. */
|
|
91
134
|
scan?: {
|
|
92
135
|
dirs?: string[];
|
|
@@ -104,6 +147,7 @@ export interface ResolvedConfig {
|
|
|
104
147
|
lock: string;
|
|
105
148
|
source: string;
|
|
106
149
|
messageFormat: 1 | 2;
|
|
150
|
+
isolate: Isolate;
|
|
107
151
|
bundled: string[];
|
|
108
152
|
pseudo: string | false;
|
|
109
153
|
stale: 'use' | 'fallback';
|
|
@@ -116,9 +160,12 @@ export interface ResolvedConfig {
|
|
|
116
160
|
rules: Record<string, { rule: CheckRule; severity: Severity }>;
|
|
117
161
|
areas: Record<string, Record<string, Severity>>;
|
|
118
162
|
} & Required<WordLists>;
|
|
163
|
+
native: (NativeTarget & { name: string })[];
|
|
119
164
|
scan: { dirs: string[]; extensions: string[]; attributes: string[] };
|
|
120
165
|
}
|
|
121
166
|
|
|
167
|
+
const NATIVE_NAMES = { rust: 'Locale', swift: 'Copy', kotlin: 'Copy', android: 'strings', apple: 'Localizable' };
|
|
168
|
+
|
|
122
169
|
/** Typed config, for `prosebook.config.ts`. */
|
|
123
170
|
export function defineConfig(config: ProsebookConfig): ProsebookConfig {
|
|
124
171
|
return config;
|
|
@@ -163,6 +210,7 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
|
|
|
163
210
|
lock: at(config.lock ?? 'copy/lock.json'),
|
|
164
211
|
source: config.source ?? 'en',
|
|
165
212
|
messageFormat: config.messageFormat ?? 1,
|
|
213
|
+
isolate: config.isolate ?? 'rtl',
|
|
166
214
|
bundled: config.bundled ?? [],
|
|
167
215
|
pseudo: config.pseudo ?? false,
|
|
168
216
|
stale: config.stale ?? 'use',
|
|
@@ -180,6 +228,15 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
|
|
|
180
228
|
properNouns: lint.properNouns ?? [],
|
|
181
229
|
pathSeparator: lint.pathSeparator ?? '›',
|
|
182
230
|
},
|
|
231
|
+
native: (config.native ?? []).map((target, i) => {
|
|
232
|
+
if (!(target.language in NATIVE_NAMES))
|
|
233
|
+
throw new Error(`native[${i}].language is "${target.language}": name one of ${Object.keys(NATIVE_NAMES).join(', ')}`);
|
|
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 };
|
|
239
|
+
}),
|
|
183
240
|
scan: {
|
|
184
241
|
dirs: (config.scan?.dirs ?? ['src']).map(at),
|
|
185
242
|
extensions: config.scan?.extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'],
|
|
@@ -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
|
+
}
|
package/src/tools/entry.ts
CHANGED
|
@@ -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);
|
package/src/tools/generate.ts
CHANGED
|
@@ -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
|
|
|
@@ -229,6 +230,7 @@ export function render(config: ResolvedConfig, accept?: Map<string, Set<string>
|
|
|
229
230
|
);
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
for (const [path, content] of nativeFiles(config, catalog, locales, entries, head)) files.set(path, content);
|
|
232
234
|
files.set(join(config.catalog, 'catalog.schema.json'), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
|
|
233
235
|
if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
|
|
234
236
|
return { catalog, files, lock, stale, entries };
|
package/src/tools/icu.ts
CHANGED
|
@@ -65,6 +65,50 @@ function argType(el: MessageFormatElement): string | undefined {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
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
|
+
|
|
68
112
|
/** Calls `visit` on every element, inside plural and select arms and tags too. */
|
|
69
113
|
export function walk(elements: MessageFormatElement[], visit: (el: MessageFormatElement) => void): void {
|
|
70
114
|
for (const el of elements) {
|
|
@@ -76,12 +120,15 @@ export function walk(elements: MessageFormatElement[], visit: (el: MessageFormat
|
|
|
76
120
|
|
|
77
121
|
export function argsOf(elements: MessageFormatElement[], clash: (name: string) => void = () => {}): Map<string, string> {
|
|
78
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
|
+
};
|
|
79
128
|
walk(elements, (el) => {
|
|
129
|
+
for (const { arg, type } of Object.values(optionArgsOf(el) ?? {})) add(arg, type);
|
|
80
130
|
const type = argType(el);
|
|
81
|
-
if (type
|
|
82
|
-
const seen = args.get(el.value);
|
|
83
|
-
if (seen !== undefined && seen !== type) clash(el.value);
|
|
84
|
-
else args.set(el.value, type);
|
|
131
|
+
if (type !== undefined && 'value' in el && typeof el.value === 'string') add(el.value, type);
|
|
85
132
|
});
|
|
86
133
|
return args;
|
|
87
134
|
}
|
|
@@ -121,8 +168,12 @@ export function numberOptions(el: NumberElement): Intl.NumberFormatOptions | und
|
|
|
121
168
|
const unknown = style.tokens.filter((t) => !stemIsKnown(t.stem, t.options)).map((t) => t.stem);
|
|
122
169
|
if (unknown.length > 0) throw new Error(`{${el.value}} has skeleton stems the formatter drops without a word: ${unknown.join(', ')}`);
|
|
123
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' }) };
|
|
124
175
|
try {
|
|
125
|
-
new Intl.NumberFormat('en', options).resolvedOptions();
|
|
176
|
+
new Intl.NumberFormat('en', { ...options, ...given }).resolvedOptions();
|
|
126
177
|
} catch (e) {
|
|
127
178
|
throw new Error(`{${el.value}} has a skeleton Intl refuses: ${(e as Error).message}`, { cause: e });
|
|
128
179
|
}
|
|
@@ -196,8 +247,13 @@ export function formatsOf(ast: MessageFormatElement[]): Map<string, string> {
|
|
|
196
247
|
said = `${kind} \`${style}\``;
|
|
197
248
|
} else if ('mf2' in style) {
|
|
198
249
|
// Read from MF2: the function as written.
|
|
199
|
-
const {
|
|
200
|
-
|
|
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(' ')}\``;
|
|
201
257
|
} else if ('tokens' in style) {
|
|
202
258
|
said = `${kind} \`::${style.tokens.map((t) => [t.stem, ...t.options].join('/')).join(' ')}\``;
|
|
203
259
|
} else {
|
package/src/tools/index.ts
CHANGED
|
@@ -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,14 +68,15 @@ 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
81
|
export {
|
|
79
82
|
hardcodedText,
|
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. */
|
package/src/tools/mf1.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import { isDateElement, isNumberElement, isTimeElement, parse, TYPE } from '@formatjs/icu-messageformat-parser';
|
|
8
8
|
import type { DateElement, DateTimeSkeleton, NumberElement, NumberSkeleton, TimeElement } from '@formatjs/icu-messageformat-parser';
|
|
9
9
|
|
|
10
|
-
import type
|
|
11
|
-
import type { Mf2DateSkeleton, Mf2NumberSkeleton } from './mf2.ts';
|
|
10
|
+
import { selectionOf, type MessageFormatElement } from './icu.ts';
|
|
11
|
+
import type { Mf2DateSkeleton, Mf2Function, Mf2NumberSkeleton } from './mf2.ts';
|
|
12
12
|
|
|
13
13
|
type Element = MessageFormatElement;
|
|
14
14
|
|
|
@@ -89,6 +89,10 @@ function native(elements: Element[], plural: { value: string; offset: number } |
|
|
|
89
89
|
}
|
|
90
90
|
if (isDateElement(el) || isTimeElement(el)) return dateElement(el);
|
|
91
91
|
if (el.type === TYPE.plural) {
|
|
92
|
+
// Digit options and `:integer`'s rounding change only which arm some values reach, which
|
|
93
|
+
// `migrate` reports when it renders both; an argument's option MF1 can't say at all.
|
|
94
|
+
const args = Object.keys(selectionOf(el)?.args ?? {});
|
|
95
|
+
if (args.length > 0) throw new Error(`{${el.value}} is selected with ${args.join(', ')} from an argument, which MF1 can't say`);
|
|
92
96
|
const inner = { value: el.value, offset: el.offset };
|
|
93
97
|
return { ...el, options: Object.fromEntries(Object.entries(el.options).map(([k, o]) => [k, { value: native(o.value, inner) }])) };
|
|
94
98
|
}
|
|
@@ -102,9 +106,9 @@ function native(elements: Element[], plural: { value: string; offset: number } |
|
|
|
102
106
|
|
|
103
107
|
function plainNumber(el: NumberElement): boolean {
|
|
104
108
|
const style = el.style;
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
);
|
|
109
|
+
if (style === null || style === undefined) return true;
|
|
110
|
+
const mf2 = 'mf2' in Object(style) ? (style as Mf2NumberSkeleton) : undefined;
|
|
111
|
+
return mf2 !== undefined && mf2.args === undefined && Object.keys(mf2.parsedOptions).length === 0;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
function same(a: object, b: object): boolean {
|
|
@@ -120,6 +124,7 @@ function same(a: object, b: object): boolean {
|
|
|
120
124
|
function numberStyle(el: NumberElement): NumberElement['style'] {
|
|
121
125
|
const style = el.style;
|
|
122
126
|
if (style === null || style === undefined || typeof style === 'string' || !('mf2' in style)) return style;
|
|
127
|
+
refuseArgs(el.value, style as Mf2NumberSkeleton);
|
|
123
128
|
const options = (style as Mf2NumberSkeleton).parsedOptions as Record<string, unknown>;
|
|
124
129
|
if (Object.keys(options).length === 0) return null;
|
|
125
130
|
if (same(options, { maximumFractionDigits: 0 })) return 'integer';
|
|
@@ -210,6 +215,7 @@ const NAMED: Record<string, readonly [TYPE.date | TYPE.time, string]> = {
|
|
|
210
215
|
function dateElement(el: DateElement | TimeElement): Element {
|
|
211
216
|
const style = el.style;
|
|
212
217
|
if (typeof style !== 'object' || style === null || !('mf2' in style)) return el;
|
|
218
|
+
refuseArgs(el.value, style as Mf2DateSkeleton);
|
|
213
219
|
const fn = (style as Mf2DateSkeleton).mf2;
|
|
214
220
|
const named = NAMED[mf2Text(fn, true).slice(1)];
|
|
215
221
|
if (named !== undefined) return { type: named[0], value: el.value, style: named[1] };
|
|
@@ -239,8 +245,16 @@ function dateSkeleton(o: Intl.DateTimeFormatOptions): string | undefined {
|
|
|
239
245
|
return out === '' ? undefined : out;
|
|
240
246
|
}
|
|
241
247
|
|
|
242
|
-
|
|
243
|
-
|
|
248
|
+
/** A skeleton names every option, so one an argument gives has no MF1 spelling. */
|
|
249
|
+
function refuseArgs(name: string, style: Mf2NumberSkeleton | Mf2DateSkeleton): void {
|
|
250
|
+
if (style.args === undefined) return;
|
|
251
|
+
throw new Error(
|
|
252
|
+
`{${name} ${mf2Text(style.mf2)}} takes an option from an argument, which MF1 can't: write the option's value in the message`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function mf2Text(fn: Mf2Function, sortOptions = false): string {
|
|
257
|
+
const options = [...Object.entries(fn.options), ...Object.entries(fn.args ?? {}).map(([k, v]) => [k, `$${v}`] as const)];
|
|
244
258
|
if (sortOptions) options.sort(([a], [b]) => a.localeCompare(b));
|
|
245
259
|
return [`:${fn.name}`, ...options.map(([k, v]) => `${k}=${v}`)].join(' ');
|
|
246
260
|
}
|