@verbaly/studio 0.53.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aron Soto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/AronSoto/verbaly/develop/assets/logo.png" alt="Verbaly" width="300" />
3
+ </p>
4
+
5
+ <p align="center"><em>A local review server over the translation files already in your repo. No account, no database, no deploy.</em></p>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@verbaly/studio"><img src="https://img.shields.io/npm/v/@verbaly/studio?logo=npm&color=cb3837" alt="npm version" /></a>
9
+ <a href="https://github.com/AronSoto/verbaly/blob/develop/LICENSE"><img src="https://img.shields.io/npm/l/@verbaly/studio?color=blue" alt="MIT" /></a>
10
+ </p>
11
+
12
+ ---
13
+
14
+ Your JSON catalogs are the database and git is the history. Studio is a window onto them, which is
15
+ why it cannot fall out of sync with your code: it does not own anything.
16
+
17
+ > **This release ships the server and the state, not the interface.** Every route below is real and
18
+ > tested; the panel that will consume them is not written yet. Useful today as `--json`, as a local
19
+ > API for a script, and as the thing the interface will be built against.
20
+
21
+ > **Studio edits translations. It never edits the source text.** The source lives in your code and
22
+ > the key derives from it, so editing it here would be undone by the next `verbaly extract`. A write
23
+ > to the source locale is refused, and so is a key the source catalog does not have.
24
+
25
+ ## 🚀 Install
26
+
27
+ ```bash
28
+ pnpm add -D @verbaly/studio
29
+ ```
30
+
31
+ ```bash
32
+ npx verbaly-studio
33
+
34
+ # Verbaly Studio http://127.0.0.1:4747/api/state?t=kQ7pVn2XsL4b
35
+ # Project /home/you/app
36
+ # Catalog 1425 messages · 212 untranslated · 187 drafts
37
+ #
38
+ # Ctrl+C to stop
39
+ ```
40
+
41
+ | Flag | What it does |
42
+ |------|--------------|
43
+ | `--root <path>` | point at another project instead of the working directory |
44
+ | `--port <n>` | start somewhere other than 4747; a busy port climbs to the next free one |
45
+ | `--json` | print the whole state to stdout and exit, no server |
46
+
47
+ `--json` is the panel without the panel: the same object the interface reads, so it can be piped,
48
+ diffed and scripted.
49
+
50
+ ## 🔒 Security, which is not optional for a server that writes files
51
+
52
+ Any page open in your browser can talk to a local server, so three defenses ship on by default:
53
+
54
+ - it binds to `127.0.0.1`, never `0.0.0.0`
55
+ - the `Host` header must be `127.0.0.1:<port>` or `localhost:<port>`, which is what stops a DNS rebind
56
+ - a 72-bit token is minted per boot, rides in the url the command prints and is compared in constant
57
+ time, and a cross `Origin` is refused even when the token is right
58
+
59
+ Requests are capped at 1 MB, and the absolute path of your project is stripped out of every error
60
+ before it reaches the browser. On a non-default port the `Host` check requires it; on port 80 it
61
+ accepts the name alone, because that is what a browser sends.
62
+
63
+ ## 🧰 The API
64
+
65
+ Every route calls the same compiler function the CLI calls, so there is no second implementation to
66
+ keep in step. Every answer is JSON, including the errors, which are `{ "error": "[verbaly] …" }`.
67
+
68
+ | Method and route | What it does |
69
+ |---|---|
70
+ | `GET /api/state` | the whole project: config, catalogs, origins, status, check, drafts, triage and problems |
71
+ | `GET /api/health` | what `verbaly doctor` reports |
72
+ | `PUT /api/message/:locale/:key` | write a translation **and clear its draft flag**; body `{ "text": "…" }` |
73
+ | `POST /api/approve` | approve drafts; body `{ "locale": "es", "keys": ["…"] }`, and the whole locale when `keys` is omitted |
74
+
75
+ `GET /api/state` is unpaginated on purpose: the biggest catalog we know is 1425 messages, and over
76
+ localhost that is instant. Its `problems` array is why nothing Studio reads can take the server
77
+ down: a catalog it cannot parse, a drafts sidecar it cannot parse, a source file Babel cannot read,
78
+ and a `--root` with no catalogs in it are all reported there, by a path relative to your project,
79
+ and the rest is served. Studio is what you open to fix those files.
80
+
81
+ A write runs the same two validations `verbaly check` runs, so a translation that lost a `{param}`,
82
+ a tag, or the `other` case of a plural is refused with the reason instead of failing your CI later.
83
+ Whitespace is stored as the empty string, because that is what untranslated means everywhere else.
84
+
85
+ **A write clears the draft flag** because a human wrote it, the same rule `verbaly import` already
86
+ applies. Without it you would fix a bad machine translation by hand and `verbaly check --drafts`
87
+ would keep failing on your own text.
88
+
89
+ ## 🔎 Triage: which machine translations are worth reading
90
+
91
+ Adding a language writes hundreds of drafts at once, and reading them one by one is a wall. Walls
92
+ are how a safeguard turns into something people route around, so Studio ranks instead of queuing.
93
+
94
+ `check` already caught the structure, so a lost `{param}` or tag never reaches this: what is left is
95
+ meaning, which nothing can measure. These signals do not score a translation, they point at the few
96
+ worth a human eye:
97
+
98
+ | Signal | What it means |
99
+ |---|---|
100
+ | `divergent` | the same source text was already rendered two ways by a reviewed locale, so the English is ambiguous |
101
+ | `collision` | the fallback when no reviewed locale can tell: one source text came back as two translations here |
102
+ | `echo` | still identical to the source, while every reviewed locale did translate it |
103
+ | `digits` | a number the source has came back changed, ignoring decimal separators and trailing punctuation |
104
+ | `url` | a URL changed |
105
+ | `code` | the contents of a `<code>` span changed |
106
+
107
+ A locale that still carries drafts is never used as the reviewed control, and neither is a blank:
108
+ an unfinished translation is not a second opinion.
109
+
110
+ **Every signal here was measured against this project's own site before it shipped, and two of them
111
+ changed because of what the measurement said.** `digits` was reading the comma after a number as
112
+ part of it, and it was flagging a number the translation added where the source had none, which is
113
+ what "refresh" becoming "F5" looks like. Those were **10 of 10** of its hits, all correct text, so
114
+ both are now excluded. The result is **18 of 1425 messages** flagged per language.
115
+
116
+ ## 🧩 Programmatic API
117
+
118
+ ```ts
119
+ import { startStudio } from '@verbaly/studio';
120
+
121
+ const studio = await startStudio(await loadConfig(process.cwd()), { port: 0 });
122
+ console.log(studio.url);
123
+ await studio.close();
124
+ ```
125
+
126
+ `startStudio(cfg, options)` returns `{ server, port, token, url, close }`, and
127
+ `createStudioApp(cfg, port, token)` is the bare request handler for mounting it somewhere else.
128
+ Nothing else is public.
129
+
130
+ ## License
131
+
132
+ [MIT](https://github.com/AronSoto/verbaly/blob/develop/LICENSE) © Aron Soto
package/dist/cli.js ADDED
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { DRAFTS_FILE, check, clearDrafts, collectOrigins, counted, doctor, effectiveDrafts, extractProject, formatCliError, loadConfig, loadDrafts, readCatalog, saveDrafts, status, targetLocales, validateMessage, validatePair, writeCatalog } from "@verbaly/compiler";
4
+ import { existsSync } from "node:fs";
5
+ import { relative } from "node:path";
6
+ import { createServer } from "node:http";
7
+ import { randomBytes, timingSafeEqual } from "node:crypto";
8
+ //#region src/http.ts
9
+ var HttpError = class extends Error {
10
+ status;
11
+ constructor(status, message) {
12
+ super(message.startsWith("[verbaly]") ? message : `[verbaly] ${message}`);
13
+ this.status = status;
14
+ this.name = "HttpError";
15
+ }
16
+ };
17
+ const badRequest = (message) => new HttpError(400, message);
18
+ function scrub(message, root) {
19
+ return root ? message.split(root).join(".") : message;
20
+ }
21
+ //#endregion
22
+ //#region src/triage.ts
23
+ const DIGITS = /\d[\d.,]*/g;
24
+ const URLS = /https?:\/\/[^\s<)"']+/g;
25
+ const CODE = /<code>([^<]+)<\/code>/g;
26
+ function bag(text, re, normalize) {
27
+ const found = text.match(re) ?? [];
28
+ return (normalize ? found.map(normalize) : found).sort();
29
+ }
30
+ function same(a, b) {
31
+ return a.length === b.length && a.every((v, i) => v === b[i]);
32
+ }
33
+ const trimPunct = (v) => v.replace(/[.,;:!?)\]]+$/, "");
34
+ const bareDigits = (v) => trimPunct(v).replaceAll(/[.,]/g, ".");
35
+ function push(out, key, reason) {
36
+ (out[key] ??= []).push(reason);
37
+ }
38
+ function triage({ source, target, reviewed }) {
39
+ const out = Object.create(null);
40
+ const keys = Object.keys(source);
41
+ const bySource = Object.create(null);
42
+ for (const key of keys) {
43
+ const text = source[key]?.trim();
44
+ if (text) (bySource[text] ??= []).push(key);
45
+ }
46
+ for (const [text, group] of Object.entries(bySource)) {
47
+ if (group.length < 2) continue;
48
+ const divergent = reviewed.some((catalog) => new Set(group.map((k) => catalog[k]?.trim() ?? "").filter(Boolean)).size > 1);
49
+ if (divergent) for (const key of group) push(out, key, {
50
+ signal: "divergent",
51
+ text: `"${text}" was already translated two ways`
52
+ });
53
+ const targets = new Set(group.map((key) => target[key]?.trim() ?? "").filter(Boolean));
54
+ if (!divergent && targets.size > 1) for (const key of group) push(out, key, {
55
+ signal: "collision",
56
+ text: `"${text}" came back as two different translations`
57
+ });
58
+ }
59
+ for (const key of keys) {
60
+ const src = source[key]?.trim() ?? "";
61
+ const trg = target[key]?.trim() ?? "";
62
+ if (!src || !trg) continue;
63
+ const opinions = reviewed.map((c) => c[key]?.trim()).filter((v) => v !== void 0 && v !== "");
64
+ if (trg === src && opinions.length > 0 && opinions.every((v) => v !== src)) push(out, key, {
65
+ signal: "echo",
66
+ text: "still identical to the source"
67
+ });
68
+ const srcDigits = bag(src, DIGITS, bareDigits);
69
+ if (srcDigits.length > 0 && !same(srcDigits, bag(trg, DIGITS, bareDigits))) push(out, key, {
70
+ signal: "digits",
71
+ text: "the numbers do not match the source"
72
+ });
73
+ if (!same(bag(src, URLS, trimPunct), bag(trg, URLS, trimPunct))) push(out, key, {
74
+ signal: "url",
75
+ text: "a URL changed"
76
+ });
77
+ if (!same(bag(src, CODE), bag(trg, CODE))) push(out, key, {
78
+ signal: "code",
79
+ text: "the contents of a <code> span changed"
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+ //#endregion
85
+ //#region src/api.ts
86
+ function readAll(cfg) {
87
+ const catalogs = {};
88
+ const problems = [];
89
+ if (!existsSync(cfg.dir)) {
90
+ const dir = relative(cfg.root, cfg.dir).replaceAll("\\", "/");
91
+ problems.push({
92
+ scope: dir,
93
+ message: "[verbaly] no catalogs here, run npx verbaly init or check --root"
94
+ });
95
+ }
96
+ for (const locale of cfg.locales) try {
97
+ catalogs[locale] = readCatalog(cfg, locale);
98
+ } catch (error) {
99
+ catalogs[locale] = {};
100
+ problems.push({
101
+ scope: locale,
102
+ message: error instanceof Error ? error.message : String(error)
103
+ });
104
+ }
105
+ return {
106
+ catalogs,
107
+ problems
108
+ };
109
+ }
110
+ function readDrafts(cfg) {
111
+ try {
112
+ return {
113
+ drafts: loadDrafts(cfg),
114
+ problems: []
115
+ };
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ return {
119
+ drafts: {},
120
+ problems: [{
121
+ scope: DRAFTS_FILE,
122
+ message
123
+ }]
124
+ };
125
+ }
126
+ }
127
+ function relativize(cfg, result) {
128
+ return {
129
+ ...result,
130
+ unknown: result.unknown.map((entry) => ({
131
+ ...entry,
132
+ files: entry.files.map((file) => relative(cfg.root, file).replaceAll("\\", "/"))
133
+ }))
134
+ };
135
+ }
136
+ async function buildState(cfg) {
137
+ const { catalogs, problems } = readAll(cfg);
138
+ const registry = await extractProject(cfg);
139
+ const sidecar = readDrafts(cfg);
140
+ problems.push(...sidecar.problems);
141
+ const live = effectiveDrafts(sidecar.drafts, catalogs);
142
+ const source = catalogs[cfg.sourceLocale] ?? {};
143
+ const targets = targetLocales(cfg);
144
+ for (const { file, message } of registry.parseErrors()) problems.push({
145
+ scope: relative(cfg.root, file).replaceAll("\\", "/"),
146
+ message
147
+ });
148
+ const perLocale = {};
149
+ for (const locale of targets) perLocale[locale] = triage({
150
+ source,
151
+ target: catalogs[locale] ?? {},
152
+ reviewed: targets.filter((other) => other !== locale && !(live[other] ?? []).length).map((other) => catalogs[other] ?? {})
153
+ });
154
+ return {
155
+ root: cfg.root,
156
+ dir: cfg.dir,
157
+ sourceLocale: cfg.sourceLocale,
158
+ locales: cfg.locales,
159
+ scanning: cfg.include.length > 0,
160
+ catalogs,
161
+ origins: await collectOrigins(cfg, registry),
162
+ status: status(cfg, catalogs, registry, live),
163
+ check: relativize(cfg, check(cfg, catalogs, registry)),
164
+ drafts: live,
165
+ triage: perLocale,
166
+ problems
167
+ };
168
+ }
169
+ function health(cfg) {
170
+ return doctor(cfg);
171
+ }
172
+ function writeMessage(cfg, locale, key, text) {
173
+ if (locale === cfg.sourceLocale) throw badRequest("the source text lives in your code, Studio never edits it");
174
+ if (!cfg.locales.includes(locale)) throw badRequest(`${locale} is not one of this project's locales`);
175
+ const source = readCatalog(cfg, cfg.sourceLocale);
176
+ if (!Object.hasOwn(source, key)) throw badRequest(`"${key}" is not in the source catalog, so Studio will not create it`);
177
+ const value = text.trim() ? text : "";
178
+ const errors = (value ? [...validateMessage(value, locale), ...validatePair(source[key], value)] : []).filter((issue) => issue.severity === "error");
179
+ if (errors.length) throw badRequest(`${errors.map((issue) => issue.message).join("; ")}`);
180
+ const catalog = {
181
+ ...readCatalog(cfg, locale),
182
+ [key]: value
183
+ };
184
+ writeCatalog(cfg, locale, catalog);
185
+ const drafts = loadDrafts(cfg);
186
+ const clearedDraft = (drafts[locale] ?? []).includes(key);
187
+ clearDrafts(drafts, locale, [key]);
188
+ saveDrafts(cfg, drafts);
189
+ return {
190
+ locale,
191
+ key,
192
+ clearedDraft
193
+ };
194
+ }
195
+ function approve(cfg, locale, keys) {
196
+ if (!cfg.locales.includes(locale)) throw badRequest(`${locale} is not one of this project's locales`);
197
+ const drafts = loadDrafts(cfg);
198
+ const before = (drafts[locale] ?? []).length;
199
+ clearDrafts(drafts, locale, keys);
200
+ const after = (drafts[locale] ?? []).length;
201
+ saveDrafts(cfg, drafts);
202
+ return {
203
+ locale,
204
+ approved: before - after
205
+ };
206
+ }
207
+ //#endregion
208
+ //#region src/guard.ts
209
+ function newToken() {
210
+ return randomBytes(9).toString("base64url");
211
+ }
212
+ function hostAllowed(host, port) {
213
+ const names = port === 80 ? ["127.0.0.1", "localhost"] : [];
214
+ return [
215
+ `127.0.0.1:${port}`,
216
+ `localhost:${port}`,
217
+ ...names
218
+ ].includes(host ?? "");
219
+ }
220
+ function originAllowed(origin, port) {
221
+ if (origin === void 0) return true;
222
+ return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
223
+ }
224
+ function tokenMatches(given, expected) {
225
+ if (given === null) return false;
226
+ const a = Buffer.from(given);
227
+ const b = Buffer.from(expected);
228
+ return a.length === b.length && timingSafeEqual(a, b);
229
+ }
230
+ const PASS = {
231
+ ok: true,
232
+ status: 200,
233
+ reason: ""
234
+ };
235
+ function guardRequest(headers, url, { port, token }) {
236
+ if (!hostAllowed(headers.host, port)) return {
237
+ ok: false,
238
+ status: 403,
239
+ reason: "[verbaly] unexpected Host header"
240
+ };
241
+ if (!originAllowed(headers.origin, port)) return {
242
+ ok: false,
243
+ status: 403,
244
+ reason: "[verbaly] cross-origin request"
245
+ };
246
+ if (!tokenMatches(url.searchParams.get("t"), token)) return {
247
+ ok: false,
248
+ status: 401,
249
+ reason: "[verbaly] missing or wrong token, open the printed url"
250
+ };
251
+ return PASS;
252
+ }
253
+ //#endregion
254
+ //#region src/server.ts
255
+ const DEFAULT_PORT = 4747;
256
+ const MAX_BODY = 1e6;
257
+ function send(res, status, body) {
258
+ res.writeHead(status, {
259
+ "content-type": "application/json; charset=utf-8",
260
+ "cache-control": "no-store"
261
+ });
262
+ res.end(JSON.stringify(body));
263
+ }
264
+ async function readJson(req) {
265
+ const chunks = [];
266
+ let size = 0;
267
+ for await (const chunk of req) {
268
+ size += chunk.length;
269
+ if (size > MAX_BODY) throw new HttpError(413, "request body too large");
270
+ chunks.push(chunk);
271
+ }
272
+ if (!chunks.length) return {};
273
+ let parsed;
274
+ try {
275
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
276
+ } catch {
277
+ throw badRequest("body is not valid JSON");
278
+ }
279
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw badRequest("body must be a JSON object");
280
+ return parsed;
281
+ }
282
+ function segment(raw, what) {
283
+ let value;
284
+ try {
285
+ value = decodeURIComponent(raw ?? "");
286
+ } catch {
287
+ throw badRequest(`the ${what} in the url is not valid percent-encoding`);
288
+ }
289
+ if (!value) throw badRequest(`the url is missing a ${what}`);
290
+ return value;
291
+ }
292
+ function createStudioApp(cfg, port, token) {
293
+ return async function handle(req, res) {
294
+ try {
295
+ let url;
296
+ try {
297
+ url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
298
+ } catch {
299
+ throw badRequest("the request target is not a valid url");
300
+ }
301
+ const verdict = guardRequest({
302
+ host: req.headers.host,
303
+ origin: req.headers.origin
304
+ }, url, {
305
+ port,
306
+ token
307
+ });
308
+ if (!verdict.ok) {
309
+ send(res, verdict.status, { error: verdict.reason });
310
+ return;
311
+ }
312
+ if (req.method === "GET" && url.pathname === "/api/state") {
313
+ send(res, 200, await buildState(cfg));
314
+ return;
315
+ }
316
+ if (req.method === "GET" && url.pathname === "/api/health") {
317
+ send(res, 200, await health(cfg));
318
+ return;
319
+ }
320
+ if (req.method === "PUT" && url.pathname.startsWith("/api/message/")) {
321
+ const rest = url.pathname.slice(13).split("/");
322
+ const locale = segment(rest[0], "locale");
323
+ const key = segment(rest.slice(1).join("/"), "key");
324
+ const body = await readJson(req);
325
+ if (typeof body.text !== "string") throw badRequest("body needs a \"text\" string");
326
+ send(res, 200, writeMessage(cfg, locale, key, body.text));
327
+ return;
328
+ }
329
+ if (req.method === "POST" && url.pathname === "/api/approve") {
330
+ const body = await readJson(req);
331
+ if (typeof body.locale !== "string") throw badRequest("body needs a \"locale\" string");
332
+ const keys = Array.isArray(body.keys) ? body.keys.filter((k) => typeof k === "string") : void 0;
333
+ send(res, 200, approve(cfg, body.locale, keys));
334
+ return;
335
+ }
336
+ send(res, 404, { error: `[verbaly] no route for ${req.method} ${url.pathname}` });
337
+ } catch (error) {
338
+ send(res, error instanceof HttpError ? error.status : 500, { error: scrub(error instanceof Error ? error.message : String(error), cfg.root) });
339
+ }
340
+ };
341
+ }
342
+ function listen(server, port, attemptsLeft) {
343
+ return new Promise((resolve, reject) => {
344
+ const onError = (error) => {
345
+ if (error.code === "EADDRINUSE" && attemptsLeft > 0) {
346
+ server.removeListener("error", onError);
347
+ resolve(listen(server, port + 1, attemptsLeft - 1));
348
+ return;
349
+ }
350
+ reject(error);
351
+ };
352
+ server.once("error", onError);
353
+ server.listen(port, "127.0.0.1", () => {
354
+ server.removeListener("error", onError);
355
+ const bound = server.address();
356
+ resolve(typeof bound === "object" && bound !== null ? bound.port : port);
357
+ });
358
+ });
359
+ }
360
+ async function startStudio(cfg, options = {}) {
361
+ const token = options.token ?? newToken();
362
+ const server = createServer();
363
+ let port = options.port ?? DEFAULT_PORT;
364
+ server.on("request", (req, res) => void createStudioApp(cfg, port, token)(req, res));
365
+ port = await listen(server, port, 20);
366
+ return {
367
+ server,
368
+ port,
369
+ token,
370
+ url: `http://127.0.0.1:${port}/api/state?t=${token}`,
371
+ close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
372
+ };
373
+ }
374
+ //#endregion
375
+ //#region src/cli.ts
376
+ try {
377
+ const { values } = parseArgs({ options: {
378
+ root: { type: "string" },
379
+ port: { type: "string" },
380
+ json: { type: "boolean" }
381
+ } });
382
+ const port = values.port === void 0 ? void 0 : Number(values.port);
383
+ if (port !== void 0 && !Number.isInteger(port)) throw new Error(`[verbaly] --port needs a number, got "${values.port}"`);
384
+ const cfg = await loadConfig(values.root ?? process.cwd());
385
+ if (values.json) console.log(JSON.stringify(await buildState(cfg), null, 2));
386
+ else {
387
+ const studio = await startStudio(cfg, { port });
388
+ const state = await buildState(cfg);
389
+ const missing = state.status.locales.reduce((n, l) => n + (l.total - l.translated), 0);
390
+ const drafts = Object.values(state.drafts).reduce((n, keys) => n + keys.length, 0);
391
+ console.log(`\n Verbaly Studio ${studio.url}`);
392
+ console.log(` Project ${cfg.root}`);
393
+ console.log(` Catalog ${counted(state.status.messages, "message")} · ${missing} untranslated · ${counted(drafts, "draft")}`);
394
+ for (const problem of state.problems) console.log(` ! ${problem.scope}: ${problem.message}`);
395
+ console.log("\n Ctrl+C to stop\n");
396
+ }
397
+ } catch (error) {
398
+ console.error(formatCliError(error));
399
+ process.exitCode = 1;
400
+ }
401
+ //#endregion
402
+ export {};
403
+
404
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/http.ts","../src/triage.ts","../src/api.ts","../src/guard.ts","../src/server.ts","../src/cli.ts"],"sourcesContent":["// A client mistake is not a server fault, so the status rides with the error, not a blanket 500.\nexport class HttpError extends Error {\n readonly status: number;\n\n constructor(status: number, message: string) {\n super(message.startsWith('[verbaly]') ? message : `[verbaly] ${message}`);\n this.status = status;\n this.name = 'HttpError';\n }\n}\n\nexport const badRequest = (message: string) => new HttpError(400, message);\n\n// The absolute path of someone's project is not the browser's business.\nexport function scrub(message: string, root: string): string {\n return root ? message.split(root).join('.') : message;\n}\n","import type { Catalog } from '@verbaly/compiler';\n\nexport type TriageSignal = 'divergent' | 'collision' | 'echo' | 'digits' | 'code' | 'url';\n\nexport interface TriageReason {\n signal: TriageSignal;\n text: string;\n}\n\nexport type TriageResult = Record<string, TriageReason[]>;\n\n// check() already caught the structure, so these only point at the few worth reading first.\nexport interface TriageInput {\n source: Catalog;\n target: Catalog;\n reviewed: Catalog[];\n}\n\nconst DIGITS = /\\d[\\d.,]*/g;\nconst URLS = /https?:\\/\\/[^\\s<)\"']+/g;\nconst CODE = /<code>([^<]+)<\\/code>/g;\n\nfunction bag(text: string, re: RegExp, normalize?: (v: string) => string): string[] {\n const found = text.match(re) ?? [];\n return (normalize ? found.map(normalize) : found).sort();\n}\n\nfunction same(a: string[], b: string[]): boolean {\n return a.length === b.length && a.every((v, i) => v === b[i]);\n}\n\n// A number or a url at the end of a clause takes the punctuation with it, and that is not a signal.\nconst trimPunct = (v: string) => v.replace(/[.,;:!?)\\]]+$/, '');\n// A separator is idiomatic (3.09 is 3,09 in Spanish); canonizing keeps 1.5 apart from 15.\nconst bareDigits = (v: string) => trimPunct(v).replaceAll(/[.,]/g, '.');\n\nfunction push(out: TriageResult, key: string, reason: TriageReason): void {\n (out[key] ??= []).push(reason);\n}\n\nexport function triage({ source, target, reviewed }: TriageInput): TriageResult {\n // null-prototype: a source text of \"__proto__\" or \"toString\" is a string, not a member of Object\n const out: TriageResult = Object.create(null);\n const keys = Object.keys(source);\n\n // One source text that two humans rendered two ways is ambiguous, so the machine had no chance.\n const bySource: Record<string, string[]> = Object.create(null);\n for (const key of keys) {\n const text = source[key]?.trim();\n if (text) (bySource[text] ??= []).push(key);\n }\n for (const [text, group] of Object.entries(bySource)) {\n if (group.length < 2) continue;\n const divergent = reviewed.some(\n (catalog) => new Set(group.map((k) => catalog[k]?.trim() ?? '').filter(Boolean)).size > 1,\n );\n if (divergent) {\n for (const key of group) {\n push(out, key, { signal: 'divergent', text: `\"${text}\" was already translated two ways` });\n }\n }\n // The same ambiguity seen from the result instead of from the history.\n const targets = new Set(group.map((key) => target[key]?.trim() ?? '').filter(Boolean));\n if (!divergent && targets.size > 1) {\n for (const key of group) {\n push(out, key, { signal: 'collision', text: `\"${text}\" came back as two different translations` });\n }\n }\n }\n\n for (const key of keys) {\n const src = source[key]?.trim() ?? '';\n const trg = target[key]?.trim() ?? '';\n if (!src || !trg) continue;\n\n // Only the locales with an opinion on this key vote: alone it fires on every do-not-translate.\n const opinions = reviewed.map((c) => c[key]?.trim()).filter((v) => v !== undefined && v !== '');\n if (trg === src && opinions.length > 0 && opinions.every((v) => v !== src)) {\n push(out, key, { signal: 'echo', text: 'still identical to the source' });\n }\n\n // a translation that adds a number is idiomatic (\"refresh\" is \"F5\"); one that changes it is not\n const srcDigits = bag(src, DIGITS, bareDigits);\n if (srcDigits.length > 0 && !same(srcDigits, bag(trg, DIGITS, bareDigits))) {\n push(out, key, { signal: 'digits', text: 'the numbers do not match the source' });\n }\n if (!same(bag(src, URLS, trimPunct), bag(trg, URLS, trimPunct))) {\n push(out, key, { signal: 'url', text: 'a URL changed' });\n }\n if (!same(bag(src, CODE), bag(trg, CODE))) {\n push(out, key, { signal: 'code', text: 'the contents of a <code> span changed' });\n }\n }\n\n return out;\n}\n","import { existsSync } from 'node:fs';\nimport { relative } from 'node:path';\nimport {\n check,\n clearDrafts,\n collectOrigins,\n doctor,\n effectiveDrafts,\n extractProject,\n loadDrafts,\n readCatalog,\n saveDrafts,\n status,\n targetLocales,\n validateMessage,\n validatePair,\n writeCatalog,\n} from '@verbaly/compiler';\nimport { DRAFTS_FILE } from '@verbaly/compiler';\nimport type {\n Catalog,\n Catalogs,\n CheckResult,\n DoctorResult,\n Drafts,\n ResolvedConfig,\n StatusResult,\n} from '@verbaly/compiler';\nimport { badRequest } from './http';\nimport { triage, type TriageResult } from './triage';\n\nexport interface StudioProblem {\n scope: string;\n message: string;\n}\n\nexport interface StudioState {\n root: string;\n dir: string;\n sourceLocale: string;\n locales: string[];\n scanning: boolean;\n catalogs: Catalogs;\n origins: Record<string, string[]>;\n status: StatusResult;\n check: CheckResult;\n drafts: Drafts;\n triage: Record<string, TriageResult>;\n problems: StudioProblem[];\n}\n\n// A locale nobody can parse must not take the panel down: Studio is what you open to fix it.\nfunction readAll(cfg: ResolvedConfig): { catalogs: Catalogs; problems: StudioProblem[] } {\n const catalogs: Catalogs = {};\n const problems: StudioProblem[] = [];\n // an empty green panel and a wrong --root look identical, and only this line tells them apart\n if (!existsSync(cfg.dir)) {\n const dir = relative(cfg.root, cfg.dir).replaceAll('\\\\', '/');\n problems.push({ scope: dir, message: '[verbaly] no catalogs here, run npx verbaly init or check --root' });\n }\n for (const locale of cfg.locales) {\n try {\n catalogs[locale] = readCatalog(cfg, locale);\n } catch (error) {\n catalogs[locale] = {};\n problems.push({\n scope: locale,\n message: error instanceof Error ? error.message : String(error),\n });\n }\n }\n return { catalogs, problems };\n}\n\n// A sidecar nobody can parse is the other file Studio exists to fix, so it degrades like a catalog.\nfunction readDrafts(cfg: ResolvedConfig): { drafts: Drafts; problems: StudioProblem[] } {\n try {\n return { drafts: loadDrafts(cfg), problems: [] };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return { drafts: {}, problems: [{ scope: DRAFTS_FILE, message }] };\n }\n}\n\n// check() reports the file a stray key was used in as an absolute path, origins already do not.\nfunction relativize(cfg: ResolvedConfig, result: CheckResult): CheckResult {\n return {\n ...result,\n unknown: result.unknown.map((entry) => ({\n ...entry,\n files: entry.files.map((file) => relative(cfg.root, file).replaceAll('\\\\', '/')),\n })),\n };\n}\n\n// One shot and unpaginated: the biggest catalog we know is 1425 messages, instant over localhost.\nexport async function buildState(cfg: ResolvedConfig): Promise<StudioState> {\n const { catalogs, problems } = readAll(cfg);\n const registry = await extractProject(cfg);\n const sidecar = readDrafts(cfg);\n problems.push(...sidecar.problems);\n const live = effectiveDrafts(sidecar.drafts, catalogs);\n const source = (catalogs[cfg.sourceLocale] ?? {}) as Catalog;\n const targets = targetLocales(cfg);\n\n for (const { file, message } of registry.parseErrors()) {\n problems.push({ scope: relative(cfg.root, file).replaceAll('\\\\', '/'), message });\n }\n\n const perLocale: Record<string, TriageResult> = {};\n for (const locale of targets) {\n perLocale[locale] = triage({\n source,\n target: (catalogs[locale] ?? {}) as Catalog,\n // a locale still carrying drafts has not been reviewed, so it cannot be the control\n reviewed: targets\n .filter((other) => other !== locale && !(live[other] ?? []).length)\n .map((other) => (catalogs[other] ?? {}) as Catalog),\n });\n }\n\n return {\n root: cfg.root,\n dir: cfg.dir,\n sourceLocale: cfg.sourceLocale,\n locales: cfg.locales,\n // with include: [] nothing is scanned, so an empty origins map is off, not \"none found\"\n scanning: cfg.include.length > 0,\n catalogs,\n origins: await collectOrigins(cfg, registry),\n status: status(cfg, catalogs, registry, live),\n check: relativize(cfg, check(cfg, catalogs, registry)),\n drafts: live,\n triage: perLocale,\n problems,\n };\n}\n\nexport function health(cfg: ResolvedConfig): Promise<DoctorResult> {\n return doctor(cfg);\n}\n\nexport interface WriteMessageResult {\n locale: string;\n key: string;\n clearedDraft: boolean;\n}\n\n// A human wrote it, so it is reviewed: the rule import already applies in run.ts:447.\nexport function writeMessage(\n cfg: ResolvedConfig,\n locale: string,\n key: string,\n text: string,\n): WriteMessageResult {\n if (locale === cfg.sourceLocale) {\n throw badRequest('the source text lives in your code, Studio never edits it');\n }\n if (!cfg.locales.includes(locale)) {\n throw badRequest(`${locale} is not one of this project's locales`);\n }\n const source = readCatalog(cfg, cfg.sourceLocale);\n // hasOwn, not `in`: `in` walks the prototype, so toString and __proto__ would pass this guard\n if (!Object.hasOwn(source, key)) {\n throw badRequest(`\"${key}\" is not in the source catalog, so Studio will not create it`);\n }\n // '' is untranslated everywhere else, so whitespace is that and not a translation to validate\n const value = text.trim() ? text : '';\n // the same two calls check() makes: validatePair alone misses a plural block with no other case\n const issues = value\n ? [...validateMessage(value, locale), ...validatePair(source[key] as string, value)]\n : [];\n const errors = issues.filter((issue) => issue.severity === 'error');\n if (errors.length) {\n throw badRequest(`${errors.map((issue) => issue.message).join('; ')}`);\n }\n\n const catalog = { ...readCatalog(cfg, locale), [key]: value };\n writeCatalog(cfg, locale, catalog);\n\n const drafts = loadDrafts(cfg);\n const clearedDraft = (drafts[locale] ?? []).includes(key);\n clearDrafts(drafts, locale, [key]);\n saveDrafts(cfg, drafts);\n return { locale, key, clearedDraft };\n}\n\nexport interface ApproveResult {\n locale: string;\n approved: number;\n}\n\n// The sidecar is re-read here: saveDrafts writes every locale, so a stale copy drops another's.\nexport function approve(cfg: ResolvedConfig, locale: string, keys?: string[]): ApproveResult {\n if (!cfg.locales.includes(locale)) {\n throw badRequest(`${locale} is not one of this project's locales`);\n }\n const drafts = loadDrafts(cfg);\n const before = (drafts[locale] ?? []).length;\n clearDrafts(drafts, locale, keys);\n const after = (drafts[locale] ?? []).length;\n saveDrafts(cfg, drafts);\n return { locale, approved: before - after };\n}\n","import { randomBytes, timingSafeEqual } from 'node:crypto';\n\nexport interface GuardOptions {\n port: number;\n token: string;\n}\n\n// A local server that writes files is reachable from any page the browser has open.\nexport function newToken(): string {\n return randomBytes(9).toString('base64url');\n}\n\n// Only the two names that resolve to this machine. A DNS rebind arrives with a foreign Host.\nexport function hostAllowed(host: string | undefined, port: number): boolean {\n // a browser omits the port when it is the scheme default, so :80 would 403 every real request\n const names = port === 80 ? ['127.0.0.1', 'localhost'] : [];\n return [`127.0.0.1:${port}`, `localhost:${port}`, ...names].includes(host ?? '');\n}\n\n// No Origin at all is a same-origin navigation or a curl, both fine; a foreign one never is.\nexport function originAllowed(origin: string | undefined, port: number): boolean {\n if (origin === undefined) return true;\n return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;\n}\n\n// Compared in constant time so the token cannot be guessed one character at a time.\nexport function tokenMatches(given: string | null, expected: string): boolean {\n if (given === null) return false;\n const a = Buffer.from(given);\n const b = Buffer.from(expected);\n return a.length === b.length && timingSafeEqual(a, b);\n}\n\nexport interface GuardVerdict {\n ok: boolean;\n status: number;\n reason: string;\n}\n\nconst PASS: GuardVerdict = { ok: true, status: 200, reason: '' };\n\nexport function guardRequest(\n headers: { host?: string; origin?: string },\n url: URL,\n { port, token }: GuardOptions,\n): GuardVerdict {\n if (!hostAllowed(headers.host, port)) {\n return { ok: false, status: 403, reason: '[verbaly] unexpected Host header' };\n }\n if (!originAllowed(headers.origin, port)) {\n return { ok: false, status: 403, reason: '[verbaly] cross-origin request' };\n }\n if (!tokenMatches(url.searchParams.get('t'), token)) {\n return { ok: false, status: 401, reason: '[verbaly] missing or wrong token, open the printed url' };\n }\n return PASS;\n}\n","import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';\nimport type { AddressInfo } from 'node:net';\nimport type { ResolvedConfig } from '@verbaly/compiler';\nimport { approve, buildState, health, writeMessage } from './api';\nimport { guardRequest, newToken } from './guard';\nimport { HttpError, badRequest, scrub } from './http';\n\nexport interface StudioServer {\n server: Server;\n port: number;\n token: string;\n url: string;\n close: () => Promise<void>;\n}\n\nexport interface StartOptions {\n port?: number;\n token?: string;\n}\n\nconst DEFAULT_PORT = 4747;\nconst MAX_BODY = 1_000_000;\n\nfunction send(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n 'content-type': 'application/json; charset=utf-8',\n 'cache-control': 'no-store',\n });\n res.end(JSON.stringify(body));\n}\n\nasync function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {\n const chunks: Buffer[] = [];\n let size = 0;\n for await (const chunk of req) {\n size += (chunk as Buffer).length;\n if (size > MAX_BODY) throw new HttpError(413, 'request body too large');\n chunks.push(chunk as Buffer);\n }\n if (!chunks.length) return {};\n let parsed: unknown;\n try {\n parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));\n } catch {\n throw badRequest('body is not valid JSON');\n }\n // null and an array both pass typeof 'object', and reading .text off them is a 500\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw badRequest('body must be a JSON object');\n }\n return parsed as Record<string, unknown>;\n}\n\nfunction segment(raw: string | undefined, what: string): string {\n let value: string;\n try {\n value = decodeURIComponent(raw ?? '');\n } catch {\n throw badRequest(`the ${what} in the url is not valid percent-encoding`);\n }\n if (!value) throw badRequest(`the url is missing a ${what}`);\n return value;\n}\n\n// One page, JSON on every route: a framework would only add a dependency to a local tool.\nexport function createStudioApp(cfg: ResolvedConfig, port: number, token: string) {\n return async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {\n // The url is built inside the try: `GET //` throws, and out here that would kill the process.\n try {\n let url: URL;\n try {\n url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);\n } catch {\n throw badRequest('the request target is not a valid url');\n }\n\n const verdict = guardRequest({ host: req.headers.host, origin: req.headers.origin }, url, {\n port,\n token,\n });\n if (!verdict.ok) {\n send(res, verdict.status, { error: verdict.reason });\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/state') {\n send(res, 200, await buildState(cfg));\n return;\n }\n if (req.method === 'GET' && url.pathname === '/api/health') {\n send(res, 200, await health(cfg));\n return;\n }\n if (req.method === 'PUT' && url.pathname.startsWith('/api/message/')) {\n const rest = url.pathname.slice('/api/message/'.length).split('/');\n const locale = segment(rest[0], 'locale');\n const key = segment(rest.slice(1).join('/'), 'key');\n const body = await readJson(req);\n if (typeof body.text !== 'string') throw badRequest('body needs a \"text\" string');\n send(res, 200, writeMessage(cfg, locale, key, body.text));\n return;\n }\n if (req.method === 'POST' && url.pathname === '/api/approve') {\n const body = await readJson(req);\n if (typeof body.locale !== 'string') throw badRequest('body needs a \"locale\" string');\n const keys = Array.isArray(body.keys)\n ? body.keys.filter((k): k is string => typeof k === 'string')\n : undefined;\n send(res, 200, approve(cfg, body.locale, keys));\n return;\n }\n send(res, 404, { error: `[verbaly] no route for ${req.method} ${url.pathname}` });\n } catch (error) {\n const status = error instanceof HttpError ? error.status : 500;\n const message = error instanceof Error ? error.message : String(error);\n send(res, status, { error: scrub(message, cfg.root) });\n }\n };\n}\n\n// A busy port climbs instead of failing: this is a local tool, not a service on a fixed address.\nfunction listen(server: Server, port: number, attemptsLeft: number): Promise<number> {\n return new Promise((resolve, reject) => {\n const onError = (error: NodeJS.ErrnoException) => {\n if (error.code === 'EADDRINUSE' && attemptsLeft > 0) {\n server.removeListener('error', onError);\n resolve(listen(server, port + 1, attemptsLeft - 1));\n return;\n }\n reject(error);\n };\n server.once('error', onError);\n server.listen(port, '127.0.0.1', () => {\n server.removeListener('error', onError);\n // port 0 asks the system to pick, so the bound address is the only place the answer is\n const bound = server.address() as AddressInfo | string | null;\n resolve(typeof bound === 'object' && bound !== null ? bound.port : port);\n });\n });\n}\n\nexport async function startStudio(\n cfg: ResolvedConfig,\n options: StartOptions = {},\n): Promise<StudioServer> {\n const token = options.token ?? newToken();\n const server = createServer();\n // The handler is attached before listen so no request can arrive without one.\n let port = options.port ?? DEFAULT_PORT;\n server.on('request', (req, res) => void createStudioApp(cfg, port, token)(req, res));\n port = await listen(server, port, 20);\n\n return {\n server,\n port,\n token,\n url: `http://127.0.0.1:${port}/api/state?t=${token}`,\n close: () =>\n new Promise<void>((resolve, reject) =>\n server.close((error) => (error ? reject(error) : resolve())),\n ),\n };\n}\n","#!/usr/bin/env node\nimport { parseArgs } from 'node:util';\nimport { counted, formatCliError, loadConfig } from '@verbaly/compiler';\nimport { buildState } from './api';\nimport { startStudio } from './server';\n\ntry {\n const { values } = parseArgs({\n options: {\n root: { type: 'string' },\n port: { type: 'string' },\n json: { type: 'boolean' },\n },\n });\n const port = values.port === undefined ? undefined : Number(values.port);\n if (port !== undefined && !Number.isInteger(port)) {\n throw new Error(`[verbaly] --port needs a number, got \"${values.port}\"`);\n }\n const cfg = await loadConfig(values.root ?? process.cwd());\n\n // --json is the state the interface reads, on stdout, before any interface exists.\n if (values.json) {\n console.log(JSON.stringify(await buildState(cfg), null, 2));\n } else {\n const studio = await startStudio(cfg, { port });\n const state = await buildState(cfg);\n const missing = state.status.locales.reduce((n, l) => n + (l.total - l.translated), 0);\n const drafts = Object.values(state.drafts).reduce((n, keys) => n + keys.length, 0);\n console.log(`\\n Verbaly Studio ${studio.url}`);\n console.log(` Project ${cfg.root}`);\n console.log(\n ` Catalog ${counted(state.status.messages, 'message')} · ` +\n `${missing} untranslated · ${counted(drafts, 'draft')}`,\n );\n for (const problem of state.problems) console.log(` ! ${problem.scope}: ${problem.message}`);\n console.log('\\n Ctrl+C to stop\\n');\n }\n} catch (error) {\n console.error(formatCliError(error));\n process.exitCode = 1;\n}\n"],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import { IncomingMessage, Server, ServerResponse } from "node:http";
2
+ import { ResolvedConfig } from "@verbaly/compiler";
3
+ //#region src/server.d.ts
4
+ interface StudioServer {
5
+ server: Server;
6
+ port: number;
7
+ token: string;
8
+ url: string;
9
+ close: () => Promise<void>;
10
+ }
11
+ interface StartOptions {
12
+ port?: number;
13
+ token?: string;
14
+ }
15
+ export declare function createStudioApp(cfg: ResolvedConfig, port: number, token: string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
16
+ export declare function startStudio(cfg: ResolvedConfig, options?: StartOptions): Promise<StudioServer>;
17
+ //#endregion
18
+ export type { StartOptions, StudioServer };
19
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,375 @@
1
+ import { createServer } from "node:http";
2
+ import { existsSync } from "node:fs";
3
+ import { relative } from "node:path";
4
+ import { DRAFTS_FILE, check, clearDrafts, collectOrigins, doctor, effectiveDrafts, extractProject, loadDrafts, readCatalog, saveDrafts, status, targetLocales, validateMessage, validatePair, writeCatalog } from "@verbaly/compiler";
5
+ import { randomBytes, timingSafeEqual } from "node:crypto";
6
+ //#region src/http.ts
7
+ var HttpError = class extends Error {
8
+ status;
9
+ constructor(status, message) {
10
+ super(message.startsWith("[verbaly]") ? message : `[verbaly] ${message}`);
11
+ this.status = status;
12
+ this.name = "HttpError";
13
+ }
14
+ };
15
+ const badRequest = (message) => new HttpError(400, message);
16
+ function scrub(message, root) {
17
+ return root ? message.split(root).join(".") : message;
18
+ }
19
+ //#endregion
20
+ //#region src/triage.ts
21
+ const DIGITS = /\d[\d.,]*/g;
22
+ const URLS = /https?:\/\/[^\s<)"']+/g;
23
+ const CODE = /<code>([^<]+)<\/code>/g;
24
+ function bag(text, re, normalize) {
25
+ const found = text.match(re) ?? [];
26
+ return (normalize ? found.map(normalize) : found).sort();
27
+ }
28
+ function same(a, b) {
29
+ return a.length === b.length && a.every((v, i) => v === b[i]);
30
+ }
31
+ const trimPunct = (v) => v.replace(/[.,;:!?)\]]+$/, "");
32
+ const bareDigits = (v) => trimPunct(v).replaceAll(/[.,]/g, ".");
33
+ function push(out, key, reason) {
34
+ (out[key] ??= []).push(reason);
35
+ }
36
+ function triage({ source, target, reviewed }) {
37
+ const out = Object.create(null);
38
+ const keys = Object.keys(source);
39
+ const bySource = Object.create(null);
40
+ for (const key of keys) {
41
+ const text = source[key]?.trim();
42
+ if (text) (bySource[text] ??= []).push(key);
43
+ }
44
+ for (const [text, group] of Object.entries(bySource)) {
45
+ if (group.length < 2) continue;
46
+ const divergent = reviewed.some((catalog) => new Set(group.map((k) => catalog[k]?.trim() ?? "").filter(Boolean)).size > 1);
47
+ if (divergent) for (const key of group) push(out, key, {
48
+ signal: "divergent",
49
+ text: `"${text}" was already translated two ways`
50
+ });
51
+ const targets = new Set(group.map((key) => target[key]?.trim() ?? "").filter(Boolean));
52
+ if (!divergent && targets.size > 1) for (const key of group) push(out, key, {
53
+ signal: "collision",
54
+ text: `"${text}" came back as two different translations`
55
+ });
56
+ }
57
+ for (const key of keys) {
58
+ const src = source[key]?.trim() ?? "";
59
+ const trg = target[key]?.trim() ?? "";
60
+ if (!src || !trg) continue;
61
+ const opinions = reviewed.map((c) => c[key]?.trim()).filter((v) => v !== void 0 && v !== "");
62
+ if (trg === src && opinions.length > 0 && opinions.every((v) => v !== src)) push(out, key, {
63
+ signal: "echo",
64
+ text: "still identical to the source"
65
+ });
66
+ const srcDigits = bag(src, DIGITS, bareDigits);
67
+ if (srcDigits.length > 0 && !same(srcDigits, bag(trg, DIGITS, bareDigits))) push(out, key, {
68
+ signal: "digits",
69
+ text: "the numbers do not match the source"
70
+ });
71
+ if (!same(bag(src, URLS, trimPunct), bag(trg, URLS, trimPunct))) push(out, key, {
72
+ signal: "url",
73
+ text: "a URL changed"
74
+ });
75
+ if (!same(bag(src, CODE), bag(trg, CODE))) push(out, key, {
76
+ signal: "code",
77
+ text: "the contents of a <code> span changed"
78
+ });
79
+ }
80
+ return out;
81
+ }
82
+ //#endregion
83
+ //#region src/api.ts
84
+ function readAll(cfg) {
85
+ const catalogs = {};
86
+ const problems = [];
87
+ if (!existsSync(cfg.dir)) {
88
+ const dir = relative(cfg.root, cfg.dir).replaceAll("\\", "/");
89
+ problems.push({
90
+ scope: dir,
91
+ message: "[verbaly] no catalogs here, run npx verbaly init or check --root"
92
+ });
93
+ }
94
+ for (const locale of cfg.locales) try {
95
+ catalogs[locale] = readCatalog(cfg, locale);
96
+ } catch (error) {
97
+ catalogs[locale] = {};
98
+ problems.push({
99
+ scope: locale,
100
+ message: error instanceof Error ? error.message : String(error)
101
+ });
102
+ }
103
+ return {
104
+ catalogs,
105
+ problems
106
+ };
107
+ }
108
+ function readDrafts(cfg) {
109
+ try {
110
+ return {
111
+ drafts: loadDrafts(cfg),
112
+ problems: []
113
+ };
114
+ } catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ return {
117
+ drafts: {},
118
+ problems: [{
119
+ scope: DRAFTS_FILE,
120
+ message
121
+ }]
122
+ };
123
+ }
124
+ }
125
+ function relativize(cfg, result) {
126
+ return {
127
+ ...result,
128
+ unknown: result.unknown.map((entry) => ({
129
+ ...entry,
130
+ files: entry.files.map((file) => relative(cfg.root, file).replaceAll("\\", "/"))
131
+ }))
132
+ };
133
+ }
134
+ async function buildState(cfg) {
135
+ const { catalogs, problems } = readAll(cfg);
136
+ const registry = await extractProject(cfg);
137
+ const sidecar = readDrafts(cfg);
138
+ problems.push(...sidecar.problems);
139
+ const live = effectiveDrafts(sidecar.drafts, catalogs);
140
+ const source = catalogs[cfg.sourceLocale] ?? {};
141
+ const targets = targetLocales(cfg);
142
+ for (const { file, message } of registry.parseErrors()) problems.push({
143
+ scope: relative(cfg.root, file).replaceAll("\\", "/"),
144
+ message
145
+ });
146
+ const perLocale = {};
147
+ for (const locale of targets) perLocale[locale] = triage({
148
+ source,
149
+ target: catalogs[locale] ?? {},
150
+ reviewed: targets.filter((other) => other !== locale && !(live[other] ?? []).length).map((other) => catalogs[other] ?? {})
151
+ });
152
+ return {
153
+ root: cfg.root,
154
+ dir: cfg.dir,
155
+ sourceLocale: cfg.sourceLocale,
156
+ locales: cfg.locales,
157
+ scanning: cfg.include.length > 0,
158
+ catalogs,
159
+ origins: await collectOrigins(cfg, registry),
160
+ status: status(cfg, catalogs, registry, live),
161
+ check: relativize(cfg, check(cfg, catalogs, registry)),
162
+ drafts: live,
163
+ triage: perLocale,
164
+ problems
165
+ };
166
+ }
167
+ function health(cfg) {
168
+ return doctor(cfg);
169
+ }
170
+ function writeMessage(cfg, locale, key, text) {
171
+ if (locale === cfg.sourceLocale) throw badRequest("the source text lives in your code, Studio never edits it");
172
+ if (!cfg.locales.includes(locale)) throw badRequest(`${locale} is not one of this project's locales`);
173
+ const source = readCatalog(cfg, cfg.sourceLocale);
174
+ if (!Object.hasOwn(source, key)) throw badRequest(`"${key}" is not in the source catalog, so Studio will not create it`);
175
+ const value = text.trim() ? text : "";
176
+ const errors = (value ? [...validateMessage(value, locale), ...validatePair(source[key], value)] : []).filter((issue) => issue.severity === "error");
177
+ if (errors.length) throw badRequest(`${errors.map((issue) => issue.message).join("; ")}`);
178
+ const catalog = {
179
+ ...readCatalog(cfg, locale),
180
+ [key]: value
181
+ };
182
+ writeCatalog(cfg, locale, catalog);
183
+ const drafts = loadDrafts(cfg);
184
+ const clearedDraft = (drafts[locale] ?? []).includes(key);
185
+ clearDrafts(drafts, locale, [key]);
186
+ saveDrafts(cfg, drafts);
187
+ return {
188
+ locale,
189
+ key,
190
+ clearedDraft
191
+ };
192
+ }
193
+ function approve(cfg, locale, keys) {
194
+ if (!cfg.locales.includes(locale)) throw badRequest(`${locale} is not one of this project's locales`);
195
+ const drafts = loadDrafts(cfg);
196
+ const before = (drafts[locale] ?? []).length;
197
+ clearDrafts(drafts, locale, keys);
198
+ const after = (drafts[locale] ?? []).length;
199
+ saveDrafts(cfg, drafts);
200
+ return {
201
+ locale,
202
+ approved: before - after
203
+ };
204
+ }
205
+ //#endregion
206
+ //#region src/guard.ts
207
+ function newToken() {
208
+ return randomBytes(9).toString("base64url");
209
+ }
210
+ function hostAllowed(host, port) {
211
+ const names = port === 80 ? ["127.0.0.1", "localhost"] : [];
212
+ return [
213
+ `127.0.0.1:${port}`,
214
+ `localhost:${port}`,
215
+ ...names
216
+ ].includes(host ?? "");
217
+ }
218
+ function originAllowed(origin, port) {
219
+ if (origin === void 0) return true;
220
+ return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
221
+ }
222
+ function tokenMatches(given, expected) {
223
+ if (given === null) return false;
224
+ const a = Buffer.from(given);
225
+ const b = Buffer.from(expected);
226
+ return a.length === b.length && timingSafeEqual(a, b);
227
+ }
228
+ const PASS = {
229
+ ok: true,
230
+ status: 200,
231
+ reason: ""
232
+ };
233
+ function guardRequest(headers, url, { port, token }) {
234
+ if (!hostAllowed(headers.host, port)) return {
235
+ ok: false,
236
+ status: 403,
237
+ reason: "[verbaly] unexpected Host header"
238
+ };
239
+ if (!originAllowed(headers.origin, port)) return {
240
+ ok: false,
241
+ status: 403,
242
+ reason: "[verbaly] cross-origin request"
243
+ };
244
+ if (!tokenMatches(url.searchParams.get("t"), token)) return {
245
+ ok: false,
246
+ status: 401,
247
+ reason: "[verbaly] missing or wrong token, open the printed url"
248
+ };
249
+ return PASS;
250
+ }
251
+ //#endregion
252
+ //#region src/server.ts
253
+ const DEFAULT_PORT = 4747;
254
+ const MAX_BODY = 1e6;
255
+ function send(res, status, body) {
256
+ res.writeHead(status, {
257
+ "content-type": "application/json; charset=utf-8",
258
+ "cache-control": "no-store"
259
+ });
260
+ res.end(JSON.stringify(body));
261
+ }
262
+ async function readJson(req) {
263
+ const chunks = [];
264
+ let size = 0;
265
+ for await (const chunk of req) {
266
+ size += chunk.length;
267
+ if (size > MAX_BODY) throw new HttpError(413, "request body too large");
268
+ chunks.push(chunk);
269
+ }
270
+ if (!chunks.length) return {};
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
274
+ } catch {
275
+ throw badRequest("body is not valid JSON");
276
+ }
277
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw badRequest("body must be a JSON object");
278
+ return parsed;
279
+ }
280
+ function segment(raw, what) {
281
+ let value;
282
+ try {
283
+ value = decodeURIComponent(raw ?? "");
284
+ } catch {
285
+ throw badRequest(`the ${what} in the url is not valid percent-encoding`);
286
+ }
287
+ if (!value) throw badRequest(`the url is missing a ${what}`);
288
+ return value;
289
+ }
290
+ function createStudioApp(cfg, port, token) {
291
+ return async function handle(req, res) {
292
+ try {
293
+ let url;
294
+ try {
295
+ url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
296
+ } catch {
297
+ throw badRequest("the request target is not a valid url");
298
+ }
299
+ const verdict = guardRequest({
300
+ host: req.headers.host,
301
+ origin: req.headers.origin
302
+ }, url, {
303
+ port,
304
+ token
305
+ });
306
+ if (!verdict.ok) {
307
+ send(res, verdict.status, { error: verdict.reason });
308
+ return;
309
+ }
310
+ if (req.method === "GET" && url.pathname === "/api/state") {
311
+ send(res, 200, await buildState(cfg));
312
+ return;
313
+ }
314
+ if (req.method === "GET" && url.pathname === "/api/health") {
315
+ send(res, 200, await health(cfg));
316
+ return;
317
+ }
318
+ if (req.method === "PUT" && url.pathname.startsWith("/api/message/")) {
319
+ const rest = url.pathname.slice(13).split("/");
320
+ const locale = segment(rest[0], "locale");
321
+ const key = segment(rest.slice(1).join("/"), "key");
322
+ const body = await readJson(req);
323
+ if (typeof body.text !== "string") throw badRequest("body needs a \"text\" string");
324
+ send(res, 200, writeMessage(cfg, locale, key, body.text));
325
+ return;
326
+ }
327
+ if (req.method === "POST" && url.pathname === "/api/approve") {
328
+ const body = await readJson(req);
329
+ if (typeof body.locale !== "string") throw badRequest("body needs a \"locale\" string");
330
+ const keys = Array.isArray(body.keys) ? body.keys.filter((k) => typeof k === "string") : void 0;
331
+ send(res, 200, approve(cfg, body.locale, keys));
332
+ return;
333
+ }
334
+ send(res, 404, { error: `[verbaly] no route for ${req.method} ${url.pathname}` });
335
+ } catch (error) {
336
+ send(res, error instanceof HttpError ? error.status : 500, { error: scrub(error instanceof Error ? error.message : String(error), cfg.root) });
337
+ }
338
+ };
339
+ }
340
+ function listen(server, port, attemptsLeft) {
341
+ return new Promise((resolve, reject) => {
342
+ const onError = (error) => {
343
+ if (error.code === "EADDRINUSE" && attemptsLeft > 0) {
344
+ server.removeListener("error", onError);
345
+ resolve(listen(server, port + 1, attemptsLeft - 1));
346
+ return;
347
+ }
348
+ reject(error);
349
+ };
350
+ server.once("error", onError);
351
+ server.listen(port, "127.0.0.1", () => {
352
+ server.removeListener("error", onError);
353
+ const bound = server.address();
354
+ resolve(typeof bound === "object" && bound !== null ? bound.port : port);
355
+ });
356
+ });
357
+ }
358
+ async function startStudio(cfg, options = {}) {
359
+ const token = options.token ?? newToken();
360
+ const server = createServer();
361
+ let port = options.port ?? DEFAULT_PORT;
362
+ server.on("request", (req, res) => void createStudioApp(cfg, port, token)(req, res));
363
+ port = await listen(server, port, 20);
364
+ return {
365
+ server,
366
+ port,
367
+ token,
368
+ url: `http://127.0.0.1:${port}/api/state?t=${token}`,
369
+ close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
370
+ };
371
+ }
372
+ //#endregion
373
+ export { createStudioApp, startStudio };
374
+
375
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/http.ts","../src/triage.ts","../src/api.ts","../src/guard.ts","../src/server.ts"],"sourcesContent":["// A client mistake is not a server fault, so the status rides with the error, not a blanket 500.\nexport class HttpError extends Error {\n readonly status: number;\n\n constructor(status: number, message: string) {\n super(message.startsWith('[verbaly]') ? message : `[verbaly] ${message}`);\n this.status = status;\n this.name = 'HttpError';\n }\n}\n\nexport const badRequest = (message: string) => new HttpError(400, message);\n\n// The absolute path of someone's project is not the browser's business.\nexport function scrub(message: string, root: string): string {\n return root ? message.split(root).join('.') : message;\n}\n","import type { Catalog } from '@verbaly/compiler';\n\nexport type TriageSignal = 'divergent' | 'collision' | 'echo' | 'digits' | 'code' | 'url';\n\nexport interface TriageReason {\n signal: TriageSignal;\n text: string;\n}\n\nexport type TriageResult = Record<string, TriageReason[]>;\n\n// check() already caught the structure, so these only point at the few worth reading first.\nexport interface TriageInput {\n source: Catalog;\n target: Catalog;\n reviewed: Catalog[];\n}\n\nconst DIGITS = /\\d[\\d.,]*/g;\nconst URLS = /https?:\\/\\/[^\\s<)\"']+/g;\nconst CODE = /<code>([^<]+)<\\/code>/g;\n\nfunction bag(text: string, re: RegExp, normalize?: (v: string) => string): string[] {\n const found = text.match(re) ?? [];\n return (normalize ? found.map(normalize) : found).sort();\n}\n\nfunction same(a: string[], b: string[]): boolean {\n return a.length === b.length && a.every((v, i) => v === b[i]);\n}\n\n// A number or a url at the end of a clause takes the punctuation with it, and that is not a signal.\nconst trimPunct = (v: string) => v.replace(/[.,;:!?)\\]]+$/, '');\n// A separator is idiomatic (3.09 is 3,09 in Spanish); canonizing keeps 1.5 apart from 15.\nconst bareDigits = (v: string) => trimPunct(v).replaceAll(/[.,]/g, '.');\n\nfunction push(out: TriageResult, key: string, reason: TriageReason): void {\n (out[key] ??= []).push(reason);\n}\n\nexport function triage({ source, target, reviewed }: TriageInput): TriageResult {\n // null-prototype: a source text of \"__proto__\" or \"toString\" is a string, not a member of Object\n const out: TriageResult = Object.create(null);\n const keys = Object.keys(source);\n\n // One source text that two humans rendered two ways is ambiguous, so the machine had no chance.\n const bySource: Record<string, string[]> = Object.create(null);\n for (const key of keys) {\n const text = source[key]?.trim();\n if (text) (bySource[text] ??= []).push(key);\n }\n for (const [text, group] of Object.entries(bySource)) {\n if (group.length < 2) continue;\n const divergent = reviewed.some(\n (catalog) => new Set(group.map((k) => catalog[k]?.trim() ?? '').filter(Boolean)).size > 1,\n );\n if (divergent) {\n for (const key of group) {\n push(out, key, { signal: 'divergent', text: `\"${text}\" was already translated two ways` });\n }\n }\n // The same ambiguity seen from the result instead of from the history.\n const targets = new Set(group.map((key) => target[key]?.trim() ?? '').filter(Boolean));\n if (!divergent && targets.size > 1) {\n for (const key of group) {\n push(out, key, { signal: 'collision', text: `\"${text}\" came back as two different translations` });\n }\n }\n }\n\n for (const key of keys) {\n const src = source[key]?.trim() ?? '';\n const trg = target[key]?.trim() ?? '';\n if (!src || !trg) continue;\n\n // Only the locales with an opinion on this key vote: alone it fires on every do-not-translate.\n const opinions = reviewed.map((c) => c[key]?.trim()).filter((v) => v !== undefined && v !== '');\n if (trg === src && opinions.length > 0 && opinions.every((v) => v !== src)) {\n push(out, key, { signal: 'echo', text: 'still identical to the source' });\n }\n\n // a translation that adds a number is idiomatic (\"refresh\" is \"F5\"); one that changes it is not\n const srcDigits = bag(src, DIGITS, bareDigits);\n if (srcDigits.length > 0 && !same(srcDigits, bag(trg, DIGITS, bareDigits))) {\n push(out, key, { signal: 'digits', text: 'the numbers do not match the source' });\n }\n if (!same(bag(src, URLS, trimPunct), bag(trg, URLS, trimPunct))) {\n push(out, key, { signal: 'url', text: 'a URL changed' });\n }\n if (!same(bag(src, CODE), bag(trg, CODE))) {\n push(out, key, { signal: 'code', text: 'the contents of a <code> span changed' });\n }\n }\n\n return out;\n}\n","import { existsSync } from 'node:fs';\nimport { relative } from 'node:path';\nimport {\n check,\n clearDrafts,\n collectOrigins,\n doctor,\n effectiveDrafts,\n extractProject,\n loadDrafts,\n readCatalog,\n saveDrafts,\n status,\n targetLocales,\n validateMessage,\n validatePair,\n writeCatalog,\n} from '@verbaly/compiler';\nimport { DRAFTS_FILE } from '@verbaly/compiler';\nimport type {\n Catalog,\n Catalogs,\n CheckResult,\n DoctorResult,\n Drafts,\n ResolvedConfig,\n StatusResult,\n} from '@verbaly/compiler';\nimport { badRequest } from './http';\nimport { triage, type TriageResult } from './triage';\n\nexport interface StudioProblem {\n scope: string;\n message: string;\n}\n\nexport interface StudioState {\n root: string;\n dir: string;\n sourceLocale: string;\n locales: string[];\n scanning: boolean;\n catalogs: Catalogs;\n origins: Record<string, string[]>;\n status: StatusResult;\n check: CheckResult;\n drafts: Drafts;\n triage: Record<string, TriageResult>;\n problems: StudioProblem[];\n}\n\n// A locale nobody can parse must not take the panel down: Studio is what you open to fix it.\nfunction readAll(cfg: ResolvedConfig): { catalogs: Catalogs; problems: StudioProblem[] } {\n const catalogs: Catalogs = {};\n const problems: StudioProblem[] = [];\n // an empty green panel and a wrong --root look identical, and only this line tells them apart\n if (!existsSync(cfg.dir)) {\n const dir = relative(cfg.root, cfg.dir).replaceAll('\\\\', '/');\n problems.push({ scope: dir, message: '[verbaly] no catalogs here, run npx verbaly init or check --root' });\n }\n for (const locale of cfg.locales) {\n try {\n catalogs[locale] = readCatalog(cfg, locale);\n } catch (error) {\n catalogs[locale] = {};\n problems.push({\n scope: locale,\n message: error instanceof Error ? error.message : String(error),\n });\n }\n }\n return { catalogs, problems };\n}\n\n// A sidecar nobody can parse is the other file Studio exists to fix, so it degrades like a catalog.\nfunction readDrafts(cfg: ResolvedConfig): { drafts: Drafts; problems: StudioProblem[] } {\n try {\n return { drafts: loadDrafts(cfg), problems: [] };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return { drafts: {}, problems: [{ scope: DRAFTS_FILE, message }] };\n }\n}\n\n// check() reports the file a stray key was used in as an absolute path, origins already do not.\nfunction relativize(cfg: ResolvedConfig, result: CheckResult): CheckResult {\n return {\n ...result,\n unknown: result.unknown.map((entry) => ({\n ...entry,\n files: entry.files.map((file) => relative(cfg.root, file).replaceAll('\\\\', '/')),\n })),\n };\n}\n\n// One shot and unpaginated: the biggest catalog we know is 1425 messages, instant over localhost.\nexport async function buildState(cfg: ResolvedConfig): Promise<StudioState> {\n const { catalogs, problems } = readAll(cfg);\n const registry = await extractProject(cfg);\n const sidecar = readDrafts(cfg);\n problems.push(...sidecar.problems);\n const live = effectiveDrafts(sidecar.drafts, catalogs);\n const source = (catalogs[cfg.sourceLocale] ?? {}) as Catalog;\n const targets = targetLocales(cfg);\n\n for (const { file, message } of registry.parseErrors()) {\n problems.push({ scope: relative(cfg.root, file).replaceAll('\\\\', '/'), message });\n }\n\n const perLocale: Record<string, TriageResult> = {};\n for (const locale of targets) {\n perLocale[locale] = triage({\n source,\n target: (catalogs[locale] ?? {}) as Catalog,\n // a locale still carrying drafts has not been reviewed, so it cannot be the control\n reviewed: targets\n .filter((other) => other !== locale && !(live[other] ?? []).length)\n .map((other) => (catalogs[other] ?? {}) as Catalog),\n });\n }\n\n return {\n root: cfg.root,\n dir: cfg.dir,\n sourceLocale: cfg.sourceLocale,\n locales: cfg.locales,\n // with include: [] nothing is scanned, so an empty origins map is off, not \"none found\"\n scanning: cfg.include.length > 0,\n catalogs,\n origins: await collectOrigins(cfg, registry),\n status: status(cfg, catalogs, registry, live),\n check: relativize(cfg, check(cfg, catalogs, registry)),\n drafts: live,\n triage: perLocale,\n problems,\n };\n}\n\nexport function health(cfg: ResolvedConfig): Promise<DoctorResult> {\n return doctor(cfg);\n}\n\nexport interface WriteMessageResult {\n locale: string;\n key: string;\n clearedDraft: boolean;\n}\n\n// A human wrote it, so it is reviewed: the rule import already applies in run.ts:447.\nexport function writeMessage(\n cfg: ResolvedConfig,\n locale: string,\n key: string,\n text: string,\n): WriteMessageResult {\n if (locale === cfg.sourceLocale) {\n throw badRequest('the source text lives in your code, Studio never edits it');\n }\n if (!cfg.locales.includes(locale)) {\n throw badRequest(`${locale} is not one of this project's locales`);\n }\n const source = readCatalog(cfg, cfg.sourceLocale);\n // hasOwn, not `in`: `in` walks the prototype, so toString and __proto__ would pass this guard\n if (!Object.hasOwn(source, key)) {\n throw badRequest(`\"${key}\" is not in the source catalog, so Studio will not create it`);\n }\n // '' is untranslated everywhere else, so whitespace is that and not a translation to validate\n const value = text.trim() ? text : '';\n // the same two calls check() makes: validatePair alone misses a plural block with no other case\n const issues = value\n ? [...validateMessage(value, locale), ...validatePair(source[key] as string, value)]\n : [];\n const errors = issues.filter((issue) => issue.severity === 'error');\n if (errors.length) {\n throw badRequest(`${errors.map((issue) => issue.message).join('; ')}`);\n }\n\n const catalog = { ...readCatalog(cfg, locale), [key]: value };\n writeCatalog(cfg, locale, catalog);\n\n const drafts = loadDrafts(cfg);\n const clearedDraft = (drafts[locale] ?? []).includes(key);\n clearDrafts(drafts, locale, [key]);\n saveDrafts(cfg, drafts);\n return { locale, key, clearedDraft };\n}\n\nexport interface ApproveResult {\n locale: string;\n approved: number;\n}\n\n// The sidecar is re-read here: saveDrafts writes every locale, so a stale copy drops another's.\nexport function approve(cfg: ResolvedConfig, locale: string, keys?: string[]): ApproveResult {\n if (!cfg.locales.includes(locale)) {\n throw badRequest(`${locale} is not one of this project's locales`);\n }\n const drafts = loadDrafts(cfg);\n const before = (drafts[locale] ?? []).length;\n clearDrafts(drafts, locale, keys);\n const after = (drafts[locale] ?? []).length;\n saveDrafts(cfg, drafts);\n return { locale, approved: before - after };\n}\n","import { randomBytes, timingSafeEqual } from 'node:crypto';\n\nexport interface GuardOptions {\n port: number;\n token: string;\n}\n\n// A local server that writes files is reachable from any page the browser has open.\nexport function newToken(): string {\n return randomBytes(9).toString('base64url');\n}\n\n// Only the two names that resolve to this machine. A DNS rebind arrives with a foreign Host.\nexport function hostAllowed(host: string | undefined, port: number): boolean {\n // a browser omits the port when it is the scheme default, so :80 would 403 every real request\n const names = port === 80 ? ['127.0.0.1', 'localhost'] : [];\n return [`127.0.0.1:${port}`, `localhost:${port}`, ...names].includes(host ?? '');\n}\n\n// No Origin at all is a same-origin navigation or a curl, both fine; a foreign one never is.\nexport function originAllowed(origin: string | undefined, port: number): boolean {\n if (origin === undefined) return true;\n return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;\n}\n\n// Compared in constant time so the token cannot be guessed one character at a time.\nexport function tokenMatches(given: string | null, expected: string): boolean {\n if (given === null) return false;\n const a = Buffer.from(given);\n const b = Buffer.from(expected);\n return a.length === b.length && timingSafeEqual(a, b);\n}\n\nexport interface GuardVerdict {\n ok: boolean;\n status: number;\n reason: string;\n}\n\nconst PASS: GuardVerdict = { ok: true, status: 200, reason: '' };\n\nexport function guardRequest(\n headers: { host?: string; origin?: string },\n url: URL,\n { port, token }: GuardOptions,\n): GuardVerdict {\n if (!hostAllowed(headers.host, port)) {\n return { ok: false, status: 403, reason: '[verbaly] unexpected Host header' };\n }\n if (!originAllowed(headers.origin, port)) {\n return { ok: false, status: 403, reason: '[verbaly] cross-origin request' };\n }\n if (!tokenMatches(url.searchParams.get('t'), token)) {\n return { ok: false, status: 401, reason: '[verbaly] missing or wrong token, open the printed url' };\n }\n return PASS;\n}\n","import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';\nimport type { AddressInfo } from 'node:net';\nimport type { ResolvedConfig } from '@verbaly/compiler';\nimport { approve, buildState, health, writeMessage } from './api';\nimport { guardRequest, newToken } from './guard';\nimport { HttpError, badRequest, scrub } from './http';\n\nexport interface StudioServer {\n server: Server;\n port: number;\n token: string;\n url: string;\n close: () => Promise<void>;\n}\n\nexport interface StartOptions {\n port?: number;\n token?: string;\n}\n\nconst DEFAULT_PORT = 4747;\nconst MAX_BODY = 1_000_000;\n\nfunction send(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n 'content-type': 'application/json; charset=utf-8',\n 'cache-control': 'no-store',\n });\n res.end(JSON.stringify(body));\n}\n\nasync function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {\n const chunks: Buffer[] = [];\n let size = 0;\n for await (const chunk of req) {\n size += (chunk as Buffer).length;\n if (size > MAX_BODY) throw new HttpError(413, 'request body too large');\n chunks.push(chunk as Buffer);\n }\n if (!chunks.length) return {};\n let parsed: unknown;\n try {\n parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));\n } catch {\n throw badRequest('body is not valid JSON');\n }\n // null and an array both pass typeof 'object', and reading .text off them is a 500\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw badRequest('body must be a JSON object');\n }\n return parsed as Record<string, unknown>;\n}\n\nfunction segment(raw: string | undefined, what: string): string {\n let value: string;\n try {\n value = decodeURIComponent(raw ?? '');\n } catch {\n throw badRequest(`the ${what} in the url is not valid percent-encoding`);\n }\n if (!value) throw badRequest(`the url is missing a ${what}`);\n return value;\n}\n\n// One page, JSON on every route: a framework would only add a dependency to a local tool.\nexport function createStudioApp(cfg: ResolvedConfig, port: number, token: string) {\n return async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {\n // The url is built inside the try: `GET //` throws, and out here that would kill the process.\n try {\n let url: URL;\n try {\n url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);\n } catch {\n throw badRequest('the request target is not a valid url');\n }\n\n const verdict = guardRequest({ host: req.headers.host, origin: req.headers.origin }, url, {\n port,\n token,\n });\n if (!verdict.ok) {\n send(res, verdict.status, { error: verdict.reason });\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/state') {\n send(res, 200, await buildState(cfg));\n return;\n }\n if (req.method === 'GET' && url.pathname === '/api/health') {\n send(res, 200, await health(cfg));\n return;\n }\n if (req.method === 'PUT' && url.pathname.startsWith('/api/message/')) {\n const rest = url.pathname.slice('/api/message/'.length).split('/');\n const locale = segment(rest[0], 'locale');\n const key = segment(rest.slice(1).join('/'), 'key');\n const body = await readJson(req);\n if (typeof body.text !== 'string') throw badRequest('body needs a \"text\" string');\n send(res, 200, writeMessage(cfg, locale, key, body.text));\n return;\n }\n if (req.method === 'POST' && url.pathname === '/api/approve') {\n const body = await readJson(req);\n if (typeof body.locale !== 'string') throw badRequest('body needs a \"locale\" string');\n const keys = Array.isArray(body.keys)\n ? body.keys.filter((k): k is string => typeof k === 'string')\n : undefined;\n send(res, 200, approve(cfg, body.locale, keys));\n return;\n }\n send(res, 404, { error: `[verbaly] no route for ${req.method} ${url.pathname}` });\n } catch (error) {\n const status = error instanceof HttpError ? error.status : 500;\n const message = error instanceof Error ? error.message : String(error);\n send(res, status, { error: scrub(message, cfg.root) });\n }\n };\n}\n\n// A busy port climbs instead of failing: this is a local tool, not a service on a fixed address.\nfunction listen(server: Server, port: number, attemptsLeft: number): Promise<number> {\n return new Promise((resolve, reject) => {\n const onError = (error: NodeJS.ErrnoException) => {\n if (error.code === 'EADDRINUSE' && attemptsLeft > 0) {\n server.removeListener('error', onError);\n resolve(listen(server, port + 1, attemptsLeft - 1));\n return;\n }\n reject(error);\n };\n server.once('error', onError);\n server.listen(port, '127.0.0.1', () => {\n server.removeListener('error', onError);\n // port 0 asks the system to pick, so the bound address is the only place the answer is\n const bound = server.address() as AddressInfo | string | null;\n resolve(typeof bound === 'object' && bound !== null ? bound.port : port);\n });\n });\n}\n\nexport async function startStudio(\n cfg: ResolvedConfig,\n options: StartOptions = {},\n): Promise<StudioServer> {\n const token = options.token ?? newToken();\n const server = createServer();\n // The handler is attached before listen so no request can arrive without one.\n let port = options.port ?? DEFAULT_PORT;\n server.on('request', (req, res) => void createStudioApp(cfg, port, token)(req, res));\n port = await listen(server, port, 20);\n\n return {\n server,\n port,\n token,\n url: `http://127.0.0.1:${port}/api/state?t=${token}`,\n close: () =>\n new Promise<void>((resolve, reject) =>\n server.close((error) => (error ? reject(error) : resolve())),\n ),\n };\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@verbaly/studio",
3
+ "version": "0.53.0",
4
+ "description": "Local review server for Verbaly: read, edit and approve translations over the JSON files already in your repo. No account, no database.",
5
+ "keywords": [
6
+ "i18n",
7
+ "translations",
8
+ "localhost",
9
+ "editor",
10
+ "verbaly"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Aron Soto",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/AronSoto/verbaly.git",
17
+ "directory": "packages/studio"
18
+ },
19
+ "homepage": "https://verbaly-web.vercel.app",
20
+ "bugs": {
21
+ "url": "https://github.com/AronSoto/verbaly/issues"
22
+ },
23
+ "type": "module",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "bin": {
32
+ "verbaly-studio": "./dist/cli.js"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "LICENSE"
37
+ ],
38
+ "sideEffects": false,
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "@verbaly/compiler": "^0.53.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^26.4.1"
50
+ },
51
+ "scripts": {
52
+ "build": "tsdown",
53
+ "dev": "tsdown --watch",
54
+ "test": "vitest run --passWithNoTests",
55
+ "typecheck": "tsc --noEmit"
56
+ }
57
+ }