prosebook 0.0.1 → 0.1.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +595 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +508 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config-CF7HziDa.d.ts +498 -0
  7. package/dist/config-CF7HziDa.d.ts.map +1 -0
  8. package/dist/config-CxVMn19f.js +1146 -0
  9. package/dist/config-CxVMn19f.js.map +1 -0
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +1 -0
  12. package/dist/copy-DXnzXsJa.d.ts +162 -0
  13. package/dist/copy-DXnzXsJa.d.ts.map +1 -0
  14. package/dist/helpers-BPuPGjQP.js +61 -0
  15. package/dist/helpers-BPuPGjQP.js.map +1 -0
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +188 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inspector.d.ts +37 -0
  21. package/dist/inspector.d.ts.map +1 -0
  22. package/dist/inspector.js +572 -0
  23. package/dist/inspector.js.map +1 -0
  24. package/dist/react/index.d.ts +37 -0
  25. package/dist/react/index.d.ts.map +1 -0
  26. package/dist/react/index.js +86 -0
  27. package/dist/react/index.js.map +1 -0
  28. package/dist/sarif-PkPvsB07.js +1033 -0
  29. package/dist/sarif-PkPvsB07.js.map +1 -0
  30. package/dist/scan-B0tZ7y-D.js +2447 -0
  31. package/dist/scan-B0tZ7y-D.js.map +1 -0
  32. package/dist/tools.d.ts +351 -0
  33. package/dist/tools.d.ts.map +1 -0
  34. package/dist/tools.js +4 -0
  35. package/dist/vite.d.ts +15 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +238 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +148 -15
  40. package/src/cli.ts +528 -0
  41. package/src/index.ts +31 -0
  42. package/src/inspector-dialog.ts +450 -0
  43. package/src/inspector.ts +228 -0
  44. package/src/react/index.tsx +112 -0
  45. package/src/runtime/copy.ts +221 -0
  46. package/src/runtime/helpers.ts +66 -0
  47. package/src/runtime/locale.ts +49 -0
  48. package/src/runtime/types.ts +106 -0
  49. package/src/tools/catalog.ts +449 -0
  50. package/src/tools/compile.ts +110 -0
  51. package/src/tools/config.ts +195 -0
  52. package/src/tools/diff.ts +84 -0
  53. package/src/tools/entry.ts +325 -0
  54. package/src/tools/extract.ts +694 -0
  55. package/src/tools/generate.ts +259 -0
  56. package/src/tools/icu.ts +182 -0
  57. package/src/tools/index.ts +78 -0
  58. package/src/tools/lint.ts +828 -0
  59. package/src/tools/lock.ts +95 -0
  60. package/src/tools/mf1.ts +246 -0
  61. package/src/tools/mf2.ts +752 -0
  62. package/src/tools/migrate.ts +138 -0
  63. package/src/tools/presets.ts +159 -0
  64. package/src/tools/pseudo.ts +40 -0
  65. package/src/tools/sarif.ts +80 -0
  66. package/src/tools/scan.ts +173 -0
  67. package/src/tools/syntax.ts +24 -0
  68. package/src/tools/types.ts +137 -0
  69. package/src/vite.ts +252 -0
  70. package/index.js +0 -1
