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/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prosebook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Every string your app shows, in one typed catalog: ICU messages compiled to plain functions, for React and any JavaScript.",
|
|
5
5
|
"keywords": [
|
|
6
|
+
"bun",
|
|
6
7
|
"i18n",
|
|
7
8
|
"icu",
|
|
8
9
|
"intl",
|
|
@@ -10,10 +11,12 @@
|
|
|
10
11
|
"localization",
|
|
11
12
|
"messageformat",
|
|
12
13
|
"messageformat2",
|
|
14
|
+
"nextjs",
|
|
13
15
|
"react",
|
|
14
16
|
"translation",
|
|
15
17
|
"typescript",
|
|
16
|
-
"ux-writing"
|
|
18
|
+
"ux-writing",
|
|
19
|
+
"vite"
|
|
17
20
|
],
|
|
18
21
|
"homepage": "https://gkurt.com/prosebook/",
|
|
19
22
|
"bugs": {
|
|
@@ -32,6 +35,9 @@
|
|
|
32
35
|
"bin": {
|
|
33
36
|
"prosebook": "./dist/cli.js"
|
|
34
37
|
},
|
|
38
|
+
"workspaces": [
|
|
39
|
+
"examples/*"
|
|
40
|
+
],
|
|
35
41
|
"files": [
|
|
36
42
|
"dist",
|
|
37
43
|
"src",
|
|
@@ -59,6 +65,13 @@
|
|
|
59
65
|
"default": "./dist/react/index.js"
|
|
60
66
|
}
|
|
61
67
|
},
|
|
68
|
+
"./react/server": {
|
|
69
|
+
"prosebook@dev": "./src/react/server.ts",
|
|
70
|
+
"import": {
|
|
71
|
+
"types": "./dist/react/server.d.ts",
|
|
72
|
+
"default": "./dist/react/server.js"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
62
75
|
"./vite": {
|
|
63
76
|
"prosebook@dev": "./src/vite.ts",
|
|
64
77
|
"import": {
|
|
@@ -66,6 +79,20 @@
|
|
|
66
79
|
"default": "./dist/vite.js"
|
|
67
80
|
}
|
|
68
81
|
},
|
|
82
|
+
"./next": {
|
|
83
|
+
"prosebook@dev": "./src/next.ts",
|
|
84
|
+
"default": {
|
|
85
|
+
"types": "./dist/next.d.ts",
|
|
86
|
+
"default": "./dist/next.js"
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"./bun": {
|
|
90
|
+
"prosebook@dev": "./src/bun.ts",
|
|
91
|
+
"import": {
|
|
92
|
+
"types": "./dist/bun.d.ts",
|
|
93
|
+
"default": "./dist/bun.js"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
69
96
|
"./config": {
|
|
70
97
|
"prosebook@dev": "./src/tools/config.ts",
|
|
71
98
|
"import": {
|
|
@@ -93,8 +120,9 @@
|
|
|
93
120
|
"build": "tsdown",
|
|
94
121
|
"prepack": "tsdown",
|
|
95
122
|
"cli": "bun --conditions=prosebook@dev src/cli.ts",
|
|
96
|
-
"example": "
|
|
97
|
-
"
|
|
123
|
+
"example": "tsdown && bun run --cwd examples/vite dev",
|
|
124
|
+
"examples:generate": "tsdown && bun run --filter 'example-*' generate",
|
|
125
|
+
"examples:smoke": "bun scripts/smoke.mts",
|
|
98
126
|
"site": "rm -rf _site && mkdir -p _site && cp -R site/* _site/",
|
|
99
127
|
"prepare": "husky",
|
|
100
128
|
"tegami": "bun scripts/tegami.mts"
|
|
@@ -111,6 +139,7 @@
|
|
|
111
139
|
"@types/react": "^19.3.0",
|
|
112
140
|
"@types/react-dom": "^19.3.0",
|
|
113
141
|
"@vitejs/plugin-react": "^6.1.2",
|
|
142
|
+
"cldr-core": "48",
|
|
114
143
|
"husky": "^9.1.7",
|
|
115
144
|
"lint-staged": "^17.6.0",
|
|
116
145
|
"oxfmt": "^0.75.0",
|
|
@@ -124,6 +153,7 @@
|
|
|
124
153
|
"vite": "^8.3.5"
|
|
125
154
|
},
|
|
126
155
|
"peerDependencies": {
|
|
156
|
+
"next": ">=16",
|
|
127
157
|
"react": ">=18",
|
|
128
158
|
"typescript": ">=7",
|
|
129
159
|
"vite": ">=6"
|
|
@@ -137,6 +167,9 @@
|
|
|
137
167
|
},
|
|
138
168
|
"typescript": {
|
|
139
169
|
"optional": true
|
|
170
|
+
},
|
|
171
|
+
"next": {
|
|
172
|
+
"optional": true
|
|
140
173
|
}
|
|
141
174
|
},
|
|
142
175
|
"lint-staged": {
|
package/src/bun.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook/bun`: the catalog as part of a Bun build, or of Bun's dev server.
|
|
3
|
+
*
|
|
4
|
+
* Generates when the plugin is set up. Given to `Bun.build`, it ships only the messages the code
|
|
5
|
+
* names: Bun loads a locale module before it has seen the code that reads it, so the keys come from
|
|
6
|
+
* the source under `scan.dirs` (`tools/bundle.ts`) and each locale loads already pruned. The `bun
|
|
7
|
+
* build` command reads no plugins, so a build that prunes is a script that calls `Bun.build`.
|
|
8
|
+
*
|
|
9
|
+
* Named in bunfig.toml's `[serve.static] plugins`, it serves `Bun.serve`'s bundler instead, which
|
|
10
|
+
* names no entry points: it regenerates whenever a catalog or translation file changes, and each
|
|
11
|
+
* locale module accepts its own update and hands the new messages to every copy, as under Vite. The
|
|
12
|
+
* index starts the inspector on the page too, whose endpoints are `inspector()`'s routes, spread into
|
|
13
|
+
* `Bun.serve`'s; an entry opens in the editor `Bun.openInEditor` finds.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { basename, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import type { BunPlugin } from 'bun';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
beside,
|
|
22
|
+
HOT_INDEX,
|
|
23
|
+
HOT_SET,
|
|
24
|
+
hotLocale,
|
|
25
|
+
installInspector,
|
|
26
|
+
keeper,
|
|
27
|
+
prunedModule,
|
|
28
|
+
scanSources,
|
|
29
|
+
unseenMessage,
|
|
30
|
+
watchCatalog,
|
|
31
|
+
type Prune,
|
|
32
|
+
} from './tools/bundle.ts';
|
|
33
|
+
import { CatalogError } from './tools/catalog.ts';
|
|
34
|
+
import { loadConfig, type ProsebookConfig } from './tools/config.ts';
|
|
35
|
+
import { inspectorEndpoints } from './tools/endpoints.ts';
|
|
36
|
+
import { generate } from './tools/generate.ts';
|
|
37
|
+
|
|
38
|
+
export interface ProsebookBunOptions {
|
|
39
|
+
/** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
|
|
40
|
+
config?: ProsebookConfig;
|
|
41
|
+
/** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */
|
|
42
|
+
root?: string;
|
|
43
|
+
/**
|
|
44
|
+
* In `Bun.build`, ship only the messages the code names. `strict`, the default, also fails the
|
|
45
|
+
* build on a read whose key it can't see, such as `t(item.key)`. `loose` prunes without that
|
|
46
|
+
* check; `false` ships every message.
|
|
47
|
+
*/
|
|
48
|
+
prune?: Prune;
|
|
49
|
+
/** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
|
|
50
|
+
keep?: string[];
|
|
51
|
+
/** Under the dev server, Alt-click a string on the page to open its entry in the editor. On by default. */
|
|
52
|
+
inspect?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const inspectorPath = beside(import.meta.url, 'inspector');
|
|
56
|
+
|
|
57
|
+
const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
58
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
59
|
+
|
|
60
|
+
export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
|
|
61
|
+
return {
|
|
62
|
+
name: 'prosebook',
|
|
63
|
+
async setup(build) {
|
|
64
|
+
const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);
|
|
65
|
+
let generated = generate(config);
|
|
66
|
+
// The generated modules, by path as Bun gives it: either separator on Windows.
|
|
67
|
+
const folder = config.out.split(/[\\/]/).map(escape).join('[\\\\/]');
|
|
68
|
+
const filter = new RegExp(`^${folder}[\\\\/][^\\\\/]+\\.[jt]s$`);
|
|
69
|
+
const nameOf = (path: string) => basename(path).replace(/\.[jt]s$/, '');
|
|
70
|
+
|
|
71
|
+
if ('entrypoints' in build.config) {
|
|
72
|
+
const prune = options.prune ?? 'strict';
|
|
73
|
+
if (prune === false) return;
|
|
74
|
+
const { refs, unseen } = scanSources(config, prune === 'strict');
|
|
75
|
+
if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
|
|
76
|
+
const kept = keeper(refs, options.keep);
|
|
77
|
+
build.onLoad({ filter }, ({ path }) => {
|
|
78
|
+
const entries = generated.entries.get(nameOf(path));
|
|
79
|
+
return entries === undefined ? undefined : { contents: prunedModule(entries, kept), loader: 'js' };
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dev = process.env.NODE_ENV !== 'production';
|
|
85
|
+
const inspector = dev && options.inspect !== false ? installInspector(inspectorPath, '/') : '';
|
|
86
|
+
if (dev) {
|
|
87
|
+
watchCatalog(config, () => {
|
|
88
|
+
try {
|
|
89
|
+
generated = generate(config);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error(describe(e));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
build.onLoad({ filter }, ({ path }) => {
|
|
96
|
+
const name = nameOf(path);
|
|
97
|
+
const loader = path.endsWith('.ts') ? 'ts' : 'js';
|
|
98
|
+
if (generated.entries.has(name)) return { contents: readFileSync(path, 'utf8') + hotLocale(name), loader };
|
|
99
|
+
return name === 'index' ? { contents: HOT_SET + inspector + readFileSync(path, 'utf8') + HOT_INDEX, loader } : undefined;
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface InspectorRoutesOptions {
|
|
106
|
+
/** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
|
|
107
|
+
config?: ProsebookConfig;
|
|
108
|
+
/** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */
|
|
109
|
+
root?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The inspector's endpoints as `Bun.serve` routes: `routes: { ...inspector(), '/': index }`. For dev
|
|
114
|
+
* only: they read and write the catalog.
|
|
115
|
+
*/
|
|
116
|
+
export function inspector(options: InspectorRoutesOptions = {}): Record<string, (req: Request) => Promise<Response>> {
|
|
117
|
+
const handle = inspectorEndpoints(loadConfig(resolve(options.root ?? process.cwd()), options.config), {
|
|
118
|
+
open: ({ file, line, column }) => {
|
|
119
|
+
try {
|
|
120
|
+
Bun.openInEditor(file, { line, column });
|
|
121
|
+
return new Response(null, { status: 204 });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return new Response(`${e instanceof Error ? e.message : String(e)}: set VISUAL or EDITOR to the editor's command`, { status: 500 });
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const route = async (req: Request) => (await handle(req)) ?? new Response(null, { status: 404 });
|
|
128
|
+
return { '/__prosebook/open': route, '/__prosebook/entry': route };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// bunfig.toml names the module, and Bun takes its default export as the plugin.
|
|
132
|
+
export default prosebook();
|
package/src/cli.ts
CHANGED
|
@@ -3,24 +3,23 @@
|
|
|
3
3
|
* The `prosebook` command. Each subcommand is a function of the resolved config; `check` runs the
|
|
4
4
|
* ones CI needs in one go.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join, relative, resolve } from 'node:path';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import { watchCatalog } from './tools/bundle.ts';
|
|
12
11
|
import { CatalogError, entryLocator, whereOf, type Catalog, type Problem } from './tools/catalog.ts';
|
|
13
12
|
import { CONFIG_FILES, loadConfig, type ResolvedConfig } from './tools/config.ts';
|
|
14
|
-
import {
|
|
15
|
-
import { applyFixes } from './tools/entry.ts';
|
|
13
|
+
import { applyFixes, importTranslations } from './tools/entry.ts';
|
|
16
14
|
import { applyExtract, planExtract } from './tools/extract.ts';
|
|
17
15
|
import { generate, render } from './tools/generate.ts';
|
|
18
|
-
import { lint } from './tools/lint.ts';
|
|
19
|
-
import type
|
|
16
|
+
import { lint, type Finding } from './tools/lint.ts';
|
|
17
|
+
import { changeOf, type StaleTranslation } from './tools/lock.ts';
|
|
20
18
|
import { applyMigrate, planMigrate } from './tools/migrate.ts';
|
|
21
19
|
import { SarifReport } from './tools/sarif.ts';
|
|
22
20
|
import { isReferenced, scan, sourceFiles } from './tools/scan.ts';
|
|
23
21
|
import { SYNTAX_NAME } from './tools/syntax.ts';
|
|
22
|
+
import { todoOf } from './tools/todo.ts';
|
|
24
23
|
|
|
25
24
|
const HELP = `prosebook: every string your app shows, in one typed catalog
|
|
26
25
|
|
|
@@ -34,6 +33,9 @@ Commands:
|
|
|
34
33
|
status Translation coverage and stale translations per locale (--strict, --verbose)
|
|
35
34
|
accept <locale> [keys] Mark stale translations as still right (--all for every stale one)
|
|
36
35
|
todo <locale> Print the missing and stale entries of a locale, ready to translate
|
|
36
|
+
(--prompt: as a prompt for a model, saying how to reply)
|
|
37
|
+
import <locale> [file] Write translations from todo's YAML filled in, or a model's reply to --prompt
|
|
38
|
+
(stdin without a file); each is checked first, and one that fails is left out
|
|
37
39
|
scan Find hardcoded text in JSX and catalog keys no code reads (--strict)
|
|
38
40
|
extract [paths] Move hardcoded JSX text into the catalog and rewrite the code to read it
|
|
39
41
|
(dry run unless --write; --area <name> puts every entry in one area)
|
|
@@ -78,6 +80,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
78
80
|
all: { type: 'boolean' },
|
|
79
81
|
write: { type: 'boolean' },
|
|
80
82
|
fix: { type: 'boolean' },
|
|
83
|
+
prompt: { type: 'boolean' },
|
|
81
84
|
area: { type: 'string' },
|
|
82
85
|
format: { type: 'string' },
|
|
83
86
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -119,7 +122,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
119
122
|
case 'accept':
|
|
120
123
|
return runAccept(config, rest, values.all === true);
|
|
121
124
|
case 'todo':
|
|
122
|
-
return runTodo(config, rest[0]);
|
|
125
|
+
return runTodo(config, rest[0], values.prompt === true);
|
|
126
|
+
case 'import':
|
|
127
|
+
return runImport(config, rest[0], rest[1]);
|
|
123
128
|
case 'scan':
|
|
124
129
|
code = runScan(config, values.strict === true, report);
|
|
125
130
|
break;
|
|
@@ -202,15 +207,7 @@ function watchMode(config: ResolvedConfig): Promise<number> {
|
|
|
202
207
|
}
|
|
203
208
|
};
|
|
204
209
|
regenerate();
|
|
205
|
-
|
|
206
|
-
for (const dir of [config.catalog, config.locales]) {
|
|
207
|
-
if (!existsSync(dir)) continue;
|
|
208
|
-
watch(dir, { recursive: true }, (_event, file) => {
|
|
209
|
-
if (file === null || !file.endsWith('.yaml')) return;
|
|
210
|
-
clearTimeout(timer);
|
|
211
|
-
timer = setTimeout(regenerate, 50);
|
|
212
|
-
});
|
|
213
|
-
}
|
|
210
|
+
watchCatalog(config, regenerate);
|
|
214
211
|
out('prosebook: watching the catalog for changes');
|
|
215
212
|
return new Promise(() => {});
|
|
216
213
|
}
|
|
@@ -231,12 +228,7 @@ function runLint(config: ResolvedConfig, report?: SarifReport, fix = false): num
|
|
|
231
228
|
findings = lint(catalog.messages, config);
|
|
232
229
|
}
|
|
233
230
|
}
|
|
234
|
-
const
|
|
235
|
-
const placed = findings.map((f) => ({ ...f, ...locate(f.file, f.key, f.at?.start, config.source) }));
|
|
236
|
-
for (const f of placed) {
|
|
237
|
-
const severity = f.severity === 'error' ? 'error' : 'warn ';
|
|
238
|
-
err(`${severity} ${f.file}:${f.line}:${f.column} ${f.key}: [${f.rule}] ${f.message}${f.fix === undefined ? '' : ' (fixable)'}`);
|
|
239
|
-
}
|
|
231
|
+
const placed = printFindings(config, findings);
|
|
240
232
|
if (report !== undefined) {
|
|
241
233
|
for (const [name, { rule }] of Object.entries(config.lint.rules)) report.rule(name, rule.description);
|
|
242
234
|
report.rule('allow', RULES.allow);
|
|
@@ -264,6 +256,17 @@ function runLint(config: ResolvedConfig, report?: SarifReport, fix = false): num
|
|
|
264
256
|
return errors > 0 ? 1 : 0;
|
|
265
257
|
}
|
|
266
258
|
|
|
259
|
+
/** Each finding on its line, placed at its entry's words. */
|
|
260
|
+
function printFindings(config: ResolvedConfig, findings: Finding[]) {
|
|
261
|
+
const locate = entryLocator(config.root);
|
|
262
|
+
const placed = findings.map((f) => ({ ...f, ...locate(f.file, f.key, f.at?.start, config.source) }));
|
|
263
|
+
for (const f of placed) {
|
|
264
|
+
const severity = f.severity === 'error' ? 'error' : 'warn ';
|
|
265
|
+
err(`${severity} ${f.file}:${f.line}:${f.column} ${f.key}: [${f.rule}] ${f.message}${f.fix === undefined ? '' : ' (fixable)'}`);
|
|
266
|
+
}
|
|
267
|
+
return placed;
|
|
268
|
+
}
|
|
269
|
+
|
|
267
270
|
function missingKeys(catalog: Catalog, locale: string): string[] {
|
|
268
271
|
return catalog.messages.filter((m) => !m.translations.has(locale)).map((m) => m.key);
|
|
269
272
|
}
|
|
@@ -337,47 +340,46 @@ function runAccept(config: ResolvedConfig, args: string[], all: boolean): number
|
|
|
337
340
|
return 0;
|
|
338
341
|
}
|
|
339
342
|
|
|
340
|
-
|
|
341
|
-
function changeOf(stale: StaleTranslation): string {
|
|
342
|
-
if (stale.was === undefined) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
|
|
343
|
-
return formatDiff(wordDiff(stale.was, stale.now)).replaceAll('\n', ' ');
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function runTodo(config: ResolvedConfig, locale: string | undefined): number {
|
|
343
|
+
function runTodo(config: ResolvedConfig, locale: string | undefined, prompt: boolean): number {
|
|
347
344
|
if (locale === undefined) {
|
|
348
|
-
err('Usage: prosebook todo <locale>');
|
|
345
|
+
err('Usage: prosebook todo <locale> [--prompt]');
|
|
349
346
|
return 1;
|
|
350
347
|
}
|
|
351
348
|
const { catalog, stale } = render(config);
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
for (const [arg, said] of m.argDocs) lines.push(`# {${arg}}: ${said}`);
|
|
363
|
-
lines.push(`# ${config.source}: ${m.text.replaceAll('\n', ' ')}`);
|
|
364
|
-
const change = staleKeys.get(m.key);
|
|
365
|
-
if (change !== undefined) lines.push(`# stale, the source changed: ${changeOf(change)}`);
|
|
366
|
-
lines.push(stringify({ [m.key]: current?.text ?? '' }, { lineWidth: 0 }).trimEnd());
|
|
367
|
-
byArea.set(m.area, lines);
|
|
368
|
-
}
|
|
369
|
-
if (count === 0) {
|
|
370
|
-
out(`# ${locale} is complete`);
|
|
371
|
-
return 0;
|
|
349
|
+
const todo = todoOf(config, catalog, stale.get(locale) ?? new Map(), locale, prompt);
|
|
350
|
+
out(todo.text);
|
|
351
|
+
if (prompt && todo.count > 0) err(`\nHand this to a model, save its reply, and \`prosebook import ${locale} <reply>\`.`);
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function runImport(config: ResolvedConfig, locale: string | undefined, file: string | undefined): number {
|
|
356
|
+
if (locale === undefined || ((file === undefined || file === '-') && process.stdin.isTTY)) {
|
|
357
|
+
err('Usage: prosebook import <locale> <file>, or the reply on stdin');
|
|
358
|
+
return 1;
|
|
372
359
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
360
|
+
const reply = readFileSync(file === undefined || file === '-' ? 0 : resolve(file), 'utf8');
|
|
361
|
+
const { written, unchanged, problems } = importTranslations(config, locale, reply);
|
|
362
|
+
reportProblems(config, problems);
|
|
363
|
+
if (written.length + unchanged.length > 0) {
|
|
364
|
+
// A stale translation given back unchanged was looked at and still holds.
|
|
365
|
+
const stale = render(config).stale.get(locale) ?? new Map<string, StaleTranslation>();
|
|
366
|
+
const kept = unchanged.filter((key) => stale.has(key));
|
|
367
|
+
const { catalog } = generate(config, { accept: new Map([[locale, new Set(kept)]]) });
|
|
368
|
+
out(
|
|
369
|
+
`prosebook: imported ${written.length} translation(s) into ${locale}${kept.length > 0 ? `, and accepted ${kept.length} stale one(s) as still right` : ''}`,
|
|
370
|
+
);
|
|
371
|
+
const imported = new Set(written);
|
|
372
|
+
printFindings(
|
|
373
|
+
config,
|
|
374
|
+
lint(
|
|
375
|
+
catalog.messages.filter((m) => imported.has(m.key)),
|
|
376
|
+
config,
|
|
377
|
+
).filter((f) => f.locale === locale),
|
|
378
|
+
);
|
|
379
379
|
}
|
|
380
|
-
return 0;
|
|
380
|
+
if (problems.length === 0) return 0;
|
|
381
|
+
err(`\n${problems.length} left out: fix them, or hand these lines back to the model, and import again.`);
|
|
382
|
+
return 1;
|
|
381
383
|
}
|
|
382
384
|
|
|
383
385
|
function runScan(config: ResolvedConfig, strict: boolean, report?: SarifReport): number {
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* functions: this runtime looks them up, picks the locale and falls back, and parses nothing.
|
|
7
7
|
*/
|
|
8
8
|
export { createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
|
|
9
|
-
export { chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
|
|
9
|
+
export { acceptedLocales, chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
|
|
10
10
|
export { createHelpers, flatten } from './runtime/helpers.ts';
|
|
11
11
|
export type {
|
|
12
12
|
AnyTagFn,
|
package/src/inspector.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The dev inspector
|
|
2
|
+
* The dev inspector each integration loads into the page: hold Alt and point at a string to see its
|
|
3
3
|
* key, Alt-click to open the entry it came from, Alt-Shift-click to open the catalog entry instead of
|
|
4
4
|
* the translation. Alt-right-click edits the entry on the page instead (`inspector-dialog.ts`).
|
|
5
5
|
*
|
|
@@ -113,10 +113,15 @@ export interface InspectorOptions {
|
|
|
113
113
|
base: string;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Starts listening for reads and for Alt on the page, once: Bun and Next call it from the generated
|
|
118
|
+
* index, which runs again whenever it changes.
|
|
119
|
+
*/
|
|
117
120
|
export function install({ base }: InspectorOptions): void {
|
|
121
|
+
const hooks = globalThis as DevHooks;
|
|
122
|
+
if (hooks.__PROSEBOOK_READ__ !== undefined) return;
|
|
118
123
|
const reads: Reads = new Map();
|
|
119
|
-
|
|
124
|
+
hooks.__PROSEBOOK_READ__ = (key, text, locale) => record(reads, key, text, locale);
|
|
120
125
|
|
|
121
126
|
const box = document.createElement('div');
|
|
122
127
|
const label = document.createElement('div');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Turbopack loader `prosebook/next` sets on the generated modules. In a build it hands over a
|
|
3
|
+
* locale module `withProsebook` has already pruned, since that reads the source once for every
|
|
4
|
+
* locale. In dev, in the browser, it puts what `withProsebook` hands it before the index (the hot
|
|
5
|
+
* hooks, and the inspector), and makes each locale module update the copies holding it whenever Fast
|
|
6
|
+
* Refresh runs it again.
|
|
7
|
+
*/
|
|
8
|
+
import { notifyingLocale } from './tools/bundle.ts';
|
|
9
|
+
|
|
10
|
+
export type LoaderOptions = { code: string } | { prepend: string } | { locale: string };
|
|
11
|
+
|
|
12
|
+
export default function loader(this: { getOptions(): LoaderOptions }, source: string): string {
|
|
13
|
+
const options = this.getOptions();
|
|
14
|
+
if ('code' in options) return options.code;
|
|
15
|
+
return 'prepend' in options ? options.prepend + source : notifyingLocale(source, options.locale);
|
|
16
|
+
}
|
package/src/next.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook/next`: the catalog as part of a Next.js app.
|
|
3
|
+
*
|
|
4
|
+
* Generates when `next dev` or `next build` loads the config, and in dev again whenever a catalog or
|
|
5
|
+
* translation file changes, so Fast Refresh picks up the new modules and a broken entry shows in the
|
|
6
|
+
* terminal. Server components read the new text on their next render; in the browser, each locale
|
|
7
|
+
* module hands its new messages to the copies holding it, which re-render in the locale and state
|
|
8
|
+
* they were in. `next build` ships only the messages the code names: each locale module is handed to a
|
|
9
|
+
* Turbopack loader (`next-loader.ts`) that returns it pruned. Turbopack loads a module before it has
|
|
10
|
+
* seen the code that reads it, so the keys come from the source under `scan.dirs` (`tools/bundle.ts`),
|
|
11
|
+
* read here once, and each pruned module travels to the loader as its options.
|
|
12
|
+
*
|
|
13
|
+
* In dev the browser's index also starts the inspector (`inspector.ts`). Its endpoints can't be a
|
|
14
|
+
* route of the app without a file in it, so a server of their own answers them on a local port, and a
|
|
15
|
+
* rewrite sends `/__prosebook/*` there; an entry opens through Next's own `/__nextjs_launch-editor`.
|
|
16
|
+
*
|
|
17
|
+
* Turbopack only: Next 16 fails a Turbopack build whose config has a `webpack` key, so none is added.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from 'node:http';
|
|
20
|
+
import type { AddressInfo } from 'node:net';
|
|
21
|
+
import { join, relative, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import type { LoaderOptions } from './next-loader.ts';
|
|
24
|
+
import {
|
|
25
|
+
beside,
|
|
26
|
+
HOT_SET,
|
|
27
|
+
installInspector,
|
|
28
|
+
keeper,
|
|
29
|
+
prunedModule,
|
|
30
|
+
scanSources,
|
|
31
|
+
unseenMessage,
|
|
32
|
+
watchCatalog,
|
|
33
|
+
type Prune,
|
|
34
|
+
} from './tools/bundle.ts';
|
|
35
|
+
import { CatalogError } from './tools/catalog.ts';
|
|
36
|
+
import { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';
|
|
37
|
+
import { inspectorEndpoints, nodeMiddleware, redirect } from './tools/endpoints.ts';
|
|
38
|
+
import { generate, type Generated } from './tools/generate.ts';
|
|
39
|
+
|
|
40
|
+
export interface ProsebookNextOptions {
|
|
41
|
+
/** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
|
|
42
|
+
config?: ProsebookConfig;
|
|
43
|
+
/** The folder the config's paths resolve from, and where its file is found: the Next app's when unset. */
|
|
44
|
+
root?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Ship only the messages the code names. `strict`, the default, also fails the build on a read
|
|
47
|
+
* whose key it can't see, such as `t(item.key)`. `loose` prunes without that check; `false` ships
|
|
48
|
+
* every message.
|
|
49
|
+
*/
|
|
50
|
+
prune?: Prune;
|
|
51
|
+
/** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
|
|
52
|
+
keep?: string[];
|
|
53
|
+
/** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */
|
|
54
|
+
inspect?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Rules = { [glob: string]: unknown };
|
|
58
|
+
type Rewrite = { source: string; destination: string };
|
|
59
|
+
type Rewrites = Rewrite[] | { beforeFiles?: Rewrite[]; afterFiles?: Rewrite[]; fallback?: Rewrite[] };
|
|
60
|
+
type ConfigFunction<C> = (phase: string, context: { defaultConfig: C }) => C | Promise<C>;
|
|
61
|
+
|
|
62
|
+
const DEV = 'phase-development-server';
|
|
63
|
+
const BUILD = 'phase-production-build';
|
|
64
|
+
|
|
65
|
+
const loaderPath = beside(import.meta.url, 'next-loader');
|
|
66
|
+
const inspectorPath = beside(import.meta.url, 'inspector');
|
|
67
|
+
|
|
68
|
+
// Next loads its config more than once in a process; one watcher and one inspector server per catalog
|
|
69
|
+
// is enough.
|
|
70
|
+
const watching = new Set<string>();
|
|
71
|
+
const serving = new Map<string, Promise<number>>();
|
|
72
|
+
|
|
73
|
+
const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
74
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Wraps the Next config: `export default withProsebook({ ... })`. Takes the config as an object or as
|
|
78
|
+
* the function Next calls with the phase, and returns the function.
|
|
79
|
+
*/
|
|
80
|
+
export function withProsebook<C extends object>(
|
|
81
|
+
next: C | ConfigFunction<C> = {} as C,
|
|
82
|
+
options: ProsebookNextOptions = {},
|
|
83
|
+
): (phase: string, context: { defaultConfig: C }) => Promise<C> {
|
|
84
|
+
return async (phase, context) => {
|
|
85
|
+
const resolved = typeof next === 'function' ? await next(phase, context) : next;
|
|
86
|
+
if (phase !== DEV && phase !== BUILD) return resolved;
|
|
87
|
+
const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);
|
|
88
|
+
if (config.language === 'ts' && config.importExtension === '.js')
|
|
89
|
+
throw new Error("prosebook: Turbopack doesn't resolve `./en.js` to `en.ts`: set `importExtension: ''` in the prosebook config");
|
|
90
|
+
const generated = generate(config);
|
|
91
|
+
if (phase === DEV) {
|
|
92
|
+
watch(config);
|
|
93
|
+
if (options.inspect === false) return withRules(resolved, hotRules(config, generated, HOT_SET));
|
|
94
|
+
const base = `${(resolved as { basePath?: string }).basePath ?? ''}/`;
|
|
95
|
+
// A relative import, which Turbopack resolves as it would the app's own.
|
|
96
|
+
const path = relative(config.out, inspectorPath).replaceAll('\\', '/');
|
|
97
|
+
const prepend = HOT_SET + installInspector(path.startsWith('.') ? path : `./${path}`, base);
|
|
98
|
+
return withRewrite(withRules(resolved, hotRules(config, generated, prepend)), await serveInspector(config, base));
|
|
99
|
+
}
|
|
100
|
+
const prune = options.prune ?? 'strict';
|
|
101
|
+
if (prune === false) return resolved;
|
|
102
|
+
return withRules(resolved, prunedRules(config, generated, prune, options.keep));
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function withRules<C extends object>(resolved: C, rules: Rules): C {
|
|
107
|
+
const turbopack = (resolved as { turbopack?: { rules?: Rules } }).turbopack;
|
|
108
|
+
return { ...resolved, turbopack: { ...turbopack, rules: { ...turbopack?.rules, ...rules } } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Sends `/__prosebook/*` to the inspector's server, before any of the app's own rewrites. */
|
|
112
|
+
function withRewrite<C extends object>(resolved: C, port: number): C {
|
|
113
|
+
const theirs = (resolved as { rewrites?: () => Promise<Rewrites> }).rewrites;
|
|
114
|
+
const ours = [{ source: '/__prosebook/:path*', destination: `http://127.0.0.1:${port}/__prosebook/:path*` }];
|
|
115
|
+
const rewrites = async (): Promise<Rewrites> => {
|
|
116
|
+
const r = (await theirs?.()) ?? [];
|
|
117
|
+
return Array.isArray(r) ? { beforeFiles: ours, afterFiles: r } : { ...r, beforeFiles: [...ours, ...(r.beforeFiles ?? [])] };
|
|
118
|
+
};
|
|
119
|
+
return { ...resolved, rewrites };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Serves the inspector's endpoints on a free local port, for as long as the process lives. Next's
|
|
124
|
+
* rewrite forwards the page's host, so an edit is still checked against the page's origin.
|
|
125
|
+
*/
|
|
126
|
+
function serveInspector(config: ResolvedConfig, base: string): Promise<number> {
|
|
127
|
+
let port = serving.get(config.catalog);
|
|
128
|
+
if (port !== undefined) return port;
|
|
129
|
+
const handle = inspectorEndpoints(config, {
|
|
130
|
+
open: ({ file, line, column }) =>
|
|
131
|
+
redirect(`${base}__nextjs_launch-editor?${new URLSearchParams({ file, line1: String(line), column1: String(column) })}`),
|
|
132
|
+
});
|
|
133
|
+
const middleware = nodeMiddleware(handle);
|
|
134
|
+
const server = createServer((req, res) =>
|
|
135
|
+
middleware(req, res, () => {
|
|
136
|
+
res.statusCode = 404;
|
|
137
|
+
res.end();
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
port = new Promise<number>((done, fail) => {
|
|
141
|
+
server.once('error', fail);
|
|
142
|
+
server.listen(0, '127.0.0.1', () => done((server.address() as AddressInfo).port));
|
|
143
|
+
});
|
|
144
|
+
server.unref();
|
|
145
|
+
serving.set(config.catalog, port);
|
|
146
|
+
return port;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function watch(config: ResolvedConfig): void {
|
|
150
|
+
if (watching.has(config.catalog)) return;
|
|
151
|
+
watching.add(config.catalog);
|
|
152
|
+
watchCatalog(config, () => {
|
|
153
|
+
try {
|
|
154
|
+
generate(config);
|
|
155
|
+
console.info(`prosebook: regenerated ${relative(config.root, config.out).replaceAll('\\', '/')}`);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error(describe(e));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A rule per generated module: matched by its file name, then by its path, which Turbopack gives from
|
|
164
|
+
* its own root, so only the end is known.
|
|
165
|
+
*/
|
|
166
|
+
function rule(config: ResolvedConfig, name: string, options: LoaderOptions, browser = false): [string, unknown] {
|
|
167
|
+
const file = `${name}.${config.language === 'ts' ? 'ts' : 'js'}`;
|
|
168
|
+
const path = { path: new RegExp(`(?:^|/)${escape(join(relative(config.root, config.out), file).replaceAll('\\', '/'))}$`) };
|
|
169
|
+
return [file, { condition: browser ? { all: ['browser', path] } : path, loaders: [{ loader: loaderPath, options }] }];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* In the browser: the index starts with `prepend`, the hot hooks and the inspector, there before any
|
|
174
|
+
* copy exists, and each locale updates the copies.
|
|
175
|
+
*/
|
|
176
|
+
function hotRules(config: ResolvedConfig, generated: Generated, prepend: string): Rules {
|
|
177
|
+
const locales = [...generated.entries.keys()].map((locale) => rule(config, locale, { locale }, true));
|
|
178
|
+
return Object.fromEntries([rule(config, 'index', { prepend }, true), ...locales]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function prunedRules(config: ResolvedConfig, generated: Generated, prune: 'strict' | 'loose', keep?: string[]): Rules {
|
|
182
|
+
const { refs, unseen } = scanSources(config, prune === 'strict');
|
|
183
|
+
if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
|
|
184
|
+
const kept = keeper(refs, keep);
|
|
185
|
+
return Object.fromEntries([...generated.entries].map(([locale, entries]) => rule(config, locale, { code: prunedModule(entries, kept) })));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default withProsebook;
|