@verbaly/studio 0.54.0 → 0.56.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/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
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";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { join, normalize, relative, resolve } from "node:path";
4
+ import { DRAFTS_FILE, check, clearDrafts, collectOrigins, doctor, effectiveDrafts, extractProject, loadCatalogs, loadDrafts, markDrafts, readCatalog, resolveProvider, saveDrafts, status, syncCatalogs, targetLocales, translateCatalogs, validateMessage, validatePair, writeCatalog, writeDts } from "@verbaly/compiler";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { randomBytes, timingSafeEqual } from "node:crypto";
6
7
  //#region src/http.ts
7
8
  var HttpError = class extends Error {
@@ -193,13 +194,138 @@ function writeMessage(cfg, locale, key, text) {
193
194
  function approve(cfg, locale, keys) {
194
195
  if (!cfg.locales.includes(locale)) throw badRequest(`${locale} is not one of this project's locales`);
195
196
  const drafts = loadDrafts(cfg);
196
- const before = (drafts[locale] ?? []).length;
197
+ const had = drafts[locale] ?? [];
198
+ const cleared = keys ? had.filter((key) => keys.includes(key)) : [...had];
197
199
  clearDrafts(drafts, locale, keys);
198
- const after = (drafts[locale] ?? []).length;
199
200
  saveDrafts(cfg, drafts);
200
201
  return {
201
202
  locale,
202
- approved: before - after
203
+ approved: cleared.length,
204
+ keys: cleared
205
+ };
206
+ }
207
+ function unapprove(cfg, locale, keys) {
208
+ if (!cfg.locales.includes(locale)) throw badRequest(`${locale} is not one of this project's locales`);
209
+ const drafts = loadDrafts(cfg);
210
+ markDrafts(drafts, locale, keys);
211
+ saveDrafts(cfg, drafts);
212
+ return {
213
+ locale,
214
+ approved: keys.length,
215
+ keys
216
+ };
217
+ }
218
+ //#endregion
219
+ //#region src/jobs.ts
220
+ let current = null;
221
+ let counter = 0;
222
+ function running() {
223
+ return current?.state === "running" ? current : null;
224
+ }
225
+ function read(id) {
226
+ if (!current || current.id !== id) throw new HttpError(404, `no job called "${id}"`);
227
+ return current;
228
+ }
229
+ function start(kind, total) {
230
+ if (running()) throw badRequest("a job is already running, wait for it or read its progress");
231
+ counter += 1;
232
+ current = {
233
+ id: `${kind}-${counter}`,
234
+ kind,
235
+ state: "running",
236
+ total,
237
+ done: 0
238
+ };
239
+ return current;
240
+ }
241
+ function advance(id, keys, locale, error) {
242
+ if (current?.id !== id) return;
243
+ current.done = Math.min(current.done + keys, current.total);
244
+ current.locale = locale;
245
+ if (error) current.message = error;
246
+ }
247
+ function finish(id, state, patch = {}) {
248
+ if (current?.id === id) Object.assign(current, patch, { state });
249
+ }
250
+ //#endregion
251
+ //#region src/actions.ts
252
+ async function runExtract(cfg) {
253
+ if (!cfg.include.length) throw badRequest("source scanning is off (include: []), so there is nothing to extract");
254
+ const registry = await extractProject(cfg);
255
+ const catalogs = loadCatalogs(cfg);
256
+ const result = syncCatalogs(cfg, catalogs, registry);
257
+ for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
258
+ if (cfg.dts !== false) writeDts(cfg, catalogs[cfg.sourceLocale] ?? {}, cfg.dts);
259
+ const found = result.added[cfg.sourceLocale]?.length ?? 0;
260
+ return {
261
+ added: result.added,
262
+ found,
263
+ messages: registry.messages().size
264
+ };
265
+ }
266
+ async function planTranslate(cfg, locales) {
267
+ const provider = await resolveProvider(cfg);
268
+ const catalogs = loadCatalogs(cfg);
269
+ const result = await translateCatalogs(cfg, catalogs, provider, {
270
+ locales,
271
+ dryRun: true
272
+ });
273
+ const total = Object.values(result.pending).reduce((n, keys) => n + keys.length, 0);
274
+ return {
275
+ pending: result.pending,
276
+ total
277
+ };
278
+ }
279
+ async function startTranslate(cfg, locales) {
280
+ const provider = await resolveProvider(cfg);
281
+ const job = start("translate", (await planTranslate(cfg, locales)).total);
282
+ const id = job.id;
283
+ (async () => {
284
+ try {
285
+ const catalogs = loadCatalogs(cfg);
286
+ const result = await translateCatalogs(cfg, catalogs, provider, {
287
+ locales,
288
+ batchSize: cfg.translate.batchSize,
289
+ concurrency: cfg.translate.concurrency,
290
+ retries: cfg.translate.retries,
291
+ origins: await collectOrigins(cfg),
292
+ onProgress: (p) => advance(id, p.keys, p.locale, p.error)
293
+ });
294
+ for (const [locale, keys] of Object.entries(result.translated)) if (keys.length) writeCatalog(cfg, locale, catalogs[locale] ?? {});
295
+ const drafts = loadDrafts(cfg);
296
+ for (const [locale, keys] of Object.entries(result.translated)) markDrafts(drafts, locale, keys);
297
+ saveDrafts(cfg, drafts);
298
+ finish(id, result.failed.length ? "failed" : "done", {
299
+ result,
300
+ message: result.failed.length ? `${result.failed.length} batches did not answer` : void 0
301
+ });
302
+ } catch (error) {
303
+ finish(id, "failed", { message: error instanceof Error ? error.message : String(error) });
304
+ }
305
+ })();
306
+ return job;
307
+ }
308
+ //#endregion
309
+ //#region src/assets.ts
310
+ const UI = resolve(fileURLToPath(new URL(".", import.meta.url)), "ui");
311
+ const TYPES = {
312
+ ".html": "text/html; charset=utf-8",
313
+ ".js": "text/javascript; charset=utf-8",
314
+ ".css": "text/css; charset=utf-8",
315
+ ".svg": "image/svg+xml",
316
+ ".woff2": "font/woff2",
317
+ ".json": "application/json; charset=utf-8",
318
+ ".ico": "image/x-icon"
319
+ };
320
+ const uiBuilt = () => existsSync(join(UI, "index.html"));
321
+ function readAsset(pathname) {
322
+ const target = resolve(join(UI, normalize(pathname === "/" ? "/index.html" : pathname)));
323
+ if (target !== UI && !target.startsWith(UI + (process.platform === "win32" ? "\\" : "/"))) return;
324
+ if (!existsSync(target) || !statSync(target).isFile()) return void 0;
325
+ const dot = target.lastIndexOf(".");
326
+ return {
327
+ body: readFileSync(target),
328
+ type: TYPES[target.slice(dot).toLowerCase()] ?? "application/octet-stream"
203
329
  };
204
330
  }
205
331
  //#endregion
@@ -296,6 +422,7 @@ function createStudioApp(cfg, port, token) {
296
422
  } catch {
297
423
  throw badRequest("the request target is not a valid url");
298
424
  }
425
+ const api = url.pathname.startsWith("/api/");
299
426
  const verdict = guardRequest({
300
427
  host: req.headers.host,
301
428
  origin: req.headers.origin
@@ -303,10 +430,22 @@ function createStudioApp(cfg, port, token) {
303
430
  port,
304
431
  token
305
432
  });
306
- if (!verdict.ok) {
433
+ if (!verdict.ok && (api || verdict.status !== 401)) {
307
434
  send(res, verdict.status, { error: verdict.reason });
308
435
  return;
309
436
  }
437
+ if (!api && req.method === "GET") {
438
+ const asset = readAsset(url.pathname);
439
+ if (asset) {
440
+ res.writeHead(200, {
441
+ "content-type": asset.type,
442
+ "cache-control": "no-store"
443
+ });
444
+ res.end(asset.body);
445
+ return;
446
+ }
447
+ if (url.pathname === "/" && !uiBuilt()) throw new HttpError(500, "the panel is not in this install, so only /api/* answers here");
448
+ }
310
449
  if (req.method === "GET" && url.pathname === "/api/state") {
311
450
  send(res, 200, await buildState(cfg));
312
451
  return;
@@ -328,9 +467,37 @@ function createStudioApp(cfg, port, token) {
328
467
  const body = await readJson(req);
329
468
  if (typeof body.locale !== "string") throw badRequest("body needs a \"locale\" string");
330
469
  const keys = Array.isArray(body.keys) ? body.keys.filter((k) => typeof k === "string") : void 0;
470
+ if (body.undo === true) {
471
+ if (!keys?.length) throw badRequest("undoing needs the \"keys\" it should put back");
472
+ send(res, 200, unapprove(cfg, body.locale, keys));
473
+ return;
474
+ }
331
475
  send(res, 200, approve(cfg, body.locale, keys));
332
476
  return;
333
477
  }
478
+ if (req.method === "POST" && url.pathname === "/api/extract") {
479
+ send(res, 200, await runExtract(cfg));
480
+ return;
481
+ }
482
+ if (req.method === "GET" && url.pathname === "/api/translate") {
483
+ const locales = url.searchParams.getAll("locale");
484
+ send(res, 200, await planTranslate(cfg, locales.length ? locales : void 0));
485
+ return;
486
+ }
487
+ if (req.method === "POST" && url.pathname === "/api/translate") {
488
+ const body = await readJson(req);
489
+ const locales = Array.isArray(body.locales) ? body.locales.filter((l) => typeof l === "string") : void 0;
490
+ send(res, 200, await startTranslate(cfg, locales?.length ? locales : void 0));
491
+ return;
492
+ }
493
+ if (req.method === "GET" && url.pathname === "/api/job") {
494
+ send(res, 200, running());
495
+ return;
496
+ }
497
+ if (req.method === "GET" && url.pathname.startsWith("/api/job/")) {
498
+ send(res, 200, read(segment(url.pathname.slice(9), "job id")));
499
+ return;
500
+ }
334
501
  send(res, 404, { error: `[verbaly] no route for ${req.method} ${url.pathname}` });
335
502
  } catch (error) {
336
503
  send(res, error instanceof HttpError ? error.status : 500, { error: scrub(error instanceof Error ? error.message : String(error), cfg.root) });
@@ -365,7 +532,7 @@ async function startStudio(cfg, options = {}) {
365
532
  server,
366
533
  port,
367
534
  token,
368
- url: `http://127.0.0.1:${port}/api/state?t=${token}`,
535
+ url: `http://127.0.0.1:${port}/?t=${token}`,
369
536
  close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
370
537
  };
371
538
  }
package/dist/index.js.map CHANGED
@@ -1 +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 1479 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":";;;;;;AACA,IAAa,YAAb,cAA+B,MAAM;CACnC;CAEA,YAAY,QAAgB,SAAiB;EAC3C,MAAM,QAAQ,WAAW,WAAW,IAAI,UAAU,aAAa,SAAS;EACxE,KAAK,SAAS;EACd,KAAK,OAAO;CACd;AACF;AAEA,MAAa,cAAc,YAAoB,IAAI,UAAU,KAAK,OAAO;AAGzE,SAAgB,MAAM,SAAiB,MAAsB;CAC3D,OAAO,OAAO,QAAQ,MAAM,IAAI,CAAC,CAAC,KAAK,GAAG,IAAI;AAChD;;;ACEA,MAAM,SAAS;AACf,MAAM,OAAO;AACb,MAAM,OAAO;AAEb,SAAS,IAAI,MAAc,IAAY,WAA6C;CAClF,MAAM,QAAQ,KAAK,MAAM,EAAE,KAAK,CAAC;CACjC,QAAQ,YAAY,MAAM,IAAI,SAAS,IAAI,MAAA,CAAO,KAAK;AACzD;AAEA,SAAS,KAAK,GAAa,GAAsB;CAC/C,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,GAAG,MAAM,MAAM,EAAE,EAAE;AAC9D;AAGA,MAAM,aAAa,MAAc,EAAE,QAAQ,iBAAiB,EAAE;AAE9D,MAAM,cAAc,MAAc,UAAU,CAAC,CAAC,CAAC,WAAW,SAAS,GAAG;AAEtE,SAAS,KAAK,KAAmB,KAAa,QAA4B;CACxE,CAAC,IAAI,SAAS,CAAC,EAAA,CAAG,KAAK,MAAM;AAC/B;AAEA,SAAgB,OAAO,EAAE,QAAQ,QAAQ,YAAuC;CAE9E,MAAM,MAAoB,OAAO,OAAO,IAAI;CAC5C,MAAM,OAAO,OAAO,KAAK,MAAM;CAG/B,MAAM,WAAqC,OAAO,OAAO,IAAI;CAC7D,KAAK,MAAM,OAAO,MAAM;EACtB,MAAM,OAAO,OAAO,IAAI,EAAE,KAAK;EAC/B,IAAI,MAAM,CAAC,SAAS,UAAU,CAAC,EAAA,CAAG,KAAK,GAAG;CAC5C;CACA,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,GAAG;EACpD,IAAI,MAAM,SAAS,GAAG;EACtB,MAAM,YAAY,SAAS,MACxB,YAAY,IAAI,IAAI,MAAM,KAAK,MAAM,QAAQ,EAAE,EAAE,KAAK,KAAK,EAAE,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,CAAC,OAAO,CAC1F;EACA,IAAI,WACF,KAAK,MAAM,OAAO,OAChB,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAa,MAAM,IAAI,KAAK;EAAmC,CAAC;EAI7F,MAAM,UAAU,IAAI,IAAI,MAAM,KAAK,QAAQ,OAAO,IAAI,EAAE,KAAK,KAAK,EAAE,CAAC,CAAC,OAAO,OAAO,CAAC;EACrF,IAAI,CAAC,aAAa,QAAQ,OAAO,GAC/B,KAAK,MAAM,OAAO,OAChB,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAa,MAAM,IAAI,KAAK;EAA2C,CAAC;CAGvG;CAEA,KAAK,MAAM,OAAO,MAAM;EACtB,MAAM,MAAM,OAAO,IAAI,EAAE,KAAK,KAAK;EACnC,MAAM,MAAM,OAAO,IAAI,EAAE,KAAK,KAAK;EACnC,IAAI,CAAC,OAAO,CAAC,KAAK;EAGlB,MAAM,WAAW,SAAS,KAAK,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,QAAQ,MAAM,MAAM,KAAA,KAAa,MAAM,EAAE;EAC9F,IAAI,QAAQ,OAAO,SAAS,SAAS,KAAK,SAAS,OAAO,MAAM,MAAM,GAAG,GACvE,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAQ,MAAM;EAAgC,CAAC;EAI1E,MAAM,YAAY,IAAI,KAAK,QAAQ,UAAU;EAC7C,IAAI,UAAU,SAAS,KAAK,CAAC,KAAK,WAAW,IAAI,KAAK,QAAQ,UAAU,CAAC,GACvE,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAU,MAAM;EAAsC,CAAC;EAElF,IAAI,CAAC,KAAK,IAAI,KAAK,MAAM,SAAS,GAAG,IAAI,KAAK,MAAM,SAAS,CAAC,GAC5D,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAO,MAAM;EAAgB,CAAC;EAEzD,IAAI,CAAC,KAAK,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,CAAC,GACtC,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAQ,MAAM;EAAwC,CAAC;CAEpF;CAEA,OAAO;AACT;;;AC3CA,SAAS,QAAQ,KAAwE;CACvF,MAAM,WAAqB,CAAC;CAC5B,MAAM,WAA4B,CAAC;CAEnC,IAAI,CAAC,WAAW,IAAI,GAAG,GAAG;EACxB,MAAM,MAAM,SAAS,IAAI,MAAM,IAAI,GAAG,CAAC,CAAC,WAAW,MAAM,GAAG;EAC5D,SAAS,KAAK;GAAE,OAAO;GAAK,SAAS;EAAmE,CAAC;CAC3G;CACA,KAAK,MAAM,UAAU,IAAI,SACvB,IAAI;EACF,SAAS,UAAU,YAAY,KAAK,MAAM;CAC5C,SAAS,OAAO;EACd,SAAS,UAAU,CAAC;EACpB,SAAS,KAAK;GACZ,OAAO;GACP,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EAChE,CAAC;CACH;CAEF,OAAO;EAAE;EAAU;CAAS;AAC9B;AAGA,SAAS,WAAW,KAAoE;CACtF,IAAI;EACF,OAAO;GAAE,QAAQ,WAAW,GAAG;GAAG,UAAU,CAAC;EAAE;CACjD,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,OAAO;GAAE,QAAQ,CAAC;GAAG,UAAU,CAAC;IAAE,OAAO;IAAa;GAAQ,CAAC;EAAE;CACnE;AACF;AAGA,SAAS,WAAW,KAAqB,QAAkC;CACzE,OAAO;EACL,GAAG;EACH,SAAS,OAAO,QAAQ,KAAK,WAAW;GACtC,GAAG;GACH,OAAO,MAAM,MAAM,KAAK,SAAS,SAAS,IAAI,MAAM,IAAI,CAAC,CAAC,WAAW,MAAM,GAAG,CAAC;EACjF,EAAE;CACJ;AACF;AAGA,eAAsB,WAAW,KAA2C;CAC1E,MAAM,EAAE,UAAU,aAAa,QAAQ,GAAG;CAC1C,MAAM,WAAW,MAAM,eAAe,GAAG;CACzC,MAAM,UAAU,WAAW,GAAG;CAC9B,SAAS,KAAK,GAAG,QAAQ,QAAQ;CACjC,MAAM,OAAO,gBAAgB,QAAQ,QAAQ,QAAQ;CACrD,MAAM,SAAU,SAAS,IAAI,iBAAiB,CAAC;CAC/C,MAAM,UAAU,cAAc,GAAG;CAEjC,KAAK,MAAM,EAAE,MAAM,aAAa,SAAS,YAAY,GACnD,SAAS,KAAK;EAAE,OAAO,SAAS,IAAI,MAAM,IAAI,CAAC,CAAC,WAAW,MAAM,GAAG;EAAG;CAAQ,CAAC;CAGlF,MAAM,YAA0C,CAAC;CACjD,KAAK,MAAM,UAAU,SACnB,UAAU,UAAU,OAAO;EACzB;EACA,QAAS,SAAS,WAAW,CAAC;EAE9B,UAAU,QACP,QAAQ,UAAU,UAAU,UAAU,EAAE,KAAK,UAAU,CAAC,EAAA,CAAG,MAAM,CAAC,CAClE,KAAK,UAAW,SAAS,UAAU,CAAC,CAAa;CACtD,CAAC;CAGH,OAAO;EACL,MAAM,IAAI;EACV,KAAK,IAAI;EACT,cAAc,IAAI;EAClB,SAAS,IAAI;EAEb,UAAU,IAAI,QAAQ,SAAS;EAC/B;EACA,SAAS,MAAM,eAAe,KAAK,QAAQ;EAC3C,QAAQ,OAAO,KAAK,UAAU,UAAU,IAAI;EAC5C,OAAO,WAAW,KAAK,MAAM,KAAK,UAAU,QAAQ,CAAC;EACrD,QAAQ;EACR,QAAQ;EACR;CACF;AACF;AAEA,SAAgB,OAAO,KAA4C;CACjE,OAAO,OAAO,GAAG;AACnB;AASA,SAAgB,aACd,KACA,QACA,KACA,MACoB;CACpB,IAAI,WAAW,IAAI,cACjB,MAAM,WAAW,2DAA2D;CAE9E,IAAI,CAAC,IAAI,QAAQ,SAAS,MAAM,GAC9B,MAAM,WAAW,GAAG,OAAO,sCAAsC;CAEnE,MAAM,SAAS,YAAY,KAAK,IAAI,YAAY;CAEhD,IAAI,CAAC,OAAO,OAAO,QAAQ,GAAG,GAC5B,MAAM,WAAW,IAAI,IAAI,6DAA6D;CAGxF,MAAM,QAAQ,KAAK,KAAK,IAAI,OAAO;CAKnC,MAAM,UAHS,QACX,CAAC,GAAG,gBAAgB,OAAO,MAAM,GAAG,GAAG,aAAa,OAAO,MAAgB,KAAK,CAAC,IACjF,CAAC,EAAA,CACiB,QAAQ,UAAU,MAAM,aAAa,OAAO;CAClE,IAAI,OAAO,QACT,MAAM,WAAW,GAAG,OAAO,KAAK,UAAU,MAAM,OAAO,CAAC,CAAC,KAAK,IAAI,GAAG;CAGvE,MAAM,UAAU;EAAE,GAAG,YAAY,KAAK,MAAM;GAAI,MAAM;CAAM;CAC5D,aAAa,KAAK,QAAQ,OAAO;CAEjC,MAAM,SAAS,WAAW,GAAG;CAC7B,MAAM,gBAAgB,OAAO,WAAW,CAAC,EAAA,CAAG,SAAS,GAAG;CACxD,YAAY,QAAQ,QAAQ,CAAC,GAAG,CAAC;CACjC,WAAW,KAAK,MAAM;CACtB,OAAO;EAAE;EAAQ;EAAK;CAAa;AACrC;AAQA,SAAgB,QAAQ,KAAqB,QAAgB,MAAgC;CAC3F,IAAI,CAAC,IAAI,QAAQ,SAAS,MAAM,GAC9B,MAAM,WAAW,GAAG,OAAO,sCAAsC;CAEnE,MAAM,SAAS,WAAW,GAAG;CAC7B,MAAM,UAAU,OAAO,WAAW,CAAC,EAAA,CAAG;CACtC,YAAY,QAAQ,QAAQ,IAAI;CAChC,MAAM,SAAS,OAAO,WAAW,CAAC,EAAA,CAAG;CACrC,WAAW,KAAK,MAAM;CACtB,OAAO;EAAE;EAAQ,UAAU,SAAS;CAAM;AAC5C;;;ACnMA,SAAgB,WAAmB;CACjC,OAAO,YAAY,CAAC,CAAC,CAAC,SAAS,WAAW;AAC5C;AAGA,SAAgB,YAAY,MAA0B,MAAuB;CAE3E,MAAM,QAAQ,SAAS,KAAK,CAAC,aAAa,WAAW,IAAI,CAAC;CAC1D,OAAO;EAAC,aAAa;EAAQ,aAAa;EAAQ,GAAG;CAAK,CAAC,CAAC,SAAS,QAAQ,EAAE;AACjF;AAGA,SAAgB,cAAc,QAA4B,MAAuB;CAC/E,IAAI,WAAW,KAAA,GAAW,OAAO;CACjC,OAAO,WAAW,oBAAoB,UAAU,WAAW,oBAAoB;AACjF;AAGA,SAAgB,aAAa,OAAsB,UAA2B;CAC5E,IAAI,UAAU,MAAM,OAAO;CAC3B,MAAM,IAAI,OAAO,KAAK,KAAK;CAC3B,MAAM,IAAI,OAAO,KAAK,QAAQ;CAC9B,OAAO,EAAE,WAAW,EAAE,UAAU,gBAAgB,GAAG,CAAC;AACtD;AAQA,MAAM,OAAqB;CAAE,IAAI;CAAM,QAAQ;CAAK,QAAQ;AAAG;AAE/D,SAAgB,aACd,SACA,KACA,EAAE,MAAM,SACM;CACd,IAAI,CAAC,YAAY,QAAQ,MAAM,IAAI,GACjC,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAK,QAAQ;CAAmC;CAE9E,IAAI,CAAC,cAAc,QAAQ,QAAQ,IAAI,GACrC,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAK,QAAQ;CAAiC;CAE5E,IAAI,CAAC,aAAa,IAAI,aAAa,IAAI,GAAG,GAAG,KAAK,GAChD,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAK,QAAQ;CAAyD;CAEpG,OAAO;AACT;;;ACpCA,MAAM,eAAe;AACrB,MAAM,WAAW;AAEjB,SAAS,KAAK,KAAqB,QAAgB,MAAqB;CACtE,IAAI,UAAU,QAAQ;EACpB,gBAAgB;EAChB,iBAAiB;CACnB,CAAC;CACD,IAAI,IAAI,KAAK,UAAU,IAAI,CAAC;AAC9B;AAEA,eAAe,SAAS,KAAwD;CAC9E,MAAM,SAAmB,CAAC;CAC1B,IAAI,OAAO;CACX,WAAW,MAAM,SAAS,KAAK;EAC7B,QAAS,MAAiB;EAC1B,IAAI,OAAO,UAAU,MAAM,IAAI,UAAU,KAAK,wBAAwB;EACtE,OAAO,KAAK,KAAe;CAC7B;CACA,IAAI,CAAC,OAAO,QAAQ,OAAO,CAAC;CAC5B,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,OAAO,OAAO,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC;CAC5D,QAAQ;EACN,MAAM,WAAW,wBAAwB;CAC3C;CAEA,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,MAAM,WAAW,4BAA4B;CAE/C,OAAO;AACT;AAEA,SAAS,QAAQ,KAAyB,MAAsB;CAC9D,IAAI;CACJ,IAAI;EACF,QAAQ,mBAAmB,OAAO,EAAE;CACtC,QAAQ;EACN,MAAM,WAAW,OAAO,KAAK,0CAA0C;CACzE;CACA,IAAI,CAAC,OAAO,MAAM,WAAW,wBAAwB,MAAM;CAC3D,OAAO;AACT;AAGA,SAAgB,gBAAgB,KAAqB,MAAc,OAAe;CAChF,OAAO,eAAe,OAAO,KAAsB,KAAoC;EAErF,IAAI;GACF,IAAI;GACJ,IAAI;IACF,MAAM,IAAI,IAAI,IAAI,OAAO,KAAK,oBAAoB,MAAM;GAC1D,QAAQ;IACN,MAAM,WAAW,uCAAuC;GAC1D;GAEA,MAAM,UAAU,aAAa;IAAE,MAAM,IAAI,QAAQ;IAAM,QAAQ,IAAI,QAAQ;GAAO,GAAG,KAAK;IACxF;IACA;GACF,CAAC;GACD,IAAI,CAAC,QAAQ,IAAI;IACf,KAAK,KAAK,QAAQ,QAAQ,EAAE,OAAO,QAAQ,OAAO,CAAC;IACnD;GACF;GAEA,IAAI,IAAI,WAAW,SAAS,IAAI,aAAa,cAAc;IACzD,KAAK,KAAK,KAAK,MAAM,WAAW,GAAG,CAAC;IACpC;GACF;GACA,IAAI,IAAI,WAAW,SAAS,IAAI,aAAa,eAAe;IAC1D,KAAK,KAAK,KAAK,MAAM,OAAO,GAAG,CAAC;IAChC;GACF;GACA,IAAI,IAAI,WAAW,SAAS,IAAI,SAAS,WAAW,eAAe,GAAG;IACpE,MAAM,OAAO,IAAI,SAAS,MAAM,EAAsB,CAAC,CAAC,MAAM,GAAG;IACjE,MAAM,SAAS,QAAQ,KAAK,IAAI,QAAQ;IACxC,MAAM,MAAM,QAAQ,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,GAAG,GAAG,KAAK;IAClD,MAAM,OAAO,MAAM,SAAS,GAAG;IAC/B,IAAI,OAAO,KAAK,SAAS,UAAU,MAAM,WAAW,8BAA4B;IAChF,KAAK,KAAK,KAAK,aAAa,KAAK,QAAQ,KAAK,KAAK,IAAI,CAAC;IACxD;GACF;GACA,IAAI,IAAI,WAAW,UAAU,IAAI,aAAa,gBAAgB;IAC5D,MAAM,OAAO,MAAM,SAAS,GAAG;IAC/B,IAAI,OAAO,KAAK,WAAW,UAAU,MAAM,WAAW,gCAA8B;IACpF,MAAM,OAAO,MAAM,QAAQ,KAAK,IAAI,IAChC,KAAK,KAAK,QAAQ,MAAmB,OAAO,MAAM,QAAQ,IAC1D,KAAA;IACJ,KAAK,KAAK,KAAK,QAAQ,KAAK,KAAK,QAAQ,IAAI,CAAC;IAC9C;GACF;GACA,KAAK,KAAK,KAAK,EAAE,OAAO,0BAA0B,IAAI,OAAO,GAAG,IAAI,WAAW,CAAC;EAClF,SAAS,OAAO;GAGd,KAAK,KAFU,iBAAiB,YAAY,MAAM,SAAS,KAEzC,EAAE,OAAO,MADX,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAC3B,IAAI,IAAI,EAAE,CAAC;EACvD;CACF;AACF;AAGA,SAAS,OAAO,QAAgB,MAAc,cAAuC;CACnF,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,WAAW,UAAiC;GAChD,IAAI,MAAM,SAAS,gBAAgB,eAAe,GAAG;IACnD,OAAO,eAAe,SAAS,OAAO;IACtC,QAAQ,OAAO,QAAQ,OAAO,GAAG,eAAe,CAAC,CAAC;IAClD;GACF;GACA,OAAO,KAAK;EACd;EACA,OAAO,KAAK,SAAS,OAAO;EAC5B,OAAO,OAAO,MAAM,mBAAmB;GACrC,OAAO,eAAe,SAAS,OAAO;GAEtC,MAAM,QAAQ,OAAO,QAAQ;GAC7B,QAAQ,OAAO,UAAU,YAAY,UAAU,OAAO,MAAM,OAAO,IAAI;EACzE,CAAC;CACH,CAAC;AACH;AAEA,eAAsB,YACpB,KACA,UAAwB,CAAC,GACF;CACvB,MAAM,QAAQ,QAAQ,SAAS,SAAS;CACxC,MAAM,SAAS,aAAa;CAE5B,IAAI,OAAO,QAAQ,QAAQ;CAC3B,OAAO,GAAG,YAAY,KAAK,QAAQ,KAAK,gBAAgB,KAAK,MAAM,KAAK,CAAC,CAAC,KAAK,GAAG,CAAC;CACnF,OAAO,MAAM,OAAO,QAAQ,MAAM,EAAE;CAEpC,OAAO;EACL;EACA;EACA;EACA,KAAK,oBAAoB,KAAK,eAAe;EAC7C,aACE,IAAI,SAAe,SAAS,WAC1B,OAAO,OAAO,UAAW,QAAQ,OAAO,KAAK,IAAI,QAAQ,CAAE,CAC7D;CACJ;AACF"}
1
+ {"version":3,"file":"index.js","names":["readJob"],"sources":["../src/http.ts","../src/triage.ts","../src/api.ts","../src/jobs.ts","../src/actions.ts","../src/assets.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 markDrafts,\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 1607 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 keys: string[];\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 had = drafts[locale] ?? [];\n // the keys it really cleared, because undo has to put back exactly those and nothing else\n const cleared = keys ? had.filter((key) => keys.includes(key)) : [...had];\n clearDrafts(drafts, locale, keys);\n saveDrafts(cfg, drafts);\n return { locale, approved: cleared.length, keys: cleared };\n}\n\n// Undo is not a nicety here: the flag records who wrote the text, so restoring it restores a fact.\nexport function unapprove(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 markDrafts(drafts, locale, keys);\n saveDrafts(cfg, drafts);\n return { locale, approved: keys.length, keys };\n}\n","import { badRequest, HttpError } from './http';\n\nexport type JobState = 'running' | 'done' | 'failed';\n\nexport interface Job {\n id: string;\n kind: 'translate';\n state: JobState;\n total: number;\n done: number;\n locale?: string;\n message?: string;\n result?: unknown;\n}\n\n// One at a time, because two runs over the same catalogs is a race over the same files.\nlet current: Job | null = null;\nlet counter = 0;\n\nexport function running(): Job | null {\n return current?.state === 'running' ? current : null;\n}\n\nexport function read(id: string): Job {\n if (!current || current.id !== id) throw new HttpError(404, `no job called \"${id}\"`);\n return current;\n}\n\nexport function start(kind: Job['kind'], total: number): Job {\n if (running()) throw badRequest('a job is already running, wait for it or read its progress');\n counter += 1;\n current = { id: `${kind}-${counter}`, kind, state: 'running', total, done: 0 };\n return current;\n}\n\n// A batch that failed still finishes: the bar reports work attempted, and message says what broke.\nexport function advance(id: string, keys: number, locale: string, error?: string): void {\n if (current?.id !== id) return;\n current.done = Math.min(current.done + keys, current.total);\n current.locale = locale;\n if (error) current.message = error;\n}\n\n// A run that writes part of its work and then fails is a success for what it wrote, so both land.\nexport function finish(id: string, state: 'done' | 'failed', patch: Partial<Job> = {}): void {\n if (current?.id === id) Object.assign(current, patch, { state });\n}\n","import {\n collectOrigins,\n extractProject,\n loadCatalogs,\n resolveProvider,\n syncCatalogs,\n translateCatalogs,\n writeCatalog,\n writeDts,\n markDrafts,\n loadDrafts,\n saveDrafts,\n} from '@verbaly/compiler';\nimport type { ResolvedConfig } from '@verbaly/compiler';\nimport { badRequest } from './http';\nimport { advance, finish, start, type Job } from './jobs';\n\nexport interface ExtractResult {\n added: Record<string, string[]>;\n // only the source locale counts as new text: a target gains a blank for every gap it had\n found: number;\n messages: number;\n}\n\n// Local, fast and free, so it answers in the same request instead of becoming a job.\nexport async function runExtract(cfg: ResolvedConfig): Promise<ExtractResult> {\n if (!cfg.include.length) {\n throw badRequest('source scanning is off (include: []), so there is nothing to extract');\n }\n const registry = await extractProject(cfg);\n const catalogs = loadCatalogs(cfg);\n const result = syncCatalogs(cfg, catalogs, registry);\n for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});\n // the types come from the source catalog, and cfg.dts false means the project opted out\n if (cfg.dts !== false) writeDts(cfg, catalogs[cfg.sourceLocale] ?? {}, cfg.dts);\n const found = result.added[cfg.sourceLocale]?.length ?? 0;\n return { added: result.added, found, messages: registry.messages().size };\n}\n\nexport interface Plan {\n pending: Record<string, string[]>;\n total: number;\n}\n\n// translate is the one command that spends money and minutes, so the panel always shows the bill.\nexport async function planTranslate(cfg: ResolvedConfig, locales?: string[]): Promise<Plan> {\n const provider = await resolveProvider(cfg);\n const catalogs = loadCatalogs(cfg);\n const result = await translateCatalogs(cfg, catalogs, provider, { locales, dryRun: true });\n const total = Object.values(result.pending).reduce((n, keys) => n + keys.length, 0);\n return { pending: result.pending, total };\n}\n\nexport async function startTranslate(cfg: ResolvedConfig, locales?: string[]): Promise<Job> {\n const provider = await resolveProvider(cfg);\n // the job carries its own denominator, so progress reads the same whether or not you asked first\n const plan = await planTranslate(cfg, locales);\n const job = start('translate', plan.total);\n const id = job.id;\n\n // the run outlives this request on purpose: the client asks the job how it is going\n void (async () => {\n try {\n const catalogs = loadCatalogs(cfg);\n const result = await translateCatalogs(cfg, catalogs, provider, {\n locales,\n batchSize: cfg.translate.batchSize,\n concurrency: cfg.translate.concurrency,\n retries: cfg.translate.retries,\n origins: await collectOrigins(cfg),\n onProgress: (p) => advance(id, p.keys, p.locale, p.error),\n });\n for (const [locale, keys] of Object.entries(result.translated)) {\n if (keys.length) writeCatalog(cfg, locale, catalogs[locale] ?? {});\n }\n // what a machine wrote stays a draft: the panel does not get to change that rule\n const drafts = loadDrafts(cfg);\n for (const [locale, keys] of Object.entries(result.translated)) {\n markDrafts(drafts, locale, keys);\n }\n saveDrafts(cfg, drafts);\n finish(id, result.failed.length ? 'failed' : 'done', {\n result,\n message: result.failed.length ? `${result.failed.length} batches did not answer` : undefined,\n });\n } catch (error) {\n finish(id, 'failed', { message: error instanceof Error ? error.message : String(error) });\n }\n })();\n\n return job;\n}\n","import { existsSync, readFileSync, statSync } from 'node:fs';\nimport { join, normalize, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\n// the built panel sits beside the compiled server, so it is found relative to this file, never cwd\nconst UI = resolve(fileURLToPath(new URL('.', import.meta.url)), 'ui');\n\nconst TYPES: Record<string, string> = {\n '.html': 'text/html; charset=utf-8',\n '.js': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.woff2': 'font/woff2',\n '.json': 'application/json; charset=utf-8',\n '.ico': 'image/x-icon',\n};\n\nexport interface Asset {\n body: Buffer;\n type: string;\n}\n\nexport const uiBuilt = (): boolean => existsSync(join(UI, 'index.html'));\n\n// A path is resolved and then checked to still be inside UI: ../ and an absolute one both land out.\nexport function readAsset(pathname: string): Asset | undefined {\n const wanted = pathname === '/' ? '/index.html' : pathname;\n const target = resolve(join(UI, normalize(wanted)));\n if (target !== UI && !target.startsWith(UI + (process.platform === 'win32' ? '\\\\' : '/'))) {\n return undefined;\n }\n if (!existsSync(target) || !statSync(target).isFile()) return undefined;\n const dot = target.lastIndexOf('.');\n return {\n body: readFileSync(target),\n type: TYPES[target.slice(dot).toLowerCase()] ?? 'application/octet-stream',\n };\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, unapprove, writeMessage } from './api';\nimport { planTranslate, runExtract, startTranslate } from './actions';\nimport { read as readJob, running } from './jobs';\nimport { readAsset, uiBuilt } from './assets';\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 api = url.pathname.startsWith('/api/');\n const verdict = guardRequest({ host: req.headers.host, origin: req.headers.origin }, url, {\n port,\n token,\n });\n // the panel files are the published npm package: only the project data needs a token\n if (!verdict.ok && (api || verdict.status !== 401)) {\n send(res, verdict.status, { error: verdict.reason });\n return;\n }\n\n if (!api && req.method === 'GET') {\n const asset = readAsset(url.pathname);\n if (asset) {\n res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'no-store' });\n res.end(asset.body);\n return;\n }\n if (url.pathname === '/' && !uiBuilt()) {\n throw new HttpError(500, 'the panel is not in this install, so only /api/* answers here');\n }\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 if (body.undo === true) {\n if (!keys?.length) throw badRequest('undoing needs the \"keys\" it should put back');\n send(res, 200, unapprove(cfg, body.locale, keys));\n return;\n }\n send(res, 200, approve(cfg, body.locale, keys));\n return;\n }\n if (req.method === 'POST' && url.pathname === '/api/extract') {\n send(res, 200, await runExtract(cfg));\n return;\n }\n if (req.method === 'GET' && url.pathname === '/api/translate') {\n const locales = url.searchParams.getAll('locale');\n send(res, 200, await planTranslate(cfg, locales.length ? locales : undefined));\n return;\n }\n if (req.method === 'POST' && url.pathname === '/api/translate') {\n const body = await readJson(req);\n const locales = Array.isArray(body.locales)\n ? body.locales.filter((l): l is string => typeof l === 'string')\n : undefined;\n send(res, 200, await startTranslate(cfg, locales?.length ? locales : undefined));\n return;\n }\n // a reload loses the id, so the panel asks what is running rather than losing the bar\n if (req.method === 'GET' && url.pathname === '/api/job') {\n send(res, 200, running());\n return;\n }\n if (req.method === 'GET' && url.pathname.startsWith('/api/job/')) {\n send(res, 200, readJob(segment(url.pathname.slice('/api/job/'.length), 'job id')));\n return;\n }\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}/?t=${token}`,\n close: () =>\n new Promise<void>((resolve, reject) =>\n server.close((error) => (error ? reject(error) : resolve())),\n ),\n };\n}\n"],"mappings":";;;;;;;AACA,IAAa,YAAb,cAA+B,MAAM;CACnC;CAEA,YAAY,QAAgB,SAAiB;EAC3C,MAAM,QAAQ,WAAW,WAAW,IAAI,UAAU,aAAa,SAAS;EACxE,KAAK,SAAS;EACd,KAAK,OAAO;CACd;AACF;AAEA,MAAa,cAAc,YAAoB,IAAI,UAAU,KAAK,OAAO;AAGzE,SAAgB,MAAM,SAAiB,MAAsB;CAC3D,OAAO,OAAO,QAAQ,MAAM,IAAI,CAAC,CAAC,KAAK,GAAG,IAAI;AAChD;;;ACEA,MAAM,SAAS;AACf,MAAM,OAAO;AACb,MAAM,OAAO;AAEb,SAAS,IAAI,MAAc,IAAY,WAA6C;CAClF,MAAM,QAAQ,KAAK,MAAM,EAAE,KAAK,CAAC;CACjC,QAAQ,YAAY,MAAM,IAAI,SAAS,IAAI,MAAA,CAAO,KAAK;AACzD;AAEA,SAAS,KAAK,GAAa,GAAsB;CAC/C,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,GAAG,MAAM,MAAM,EAAE,EAAE;AAC9D;AAGA,MAAM,aAAa,MAAc,EAAE,QAAQ,iBAAiB,EAAE;AAE9D,MAAM,cAAc,MAAc,UAAU,CAAC,CAAC,CAAC,WAAW,SAAS,GAAG;AAEtE,SAAS,KAAK,KAAmB,KAAa,QAA4B;CACxE,CAAC,IAAI,SAAS,CAAC,EAAA,CAAG,KAAK,MAAM;AAC/B;AAEA,SAAgB,OAAO,EAAE,QAAQ,QAAQ,YAAuC;CAE9E,MAAM,MAAoB,OAAO,OAAO,IAAI;CAC5C,MAAM,OAAO,OAAO,KAAK,MAAM;CAG/B,MAAM,WAAqC,OAAO,OAAO,IAAI;CAC7D,KAAK,MAAM,OAAO,MAAM;EACtB,MAAM,OAAO,OAAO,IAAI,EAAE,KAAK;EAC/B,IAAI,MAAM,CAAC,SAAS,UAAU,CAAC,EAAA,CAAG,KAAK,GAAG;CAC5C;CACA,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,GAAG;EACpD,IAAI,MAAM,SAAS,GAAG;EACtB,MAAM,YAAY,SAAS,MACxB,YAAY,IAAI,IAAI,MAAM,KAAK,MAAM,QAAQ,EAAE,EAAE,KAAK,KAAK,EAAE,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,CAAC,OAAO,CAC1F;EACA,IAAI,WACF,KAAK,MAAM,OAAO,OAChB,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAa,MAAM,IAAI,KAAK;EAAmC,CAAC;EAI7F,MAAM,UAAU,IAAI,IAAI,MAAM,KAAK,QAAQ,OAAO,IAAI,EAAE,KAAK,KAAK,EAAE,CAAC,CAAC,OAAO,OAAO,CAAC;EACrF,IAAI,CAAC,aAAa,QAAQ,OAAO,GAC/B,KAAK,MAAM,OAAO,OAChB,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAa,MAAM,IAAI,KAAK;EAA2C,CAAC;CAGvG;CAEA,KAAK,MAAM,OAAO,MAAM;EACtB,MAAM,MAAM,OAAO,IAAI,EAAE,KAAK,KAAK;EACnC,MAAM,MAAM,OAAO,IAAI,EAAE,KAAK,KAAK;EACnC,IAAI,CAAC,OAAO,CAAC,KAAK;EAGlB,MAAM,WAAW,SAAS,KAAK,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,QAAQ,MAAM,MAAM,KAAA,KAAa,MAAM,EAAE;EAC9F,IAAI,QAAQ,OAAO,SAAS,SAAS,KAAK,SAAS,OAAO,MAAM,MAAM,GAAG,GACvE,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAQ,MAAM;EAAgC,CAAC;EAI1E,MAAM,YAAY,IAAI,KAAK,QAAQ,UAAU;EAC7C,IAAI,UAAU,SAAS,KAAK,CAAC,KAAK,WAAW,IAAI,KAAK,QAAQ,UAAU,CAAC,GACvE,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAU,MAAM;EAAsC,CAAC;EAElF,IAAI,CAAC,KAAK,IAAI,KAAK,MAAM,SAAS,GAAG,IAAI,KAAK,MAAM,SAAS,CAAC,GAC5D,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAO,MAAM;EAAgB,CAAC;EAEzD,IAAI,CAAC,KAAK,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,CAAC,GACtC,KAAK,KAAK,KAAK;GAAE,QAAQ;GAAQ,MAAM;EAAwC,CAAC;CAEpF;CAEA,OAAO;AACT;;;AC1CA,SAAS,QAAQ,KAAwE;CACvF,MAAM,WAAqB,CAAC;CAC5B,MAAM,WAA4B,CAAC;CAEnC,IAAI,CAAC,WAAW,IAAI,GAAG,GAAG;EACxB,MAAM,MAAM,SAAS,IAAI,MAAM,IAAI,GAAG,CAAC,CAAC,WAAW,MAAM,GAAG;EAC5D,SAAS,KAAK;GAAE,OAAO;GAAK,SAAS;EAAmE,CAAC;CAC3G;CACA,KAAK,MAAM,UAAU,IAAI,SACvB,IAAI;EACF,SAAS,UAAU,YAAY,KAAK,MAAM;CAC5C,SAAS,OAAO;EACd,SAAS,UAAU,CAAC;EACpB,SAAS,KAAK;GACZ,OAAO;GACP,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EAChE,CAAC;CACH;CAEF,OAAO;EAAE;EAAU;CAAS;AAC9B;AAGA,SAAS,WAAW,KAAoE;CACtF,IAAI;EACF,OAAO;GAAE,QAAQ,WAAW,GAAG;GAAG,UAAU,CAAC;EAAE;CACjD,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,OAAO;GAAE,QAAQ,CAAC;GAAG,UAAU,CAAC;IAAE,OAAO;IAAa;GAAQ,CAAC;EAAE;CACnE;AACF;AAGA,SAAS,WAAW,KAAqB,QAAkC;CACzE,OAAO;EACL,GAAG;EACH,SAAS,OAAO,QAAQ,KAAK,WAAW;GACtC,GAAG;GACH,OAAO,MAAM,MAAM,KAAK,SAAS,SAAS,IAAI,MAAM,IAAI,CAAC,CAAC,WAAW,MAAM,GAAG,CAAC;EACjF,EAAE;CACJ;AACF;AAGA,eAAsB,WAAW,KAA2C;CAC1E,MAAM,EAAE,UAAU,aAAa,QAAQ,GAAG;CAC1C,MAAM,WAAW,MAAM,eAAe,GAAG;CACzC,MAAM,UAAU,WAAW,GAAG;CAC9B,SAAS,KAAK,GAAG,QAAQ,QAAQ;CACjC,MAAM,OAAO,gBAAgB,QAAQ,QAAQ,QAAQ;CACrD,MAAM,SAAU,SAAS,IAAI,iBAAiB,CAAC;CAC/C,MAAM,UAAU,cAAc,GAAG;CAEjC,KAAK,MAAM,EAAE,MAAM,aAAa,SAAS,YAAY,GACnD,SAAS,KAAK;EAAE,OAAO,SAAS,IAAI,MAAM,IAAI,CAAC,CAAC,WAAW,MAAM,GAAG;EAAG;CAAQ,CAAC;CAGlF,MAAM,YAA0C,CAAC;CACjD,KAAK,MAAM,UAAU,SACnB,UAAU,UAAU,OAAO;EACzB;EACA,QAAS,SAAS,WAAW,CAAC;EAE9B,UAAU,QACP,QAAQ,UAAU,UAAU,UAAU,EAAE,KAAK,UAAU,CAAC,EAAA,CAAG,MAAM,CAAC,CAClE,KAAK,UAAW,SAAS,UAAU,CAAC,CAAa;CACtD,CAAC;CAGH,OAAO;EACL,MAAM,IAAI;EACV,KAAK,IAAI;EACT,cAAc,IAAI;EAClB,SAAS,IAAI;EAEb,UAAU,IAAI,QAAQ,SAAS;EAC/B;EACA,SAAS,MAAM,eAAe,KAAK,QAAQ;EAC3C,QAAQ,OAAO,KAAK,UAAU,UAAU,IAAI;EAC5C,OAAO,WAAW,KAAK,MAAM,KAAK,UAAU,QAAQ,CAAC;EACrD,QAAQ;EACR,QAAQ;EACR;CACF;AACF;AAEA,SAAgB,OAAO,KAA4C;CACjE,OAAO,OAAO,GAAG;AACnB;AASA,SAAgB,aACd,KACA,QACA,KACA,MACoB;CACpB,IAAI,WAAW,IAAI,cACjB,MAAM,WAAW,2DAA2D;CAE9E,IAAI,CAAC,IAAI,QAAQ,SAAS,MAAM,GAC9B,MAAM,WAAW,GAAG,OAAO,sCAAsC;CAEnE,MAAM,SAAS,YAAY,KAAK,IAAI,YAAY;CAEhD,IAAI,CAAC,OAAO,OAAO,QAAQ,GAAG,GAC5B,MAAM,WAAW,IAAI,IAAI,6DAA6D;CAGxF,MAAM,QAAQ,KAAK,KAAK,IAAI,OAAO;CAKnC,MAAM,UAHS,QACX,CAAC,GAAG,gBAAgB,OAAO,MAAM,GAAG,GAAG,aAAa,OAAO,MAAgB,KAAK,CAAC,IACjF,CAAC,EAAA,CACiB,QAAQ,UAAU,MAAM,aAAa,OAAO;CAClE,IAAI,OAAO,QACT,MAAM,WAAW,GAAG,OAAO,KAAK,UAAU,MAAM,OAAO,CAAC,CAAC,KAAK,IAAI,GAAG;CAGvE,MAAM,UAAU;EAAE,GAAG,YAAY,KAAK,MAAM;GAAI,MAAM;CAAM;CAC5D,aAAa,KAAK,QAAQ,OAAO;CAEjC,MAAM,SAAS,WAAW,GAAG;CAC7B,MAAM,gBAAgB,OAAO,WAAW,CAAC,EAAA,CAAG,SAAS,GAAG;CACxD,YAAY,QAAQ,QAAQ,CAAC,GAAG,CAAC;CACjC,WAAW,KAAK,MAAM;CACtB,OAAO;EAAE;EAAQ;EAAK;CAAa;AACrC;AASA,SAAgB,QAAQ,KAAqB,QAAgB,MAAgC;CAC3F,IAAI,CAAC,IAAI,QAAQ,SAAS,MAAM,GAC9B,MAAM,WAAW,GAAG,OAAO,sCAAsC;CAEnE,MAAM,SAAS,WAAW,GAAG;CAC7B,MAAM,MAAM,OAAO,WAAW,CAAC;CAE/B,MAAM,UAAU,OAAO,IAAI,QAAQ,QAAQ,KAAK,SAAS,GAAG,CAAC,IAAI,CAAC,GAAG,GAAG;CACxE,YAAY,QAAQ,QAAQ,IAAI;CAChC,WAAW,KAAK,MAAM;CACtB,OAAO;EAAE;EAAQ,UAAU,QAAQ;EAAQ,MAAM;CAAQ;AAC3D;AAGA,SAAgB,UAAU,KAAqB,QAAgB,MAA+B;CAC5F,IAAI,CAAC,IAAI,QAAQ,SAAS,MAAM,GAC9B,MAAM,WAAW,GAAG,OAAO,sCAAsC;CAEnE,MAAM,SAAS,WAAW,GAAG;CAC7B,WAAW,QAAQ,QAAQ,IAAI;CAC/B,WAAW,KAAK,MAAM;CACtB,OAAO;EAAE;EAAQ,UAAU,KAAK;EAAQ;CAAK;AAC/C;;;ACzMA,IAAI,UAAsB;AAC1B,IAAI,UAAU;AAEd,SAAgB,UAAsB;CACpC,OAAO,SAAS,UAAU,YAAY,UAAU;AAClD;AAEA,SAAgB,KAAK,IAAiB;CACpC,IAAI,CAAC,WAAW,QAAQ,OAAO,IAAI,MAAM,IAAI,UAAU,KAAK,kBAAkB,GAAG,EAAE;CACnF,OAAO;AACT;AAEA,SAAgB,MAAM,MAAmB,OAAoB;CAC3D,IAAI,QAAQ,GAAG,MAAM,WAAW,4DAA4D;CAC5F,WAAW;CACX,UAAU;EAAE,IAAI,GAAG,KAAK,GAAG;EAAW;EAAM,OAAO;EAAW;EAAO,MAAM;CAAE;CAC7E,OAAO;AACT;AAGA,SAAgB,QAAQ,IAAY,MAAc,QAAgB,OAAsB;CACtF,IAAI,SAAS,OAAO,IAAI;CACxB,QAAQ,OAAO,KAAK,IAAI,QAAQ,OAAO,MAAM,QAAQ,KAAK;CAC1D,QAAQ,SAAS;CACjB,IAAI,OAAO,QAAQ,UAAU;AAC/B;AAGA,SAAgB,OAAO,IAAY,OAA0B,QAAsB,CAAC,GAAS;CAC3F,IAAI,SAAS,OAAO,IAAI,OAAO,OAAO,SAAS,OAAO,EAAE,MAAM,CAAC;AACjE;;;ACrBA,eAAsB,WAAW,KAA6C;CAC5E,IAAI,CAAC,IAAI,QAAQ,QACf,MAAM,WAAW,sEAAsE;CAEzF,MAAM,WAAW,MAAM,eAAe,GAAG;CACzC,MAAM,WAAW,aAAa,GAAG;CACjC,MAAM,SAAS,aAAa,KAAK,UAAU,QAAQ;CACnD,KAAK,MAAM,UAAU,IAAI,SAAS,aAAa,KAAK,QAAQ,SAAS,WAAW,CAAC,CAAC;CAElF,IAAI,IAAI,QAAQ,OAAO,SAAS,KAAK,SAAS,IAAI,iBAAiB,CAAC,GAAG,IAAI,GAAG;CAC9E,MAAM,QAAQ,OAAO,MAAM,IAAI,aAAa,EAAE,UAAU;CACxD,OAAO;EAAE,OAAO,OAAO;EAAO;EAAO,UAAU,SAAS,SAAS,CAAC,CAAC;CAAK;AAC1E;AAQA,eAAsB,cAAc,KAAqB,SAAmC;CAC1F,MAAM,WAAW,MAAM,gBAAgB,GAAG;CAC1C,MAAM,WAAW,aAAa,GAAG;CACjC,MAAM,SAAS,MAAM,kBAAkB,KAAK,UAAU,UAAU;EAAE;EAAS,QAAQ;CAAK,CAAC;CACzF,MAAM,QAAQ,OAAO,OAAO,OAAO,OAAO,CAAC,CAAC,QAAQ,GAAG,SAAS,IAAI,KAAK,QAAQ,CAAC;CAClF,OAAO;EAAE,SAAS,OAAO;EAAS;CAAM;AAC1C;AAEA,eAAsB,eAAe,KAAqB,SAAkC;CAC1F,MAAM,WAAW,MAAM,gBAAgB,GAAG;CAG1C,MAAM,MAAM,MAAM,cAAa,MADZ,cAAc,KAAK,OAAO,EACd,CAAK,KAAK;CACzC,MAAM,KAAK,IAAI;CAGf,CAAM,YAAY;EAChB,IAAI;GACF,MAAM,WAAW,aAAa,GAAG;GACjC,MAAM,SAAS,MAAM,kBAAkB,KAAK,UAAU,UAAU;IAC9D;IACA,WAAW,IAAI,UAAU;IACzB,aAAa,IAAI,UAAU;IAC3B,SAAS,IAAI,UAAU;IACvB,SAAS,MAAM,eAAe,GAAG;IACjC,aAAa,MAAM,QAAQ,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK;GAC1D,CAAC;GACD,KAAK,MAAM,CAAC,QAAQ,SAAS,OAAO,QAAQ,OAAO,UAAU,GAC3D,IAAI,KAAK,QAAQ,aAAa,KAAK,QAAQ,SAAS,WAAW,CAAC,CAAC;GAGnE,MAAM,SAAS,WAAW,GAAG;GAC7B,KAAK,MAAM,CAAC,QAAQ,SAAS,OAAO,QAAQ,OAAO,UAAU,GAC3D,WAAW,QAAQ,QAAQ,IAAI;GAEjC,WAAW,KAAK,MAAM;GACtB,OAAO,IAAI,OAAO,OAAO,SAAS,WAAW,QAAQ;IACnD;IACA,SAAS,OAAO,OAAO,SAAS,GAAG,OAAO,OAAO,OAAO,2BAA2B,KAAA;GACrF,CAAC;EACH,SAAS,OAAO;GACd,OAAO,IAAI,UAAU,EAAE,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,CAAC;EAC1F;CACF,EAAA,CAAG;CAEH,OAAO;AACT;;;ACtFA,MAAM,KAAK,QAAQ,cAAc,IAAI,IAAI,KAAK,YAAY,GAAG,CAAC,GAAG,IAAI;AAErE,MAAM,QAAgC;CACpC,SAAS;CACT,OAAO;CACP,QAAQ;CACR,QAAQ;CACR,UAAU;CACV,SAAS;CACT,QAAQ;AACV;AAOA,MAAa,gBAAyB,WAAW,KAAK,IAAI,YAAY,CAAC;AAGvE,SAAgB,UAAU,UAAqC;CAE7D,MAAM,SAAS,QAAQ,KAAK,IAAI,UADjB,aAAa,MAAM,gBAAgB,QACF,CAAC,CAAC;CAClD,IAAI,WAAW,MAAM,CAAC,OAAO,WAAW,MAAM,QAAQ,aAAa,UAAU,OAAO,IAAI,GACtF;CAEF,IAAI,CAAC,WAAW,MAAM,KAAK,CAAC,SAAS,MAAM,CAAC,CAAC,OAAO,GAAG,OAAO,KAAA;CAC9D,MAAM,MAAM,OAAO,YAAY,GAAG;CAClC,OAAO;EACL,MAAM,aAAa,MAAM;EACzB,MAAM,MAAM,OAAO,MAAM,GAAG,CAAC,CAAC,YAAY,MAAM;CAClD;AACF;;;AC7BA,SAAgB,WAAmB;CACjC,OAAO,YAAY,CAAC,CAAC,CAAC,SAAS,WAAW;AAC5C;AAGA,SAAgB,YAAY,MAA0B,MAAuB;CAE3E,MAAM,QAAQ,SAAS,KAAK,CAAC,aAAa,WAAW,IAAI,CAAC;CAC1D,OAAO;EAAC,aAAa;EAAQ,aAAa;EAAQ,GAAG;CAAK,CAAC,CAAC,SAAS,QAAQ,EAAE;AACjF;AAGA,SAAgB,cAAc,QAA4B,MAAuB;CAC/E,IAAI,WAAW,KAAA,GAAW,OAAO;CACjC,OAAO,WAAW,oBAAoB,UAAU,WAAW,oBAAoB;AACjF;AAGA,SAAgB,aAAa,OAAsB,UAA2B;CAC5E,IAAI,UAAU,MAAM,OAAO;CAC3B,MAAM,IAAI,OAAO,KAAK,KAAK;CAC3B,MAAM,IAAI,OAAO,KAAK,QAAQ;CAC9B,OAAO,EAAE,WAAW,EAAE,UAAU,gBAAgB,GAAG,CAAC;AACtD;AAQA,MAAM,OAAqB;CAAE,IAAI;CAAM,QAAQ;CAAK,QAAQ;AAAG;AAE/D,SAAgB,aACd,SACA,KACA,EAAE,MAAM,SACM;CACd,IAAI,CAAC,YAAY,QAAQ,MAAM,IAAI,GACjC,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAK,QAAQ;CAAmC;CAE9E,IAAI,CAAC,cAAc,QAAQ,QAAQ,IAAI,GACrC,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAK,QAAQ;CAAiC;CAE5E,IAAI,CAAC,aAAa,IAAI,aAAa,IAAI,GAAG,GAAG,KAAK,GAChD,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAK,QAAQ;CAAyD;CAEpG,OAAO;AACT;;;ACjCA,MAAM,eAAe;AACrB,MAAM,WAAW;AAEjB,SAAS,KAAK,KAAqB,QAAgB,MAAqB;CACtE,IAAI,UAAU,QAAQ;EACpB,gBAAgB;EAChB,iBAAiB;CACnB,CAAC;CACD,IAAI,IAAI,KAAK,UAAU,IAAI,CAAC;AAC9B;AAEA,eAAe,SAAS,KAAwD;CAC9E,MAAM,SAAmB,CAAC;CAC1B,IAAI,OAAO;CACX,WAAW,MAAM,SAAS,KAAK;EAC7B,QAAS,MAAiB;EAC1B,IAAI,OAAO,UAAU,MAAM,IAAI,UAAU,KAAK,wBAAwB;EACtE,OAAO,KAAK,KAAe;CAC7B;CACA,IAAI,CAAC,OAAO,QAAQ,OAAO,CAAC;CAC5B,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,OAAO,OAAO,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC;CAC5D,QAAQ;EACN,MAAM,WAAW,wBAAwB;CAC3C;CAEA,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,MAAM,WAAW,4BAA4B;CAE/C,OAAO;AACT;AAEA,SAAS,QAAQ,KAAyB,MAAsB;CAC9D,IAAI;CACJ,IAAI;EACF,QAAQ,mBAAmB,OAAO,EAAE;CACtC,QAAQ;EACN,MAAM,WAAW,OAAO,KAAK,0CAA0C;CACzE;CACA,IAAI,CAAC,OAAO,MAAM,WAAW,wBAAwB,MAAM;CAC3D,OAAO;AACT;AAGA,SAAgB,gBAAgB,KAAqB,MAAc,OAAe;CAChF,OAAO,eAAe,OAAO,KAAsB,KAAoC;EAErF,IAAI;GACF,IAAI;GACJ,IAAI;IACF,MAAM,IAAI,IAAI,IAAI,OAAO,KAAK,oBAAoB,MAAM;GAC1D,QAAQ;IACN,MAAM,WAAW,uCAAuC;GAC1D;GAEA,MAAM,MAAM,IAAI,SAAS,WAAW,OAAO;GAC3C,MAAM,UAAU,aAAa;IAAE,MAAM,IAAI,QAAQ;IAAM,QAAQ,IAAI,QAAQ;GAAO,GAAG,KAAK;IACxF;IACA;GACF,CAAC;GAED,IAAI,CAAC,QAAQ,OAAO,OAAO,QAAQ,WAAW,MAAM;IAClD,KAAK,KAAK,QAAQ,QAAQ,EAAE,OAAO,QAAQ,OAAO,CAAC;IACnD;GACF;GAEA,IAAI,CAAC,OAAO,IAAI,WAAW,OAAO;IAChC,MAAM,QAAQ,UAAU,IAAI,QAAQ;IACpC,IAAI,OAAO;KACT,IAAI,UAAU,KAAK;MAAE,gBAAgB,MAAM;MAAM,iBAAiB;KAAW,CAAC;KAC9E,IAAI,IAAI,MAAM,IAAI;KAClB;IACF;IACA,IAAI,IAAI,aAAa,OAAO,CAAC,QAAQ,GACnC,MAAM,IAAI,UAAU,KAAK,+DAA+D;GAE5F;GAEA,IAAI,IAAI,WAAW,SAAS,IAAI,aAAa,cAAc;IACzD,KAAK,KAAK,KAAK,MAAM,WAAW,GAAG,CAAC;IACpC;GACF;GACA,IAAI,IAAI,WAAW,SAAS,IAAI,aAAa,eAAe;IAC1D,KAAK,KAAK,KAAK,MAAM,OAAO,GAAG,CAAC;IAChC;GACF;GACA,IAAI,IAAI,WAAW,SAAS,IAAI,SAAS,WAAW,eAAe,GAAG;IACpE,MAAM,OAAO,IAAI,SAAS,MAAM,EAAsB,CAAC,CAAC,MAAM,GAAG;IACjE,MAAM,SAAS,QAAQ,KAAK,IAAI,QAAQ;IACxC,MAAM,MAAM,QAAQ,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,GAAG,GAAG,KAAK;IAClD,MAAM,OAAO,MAAM,SAAS,GAAG;IAC/B,IAAI,OAAO,KAAK,SAAS,UAAU,MAAM,WAAW,8BAA4B;IAChF,KAAK,KAAK,KAAK,aAAa,KAAK,QAAQ,KAAK,KAAK,IAAI,CAAC;IACxD;GACF;GACA,IAAI,IAAI,WAAW,UAAU,IAAI,aAAa,gBAAgB;IAC5D,MAAM,OAAO,MAAM,SAAS,GAAG;IAC/B,IAAI,OAAO,KAAK,WAAW,UAAU,MAAM,WAAW,gCAA8B;IACpF,MAAM,OAAO,MAAM,QAAQ,KAAK,IAAI,IAChC,KAAK,KAAK,QAAQ,MAAmB,OAAO,MAAM,QAAQ,IAC1D,KAAA;IACJ,IAAI,KAAK,SAAS,MAAM;KACtB,IAAI,CAAC,MAAM,QAAQ,MAAM,WAAW,+CAA6C;KACjF,KAAK,KAAK,KAAK,UAAU,KAAK,KAAK,QAAQ,IAAI,CAAC;KAChD;IACF;IACA,KAAK,KAAK,KAAK,QAAQ,KAAK,KAAK,QAAQ,IAAI,CAAC;IAC9C;GACF;GACA,IAAI,IAAI,WAAW,UAAU,IAAI,aAAa,gBAAgB;IAC5D,KAAK,KAAK,KAAK,MAAM,WAAW,GAAG,CAAC;IACpC;GACF;GACA,IAAI,IAAI,WAAW,SAAS,IAAI,aAAa,kBAAkB;IAC7D,MAAM,UAAU,IAAI,aAAa,OAAO,QAAQ;IAChD,KAAK,KAAK,KAAK,MAAM,cAAc,KAAK,QAAQ,SAAS,UAAU,KAAA,CAAS,CAAC;IAC7E;GACF;GACA,IAAI,IAAI,WAAW,UAAU,IAAI,aAAa,kBAAkB;IAC9D,MAAM,OAAO,MAAM,SAAS,GAAG;IAC/B,MAAM,UAAU,MAAM,QAAQ,KAAK,OAAO,IACtC,KAAK,QAAQ,QAAQ,MAAmB,OAAO,MAAM,QAAQ,IAC7D,KAAA;IACJ,KAAK,KAAK,KAAK,MAAM,eAAe,KAAK,SAAS,SAAS,UAAU,KAAA,CAAS,CAAC;IAC/E;GACF;GAEA,IAAI,IAAI,WAAW,SAAS,IAAI,aAAa,YAAY;IACvD,KAAK,KAAK,KAAK,QAAQ,CAAC;IACxB;GACF;GACA,IAAI,IAAI,WAAW,SAAS,IAAI,SAAS,WAAW,WAAW,GAAG;IAChE,KAAK,KAAK,KAAKA,KAAQ,QAAQ,IAAI,SAAS,MAAM,CAAkB,GAAG,QAAQ,CAAC,CAAC;IACjF;GACF;GAEA,KAAK,KAAK,KAAK,EAAE,OAAO,0BAA0B,IAAI,OAAO,GAAG,IAAI,WAAW,CAAC;EAClF,SAAS,OAAO;GAGd,KAAK,KAFU,iBAAiB,YAAY,MAAM,SAAS,KAEzC,EAAE,OAAO,MADX,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAC3B,IAAI,IAAI,EAAE,CAAC;EACvD;CACF;AACF;AAGA,SAAS,OAAO,QAAgB,MAAc,cAAuC;CACnF,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,WAAW,UAAiC;GAChD,IAAI,MAAM,SAAS,gBAAgB,eAAe,GAAG;IACnD,OAAO,eAAe,SAAS,OAAO;IACtC,QAAQ,OAAO,QAAQ,OAAO,GAAG,eAAe,CAAC,CAAC;IAClD;GACF;GACA,OAAO,KAAK;EACd;EACA,OAAO,KAAK,SAAS,OAAO;EAC5B,OAAO,OAAO,MAAM,mBAAmB;GACrC,OAAO,eAAe,SAAS,OAAO;GAEtC,MAAM,QAAQ,OAAO,QAAQ;GAC7B,QAAQ,OAAO,UAAU,YAAY,UAAU,OAAO,MAAM,OAAO,IAAI;EACzE,CAAC;CACH,CAAC;AACH;AAEA,eAAsB,YACpB,KACA,UAAwB,CAAC,GACF;CACvB,MAAM,QAAQ,QAAQ,SAAS,SAAS;CACxC,MAAM,SAAS,aAAa;CAE5B,IAAI,OAAO,QAAQ,QAAQ;CAC3B,OAAO,GAAG,YAAY,KAAK,QAAQ,KAAK,gBAAgB,KAAK,MAAM,KAAK,CAAC,CAAC,KAAK,GAAG,CAAC;CACnF,OAAO,MAAM,OAAO,QAAQ,MAAM,EAAE;CAEpC,OAAO;EACL;EACA;EACA;EACA,KAAK,oBAAoB,KAAK,MAAM;EACpC,aACE,IAAI,SAAe,SAAS,WAC1B,OAAO,OAAO,UAAW,QAAQ,OAAO,KAAK,IAAI,QAAQ,CAAE,CAC7D;CACJ;AACF"}
@@ -0,0 +1,248 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { ACTION, messages } from './words';
4
+ import type { Job, Plan, StudioApi } from './api';
5
+
6
+ interface Props {
7
+ api: StudioApi;
8
+ shown: string[];
9
+ onDone: (said: string) => Promise<void> | void;
10
+ }
11
+
12
+ const { api, shown, onDone }: Props = $props();
13
+
14
+ type Phase =
15
+ | { at: 'idle' }
16
+ | { at: 'finding' }
17
+ | { at: 'planning' }
18
+ | { at: 'plan'; plan: Plan }
19
+ | { at: 'running'; job: Job }
20
+ | { at: 'said'; text: string; bad: boolean };
21
+
22
+ // the annotation goes on the rune: on the declaration, phase narrows to its first member
23
+ let phase = $state<Phase>({ at: 'idle' });
24
+ const busy = $derived(phase.at === 'finding' || phase.at === 'planning' || phase.at === 'running');
25
+
26
+ function said(text: string, bad = false) {
27
+ phase = { at: 'said', text, bad };
28
+ }
29
+
30
+ async function find() {
31
+ phase = { at: 'finding' };
32
+ const answer = await api.extract();
33
+ if (answer.error || !answer.value) return said(answer.error ?? 'no answer', true);
34
+ const { found } = answer.value;
35
+ if (!found) return said(ACTION.nothingNew);
36
+ const wrote = `${messages(found)} your code uses and your catalogs did not have`;
37
+ await onDone(wrote);
38
+ said(wrote);
39
+ }
40
+
41
+ async function plan() {
42
+ phase = { at: 'planning' };
43
+ const answer = await api.plan(shown);
44
+ if (answer.error || !answer.value) return said(answer.error ?? 'no answer', true);
45
+ if (!answer.value.total) return said(ACTION.nothingMissing);
46
+ phase = { at: 'plan', plan: answer.value };
47
+ }
48
+
49
+ // the bar is asked, never pushed: translate writes partial results, so a reload must not lose it
50
+ async function poll(id: string) {
51
+ const answer = await api.job(id);
52
+ if (answer.error || !answer.value) return said(answer.error ?? 'no answer', true);
53
+ const job = answer.value;
54
+ if (job.state === 'running') {
55
+ phase = { at: 'running', job };
56
+ return;
57
+ }
58
+ // every translation a machine wrote is a draft, and the panel has to say so out loud
59
+ const wrote = `${messages(job.done)} translated, unread until you read them`;
60
+ await onDone(wrote);
61
+ said(job.state === 'failed' ? (job.message ?? 'the run failed') : wrote, job.state === 'failed');
62
+ }
63
+
64
+ async function go() {
65
+ const answer = await api.translate(shown);
66
+ if (answer.error || !answer.value) return said(answer.error ?? 'no answer', true);
67
+ phase = { at: 'running', job: answer.value };
68
+ }
69
+
70
+ // a run outlives the page that started it, so the bar comes back instead of the job hiding
71
+ onMount(() => {
72
+ void api.job().then((answer) => {
73
+ if (answer.value && phase.at === 'idle') phase = { at: 'running', job: answer.value };
74
+ });
75
+ });
76
+
77
+ $effect(() => {
78
+ if (phase.at !== 'running') return;
79
+ const id = phase.job.id;
80
+ const timer = setInterval(() => void poll(id), 1000);
81
+ return () => clearInterval(timer);
82
+ });
83
+
84
+ const share = $derived(
85
+ phase.at === 'running' && phase.job.total ? phase.job.done / phase.job.total : 0,
86
+ );
87
+ </script>
88
+
89
+ <section class="acts" aria-label="Commands">
90
+ <h2 class="cap">Commands</h2>
91
+
92
+ {#if phase.at === 'plan'}
93
+ <div class="bill">
94
+ <p class="lede">{messages(phase.plan.total)} in {Object.keys(phase.plan.pending).join(', ')}</p>
95
+ <p class="why">{ACTION.translateWhy}</p>
96
+ <div class="pair">
97
+ <button class="go" onclick={go}>{ACTION.translate}</button>
98
+ <button class="plain" onclick={() => (phase = { at: 'idle' })}>{ACTION.cancel}</button>
99
+ </div>
100
+ </div>
101
+ {:else if phase.at === 'running'}
102
+ <div class="bill">
103
+ <p class="lede">{ACTION.running} {phase.job.locale ?? ''}</p>
104
+ <div class="track"><span class="fill" style="width: {Math.round(share * 100)}%"></span></div>
105
+ <p class="why">{phase.job.done} of {phase.job.total}</p>
106
+ </div>
107
+ {:else}
108
+ <button class="act" disabled={busy} onclick={find}>
109
+ <span class="name">{phase.at === 'finding' ? ACTION.working : ACTION.find}</span>
110
+ <span class="why">{ACTION.findWhy}</span>
111
+ </button>
112
+ <button class="act" disabled={busy || !shown.length} onclick={plan}>
113
+ <span class="name">{ACTION.translate}</span>
114
+ <span class="why">{shown.length ? ACTION.translateWhy : ACTION.pickFirst}</span>
115
+ </button>
116
+ {/if}
117
+
118
+ {#if phase.at === 'said'}
119
+ <p class="said" class:bad={phase.bad}>{phase.text}</p>
120
+ {/if}
121
+ </section>
122
+
123
+ <style>
124
+ .acts {
125
+ margin-bottom: 22px;
126
+ }
127
+
128
+ .cap {
129
+ margin: 0 0 8px;
130
+ padding: 0 6px;
131
+ font-size: 11px;
132
+ font-weight: 600;
133
+ letter-spacing: 0.08em;
134
+ text-transform: uppercase;
135
+ color: var(--muted);
136
+ }
137
+
138
+ .act {
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 1px;
142
+ width: 100%;
143
+ margin-bottom: 4px;
144
+ padding: 7px 8px;
145
+ border: 0;
146
+ border-radius: var(--radius);
147
+ background: none;
148
+ font: inherit;
149
+ color: var(--body);
150
+ cursor: pointer;
151
+ text-align: left;
152
+ }
153
+
154
+ .act:hover:not(:disabled) {
155
+ background: var(--hollow);
156
+ }
157
+
158
+ .act:disabled {
159
+ cursor: default;
160
+ }
161
+
162
+ /* only the name dims: the line under it is where a disabled button says why it is disabled */
163
+ .act:disabled .name {
164
+ opacity: 0.55;
165
+ }
166
+
167
+ .name {
168
+ font-size: 13px;
169
+ color: var(--ink);
170
+ }
171
+
172
+ .why {
173
+ margin: 0;
174
+ font-size: 11.5px;
175
+ line-height: 1.35;
176
+ color: var(--muted);
177
+ }
178
+
179
+ .bill {
180
+ padding: 10px;
181
+ border: 1px solid var(--rule);
182
+ border-radius: var(--radius);
183
+ background: var(--paper);
184
+ }
185
+
186
+ .lede {
187
+ margin: 0 0 4px;
188
+ font-size: 13px;
189
+ color: var(--ink);
190
+ }
191
+
192
+ .pair {
193
+ display: flex;
194
+ gap: 6px;
195
+ margin-top: 10px;
196
+ }
197
+
198
+ .go {
199
+ flex: 1;
200
+ padding: 5px 10px;
201
+ border: 0;
202
+ border-radius: var(--radius);
203
+ background: var(--done);
204
+ /* paper turns with the theme, so the label stays legible on both greens */
205
+ color: var(--paper);
206
+ font: inherit;
207
+ font-size: 12px;
208
+ cursor: pointer;
209
+ }
210
+
211
+ .plain {
212
+ padding: 5px 10px;
213
+ border: 1px solid var(--rule-strong);
214
+ border-radius: var(--radius);
215
+ background: none;
216
+ color: var(--body);
217
+ font: inherit;
218
+ font-size: 12px;
219
+ cursor: pointer;
220
+ }
221
+
222
+ /* the one thing on this screen that moves, because it reports a run that is really happening */
223
+ .track {
224
+ height: 4px;
225
+ margin: 8px 0 6px;
226
+ border-radius: 999px;
227
+ background: var(--hollow);
228
+ overflow: hidden;
229
+ }
230
+
231
+ .fill {
232
+ display: block;
233
+ height: 100%;
234
+ background: var(--done);
235
+ transition: width 0.3s linear;
236
+ }
237
+
238
+ .said {
239
+ margin: 6px 0 0;
240
+ padding: 0 8px;
241
+ font-size: 11.5px;
242
+ color: var(--muted);
243
+ }
244
+
245
+ .said.bad {
246
+ color: var(--broken);
247
+ }
248
+ </style>