@@ -0,0 +1,137 @@
1
+ /**
2
+ * What kind of value an expression in JSX holds, so `extract` can write `{n, number}` for a number and
3
+ * `{d, date, medium}` for a date instead of a bare `{x}`, which is text and prints a number unformatted.
4
+ *
5
+ * The project's own TypeScript (7 or later, through its `unstable/async` API) answers where it can; the
6
+ * syntax of the expression answers where it can't: a JavaScript project, a file outside any tsconfig,
7
+ * or no TypeScript installed. Not the `unstable/sync` API: on Linux and macOS it reads the compiler's
8
+ * pipes through a Node internal (`stdout._handle.fd`) that Bun doesn't have, so under Bun it throws.
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { dirname, join, resolve } from 'node:path';
12
+
13
+ import type * as TsAsync from 'typescript/unstable/async';
14
+ import type { Checker, Type } from 'typescript/unstable/async';
15
+
16
+ // oxlint-disable-next-line typescript/no-explicit-any -- a loose ESTree node: the walk reads whichever fields each node type has
17
+ type Node = { type: string; start: number; end: number; [field: string]: any };
18
+
19
+ type TsNode = { pos: number; end: number; forEachChild(visit: (child: TsNode) => unknown): unknown };
20
+
21
+ /** `node` is markup or an object, which a message can't hold as text. */
22
+ export type ValueKind = 'string' | 'number' | 'date' | 'node' | 'unknown';
23
+
24
+ export interface Span {
25
+ start: number;
26
+ end: number;
27
+ }
28
+
29
+ export interface TypeOracle {
30
+ /** Where the answers come from, for the report. */
31
+ readonly source: string;
32
+ /** The kind of each span's expression in `file`, or nothing where the oracle can't say. */
33
+ kinds(file: string, spans: readonly Span[]): Promise<(ValueKind | undefined)[]>;
34
+ close(): Promise<void>;
35
+ }
36
+
37
+ /** The nearest `tsconfig.json` at or above the file's folder. */
38
+ function tsconfigFor(file: string): string | undefined {
39
+ for (let dir = dirname(resolve(file)); ; dir = dirname(dir)) {
40
+ const candidate = join(dir, 'tsconfig.json');
41
+ if (existsSync(candidate)) return candidate;
42
+ if (dirname(dir) === dir) return undefined;
43
+ }
44
+ }
45
+
46
+ // TypeFlags, as the TypeScript 7 API numbers them.
47
+ const ANY_OR_UNKNOWN = 3;
48
+ const NUMBER_LIKE = 67648 | 4224;
49
+ const STRING_LIKE = 12583968;
50
+ const UNION = 134217728;
51
+ const OBJECT = 1048576;
52
+
53
+ /**
54
+ * An oracle over the projects the files belong to, or nothing where TypeScript 7 isn't installed or
55
+ * none of the files has a tsconfig. One compiler process serves every project and is closed by `close`.
56
+ */
57
+ export async function typescriptOracle(root: string, files: readonly string[]): Promise<TypeOracle | undefined> {
58
+ const configs = [...new Set(files.map(tsconfigFor).filter((c) => c !== undefined))];
59
+ if (configs.length === 0) return undefined;
60
+ let ts: typeof TsAsync;
61
+ try {
62
+ ts = await import('typescript/unstable/async');
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ let api: InstanceType<typeof ts.API>;
67
+ let snapshot: Awaited<ReturnType<InstanceType<typeof ts.API>['updateSnapshot']>>;
68
+ try {
69
+ api = new ts.API({ cwd: root });
70
+ snapshot = await api.updateSnapshot({ openProjects: configs });
71
+ } catch {
72
+ return undefined;
73
+ }
74
+
75
+ const classify = async (checker: Checker, type: Type | undefined): Promise<ValueKind | undefined> => {
76
+ if (type === undefined) return undefined;
77
+ const t = (await checker.getNonNullableType(type)) ?? type;
78
+ if ((t.flags & ANY_OR_UNKNOWN) !== 0) return 'unknown';
79
+ if ((t.flags & UNION) !== 0) {
80
+ const members = (await (t as Type & { getTypes(): Promise<readonly Type[] | undefined> }).getTypes()) ?? [];
81
+ const kinds = new Set(await Promise.all(members.map((m) => classify(checker, m))));
82
+ if (kinds.size === 1) return [...kinds][0];
83
+ return kinds.has('node') ? 'node' : 'unknown';
84
+ }
85
+ if ((t.flags & NUMBER_LIKE) !== 0) return 'number';
86
+ if ((t.flags & STRING_LIKE) !== 0) return 'string';
87
+ if ((await checker.typeToString(t)) === 'Date') return 'date';
88
+ if ((t.flags & OBJECT) !== 0) return 'node';
89
+ return 'unknown';
90
+ };
91
+
92
+ return {
93
+ source: 'TypeScript',
94
+ async kinds(file, spans) {
95
+ const project = await snapshot.getDefaultProjectForFile(resolve(file));
96
+ const sourceFile = await project?.program.getSourceFile(resolve(file));
97
+ if (project === undefined || sourceFile === undefined) return spans.map(() => undefined);
98
+ // The smallest node covering each span is the expression itself.
99
+ const nodes = spans.map(({ start, end }) => {
100
+ let node: TsNode = sourceFile;
101
+ for (;;) {
102
+ let next: TsNode | undefined;
103
+ node.forEachChild((child) => {
104
+ if (next === undefined && child.pos <= start && child.end >= end) next = child;
105
+ });
106
+ if (next === undefined) return node;
107
+ node = next;
108
+ }
109
+ });
110
+ const types = await project.checker.getTypeAtLocation(nodes as never[]);
111
+ return Promise.all(types.map((type) => classify(project.checker, type)));
112
+ },
113
+ close() {
114
+ return api.close();
115
+ },
116
+ };
117
+ }
118
+
119
+ /** The kind an expression's syntax gives away, where it does: `items.length`, `a * b`, `new Date()`. */
120
+ export function syntaxKind(expr: Node): ValueKind | undefined {
121
+ switch (expr.type) {
122
+ case 'Literal':
123
+ return typeof expr.value === 'number' ? 'number' : typeof expr.value === 'string' ? 'string' : undefined;
124
+ case 'TemplateLiteral':
125
+ return 'string';
126
+ case 'MemberExpression':
127
+ return !expr.computed && ['length', 'size', 'count'].includes(expr.property?.name as string) ? 'number' : undefined;
128
+ case 'BinaryExpression':
129
+ return ['-', '*', '/', '%', '**'].includes(expr.operator as string) ? 'number' : undefined;
130
+ case 'NewExpression':
131
+ return expr.callee?.type === 'Identifier' && expr.callee.name === 'Date' ? 'date' : undefined;
132
+ case 'CallExpression':
133
+ return expr.callee?.type === 'MemberExpression' && expr.callee.object?.name === 'Math' ? 'number' : undefined;
134
+ default:
135
+ return undefined;
136
+ }
137
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * `prosebook/vite`: the catalog as part of the build.
3
+ *
4
+ * Generates on start and again whenever a catalog or translation file changes, so dev never runs on
5
+ * stale modules and a broken entry shows in the error overlay. A build ships only the messages the
6
+ * code names: each locale module is swapped for its messages less the keys no module references
7
+ * (see `tools/scan.ts` for how a reference is found, and why it errs towards keeping).
8
+ *
9
+ * In dev an edit doesn't reload the page: each generated locale module accepts its own update and
10
+ * hands the new messages to every copy (`HotHook`), which re-renders in the locale and state it was in.
11
+ * The index accepts its own too, since its doc comments change with the source text, and reloads the
12
+ * page only when a locale was added or removed. The page also gets the inspector (`inspector.ts`), and
13
+ * the server opens the entry it asks for through Vite's own `/__open-in-editor`, so the editor is
14
+ * whichever Vite already launches.
15
+ */
16
+ import { resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import type { Plugin } from 'vite';
20
+
21
+ import { CatalogError, entryLocation } from './tools/catalog.ts';
22
+ import { compileLocale, localeExpression } from './tools/compile.ts';
23
+ import { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';
24
+ import { editEntry, readEntry, type EntryEdit } from './tools/entry.ts';
25
+ import { generate, type Generated } from './tools/generate.ts';
26
+ import { isReferenced, keyRefs, type KeyRefs } from './tools/scan.ts';
27
+
28
+ export interface ProsebookPluginOptions {
29
+ /** The config, inline. Without it, `prosebook.config.*` at the Vite root is read. */
30
+ config?: ProsebookConfig;
31
+ /** Ship only the messages the code names. On by default. */
32
+ prune?: boolean;
33
+ /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
34
+ keep?: string[];
35
+ /** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */
36
+ inspect?: boolean;
37
+ }
38
+
39
+ const PLACEHOLDER = /__PROSEBOOK_LOCALE_(\d+)__/g;
40
+ const DEV_ID = '\0prosebook/dev';
41
+
42
+ const hotLocale = (locale: string) => `
43
+ if (import.meta.hot) {
44
+ import.meta.hot.accept((next) => {
45
+ const hot = globalThis.__PROSEBOOK_HOT__;
46
+ if (next === undefined || hot === undefined) return import.meta.hot.invalidate();
47
+ for (const update of hot) update(${JSON.stringify(locale)}, next.default);
48
+ });
49
+ }
50
+ `;
51
+
52
+ const HOT_INDEX = `
53
+ if (import.meta.hot) {
54
+ import.meta.hot.accept((next) => {
55
+ if (next === undefined || next.catalog.locales.join() !== catalog.locales.join()) import.meta.hot.invalidate();
56
+ });
57
+ }
58
+ `;
59
+
60
+ const normalize = (path: string) => resolve(path).replaceAll('\\', '/');
61
+
62
+ // Beside this module: the source when run from source, the built file in dist.
63
+ const inspectorPath = normalize(fileURLToPath(new URL(`./inspector${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url)));
64
+
65
+ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
66
+ let config: ResolvedConfig;
67
+ let generated: Generated | undefined;
68
+ let building = false;
69
+ let base = '/';
70
+ let refs: KeyRefs = { exact: new Set(), prefixes: new Set() };
71
+ const placeholders: string[] = [];
72
+ const shipped = { total: 0, kept: 0 };
73
+
74
+ const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
75
+
76
+ const localeOf = (id: string): string | undefined => {
77
+ const path = normalize(id.split('?', 1)[0]!);
78
+ const out = normalize(config.out);
79
+ if (!path.startsWith(`${out}/`)) return undefined;
80
+ const name = path.slice(out.length + 1).replace(/\.[jt]s$/, '');
81
+ return generated?.entries.has(name) === true ? name : undefined;
82
+ };
83
+
84
+ const isIndex = (id: string) => {
85
+ const path = normalize(id.split('?', 1)[0]!);
86
+ const out = normalize(config.out);
87
+ return path === `${out}/index.ts` || path === `${out}/index.js`;
88
+ };
89
+
90
+ const kept = (key: string) =>
91
+ isReferenced(key, refs) || (options.keep ?? []).some((k) => (k.endsWith('.*') ? key.startsWith(k.slice(0, -1)) : k === key));
92
+
93
+ return {
94
+ name: 'prosebook',
95
+
96
+ async configResolved(resolved) {
97
+ building = resolved.command === 'build';
98
+ base = resolved.base;
99
+ config = await loadConfig(resolved.root, options.config);
100
+ },
101
+
102
+ // First in <head>, so the hot hooks are there before any copy is created.
103
+ transformIndexHtml() {
104
+ if (building) return [];
105
+ return [{ tag: 'script', attrs: { type: 'module', src: `${base}@id/__x00__prosebook/dev` }, injectTo: 'head-prepend' }];
106
+ },
107
+
108
+ resolveId(id) {
109
+ return id === DEV_ID ? id : null;
110
+ },
111
+
112
+ buildStart() {
113
+ try {
114
+ generated = generate(config);
115
+ } catch (e) {
116
+ this.error(describe(e));
117
+ }
118
+ refs = { exact: new Set(), prefixes: new Set() };
119
+ placeholders.length = 0;
120
+ shipped.total = shipped.kept = 0;
121
+ },
122
+
123
+ configureServer(server) {
124
+ const dirs = [normalize(config.catalog), normalize(config.locales)];
125
+ server.watcher.add(dirs);
126
+ const onChange = (file: string) => {
127
+ const path = normalize(file);
128
+ if (!path.endsWith('.yaml') || !dirs.some((d) => path.startsWith(`${d}/`))) return;
129
+ try {
130
+ generated = generate(config);
131
+ server.config.logger.info(`prosebook: regenerated from ${path.slice(normalize(config.root).length + 1)}`, { timestamp: true });
132
+ } catch (e) {
133
+ server.config.logger.error(describe(e), { timestamp: true });
134
+ server.ws.send({ type: 'error', err: { message: describe(e), stack: '', plugin: 'prosebook' } });
135
+ }
136
+ };
137
+ server.watcher.on('change', onChange);
138
+ server.watcher.on('add', onChange);
139
+ server.watcher.on('unlink', onChange);
140
+ if (options.inspect === false) return;
141
+
142
+ // `?key=cart.items&locale=de`: the translation's entry, or the catalog's with `catalog` or when
143
+ // the locale has none. Handed on to Vite's `/__open-in-editor`, which comes later in the stack.
144
+ server.middlewares.use((req, res, next) => {
145
+ const url = new URL(req.url ?? '/', 'http://localhost');
146
+ if (url.pathname !== `${base}__prosebook/open`) return next();
147
+ const key = url.searchParams.get('key') ?? '';
148
+ const message = generated?.catalog.messages.find((m) => m.key === key);
149
+ if (message === undefined) {
150
+ res.statusCode = 404;
151
+ res.end(`no entry for "${key}" in the catalog: has it been renamed since the page loaded?`);
152
+ return;
153
+ }
154
+ const locale = url.searchParams.has('catalog') ? undefined : (url.searchParams.get('locale') ?? undefined);
155
+ const { file, line, column } = entryLocation(config, message, locale);
156
+ req.url = `${base}__open-in-editor?file=${encodeURIComponent(`${file}:${line}:${column}`)}`;
157
+ next();
158
+ });
159
+
160
+ // The inspector's dialog: GET `?key=…` reads an entry in every locale, POST `{ key, edit, write }`
161
+ // applies an edit (`tools/entry.ts`). A POST writes files, so only the page itself may send one:
162
+ // same origin, and JSON, which a form on another site can't send without asking first.
163
+ server.middlewares.use((req, res, next) => {
164
+ const url = new URL(req.url ?? '/', 'http://localhost');
165
+ if (url.pathname !== `${base}__prosebook/entry`) return next();
166
+ const reply = (status: number, body: unknown) => {
167
+ res.statusCode = status;
168
+ res.setHeader('Content-Type', 'application/json');
169
+ res.end(JSON.stringify(body));
170
+ };
171
+ const fail = (e: unknown) =>
172
+ reply(e instanceof CatalogError ? 409 : 400, {
173
+ problems: e instanceof CatalogError ? e.problems : [{ severity: 'error', message: e instanceof Error ? e.message : String(e) }],
174
+ });
175
+ if (req.method === 'GET') {
176
+ try {
177
+ reply(200, readEntry(config, url.searchParams.get('key') ?? ''));
178
+ } catch (e) {
179
+ fail(e);
180
+ }
181
+ return;
182
+ }
183
+ const origin = req.headers.origin;
184
+ const sameOrigin = origin === undefined || new URL(origin).host === req.headers.host;
185
+ if (req.method !== 'POST' || !sameOrigin || req.headers['content-type']?.startsWith('application/json') !== true) {
186
+ reply(403, { problems: [{ severity: 'error', message: 'edits come from the page, as JSON' }] });
187
+ return;
188
+ }
189
+ let body = '';
190
+ req.setEncoding('utf8');
191
+ req.on('data', (chunk: string) => (body += chunk));
192
+ req.on('end', () => {
193
+ try {
194
+ const { key, edit, write } = JSON.parse(body) as { key: string; edit: EntryEdit; write?: boolean };
195
+ const result = editEntry(config, key, edit, write === true);
196
+ reply(result.problems.length > 0 ? 422 : 200, result);
197
+ } catch (e) {
198
+ fail(e);
199
+ }
200
+ });
201
+ });
202
+ },
203
+
204
+ load(id) {
205
+ if (id === DEV_ID) {
206
+ const hot = 'globalThis.__PROSEBOOK_HOT__ ??= new Set();\n';
207
+ if (options.inspect === false) return hot;
208
+ return `import { install } from ${JSON.stringify(inspectorPath)};\n${hot}install(${JSON.stringify({ base })});\n`;
209
+ }
210
+ if (!building || options.prune === false || generated === undefined) return null;
211
+ const locale = localeOf(id);
212
+ if (locale === undefined) return null;
213
+ placeholders.push(locale);
214
+ return `export default __PROSEBOOK_LOCALE_${placeholders.length - 1}__;\n`;
215
+ },
216
+
217
+ transform(code, id) {
218
+ if (!building) {
219
+ const locale = localeOf(id);
220
+ if (locale !== undefined) return { code: code + hotLocale(locale), map: null };
221
+ return isIndex(id) ? { code: code + HOT_INDEX, map: null } : null;
222
+ }
223
+ if (options.prune === false || id.includes('/node_modules/') || id.startsWith('\0')) return null;
224
+ if (localeOf(id) !== undefined) return null;
225
+ keyRefs(code, refs);
226
+ return null;
227
+ },
228
+
229
+ renderChunk(code) {
230
+ if (!code.includes('__PROSEBOOK_LOCALE_') || generated === undefined) return null;
231
+ const entries = generated.entries;
232
+ const replaced = code.replace(PLACEHOLDER, (_match, index: string) => {
233
+ const locale = placeholders[Number(index)]!;
234
+ const list = entries.get(locale) ?? [];
235
+ const compiled = compileLocale(list, kept);
236
+ shipped.total += list.length;
237
+ shipped.kept += compiled.rows.length;
238
+ return localeExpression(compiled);
239
+ });
240
+ return { code: replaced, map: null };
241
+ },
242
+
243
+ generateBundle() {
244
+ if (shipped.total === 0) return;
245
+ this.info(
246
+ `shipped ${shipped.kept} of ${shipped.total} messages across ${placeholders.length} locale(s); pruned ${shipped.total - shipped.kept} no code names`,
247
+ );
248
+ },
249
+ };
250
+ }
251
+
252
+ export default prosebook;
package/index.js DELETED
@@ -1 +0,0 @@
1
- console.error("Package isn't ready yet. Please wait for the next release.");