@blocofy/cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -226,6 +226,43 @@ get a random UUID. Transient failures (429/502/503/504, network) are retried aga
226
226
  idempotency key, so a retry replays the same batch. Exit 0 on success (or "No changes" when
227
227
  every item was already recorded).
228
228
 
229
+ ### Translations
230
+
231
+ ```
232
+ blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff] [--only all|missing|stale|pending] [--json]
233
+ blocofy translations import <file.json|file.xlf> [--dry-run] [--publish] [--on-source-change skip|apply] [--json]
234
+ ```
235
+ `export` writes every text of one language that needs translating (pages, image texts, records,
236
+ menus, site settings, theme texts) to ONE file. The platform answers in windows; the command
237
+ follows every window until the last and merges them, then lists what was left out and why.
238
+ `--format xliff` writes XLIFF 1.2 for translation tools (the platform's own dialect, byte for
239
+ byte); `--only pending` limits the file to texts that are missing or need an update. The export
240
+ asks for every kind, so the API key needs the read scopes of all of them: `pages:read`,
241
+ `content:read`, `navigation:write` (menus have no read scope), `settings:read`, `themes:read` and
242
+ `models:read`; a key without one of them is refused for the whole export. An existing `--out` file
243
+ is never replaced silently: the command refuses before any request unless `--force` is given, and
244
+ the file is written through a temporary file and a rename.
245
+
246
+ `import` reads a JSON or XLIFF package, drops empty translations, and sends the rest as JSON in
247
+ chunks of at most 500 units (a page or record is never split; one group of up to 5,000 units is
248
+ sent whole). Every chunk is checked locally before the first request. It prints the count per
249
+ state (written, unchanged, empty, source changed, target changed, invalid, blocked). New pages and
250
+ records are created as drafts and page content goes to the draft; menus, site settings, theme texts,
251
+ a live record's text and a live page's title and SEO texts change at once (a live page's URL only
252
+ with `--publish`, once that page's publish succeeded). `--publish` also publishes the pages and records this
253
+ import wrote (needs `pages:write` and `content:write`) and reports what could not be published;
254
+ `--dry-run` writes nothing and reports what would change. The API key needs the write scopes of
255
+ the kinds in the package. A refusal (for example an invalid text) exits 2 with the server's error
256
+ JSON on stderr; if a later chunk fails, the chunks already written stay written, the report of
257
+ those chunks (counts, what was published or held back, a failed cache refresh) is printed before the
258
+ error (`--json`: one object with `"stopped": true` on stdout), and running the command again is safe
259
+ (written texts answer "unchanged"). Transient failures (429/502/503/504, network) are retried, except
260
+ for a chunk imported with `--publish`: a resend could not see what the first attempt already
261
+ published, so such a chunk is sent once. A 5xx or a lost connection stops the command with
262
+ `TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN` (exit 1), naming the chunk that may or may not have been
263
+ applied and published; a 429 stops it with `TRANSLATIONS_IMPORT_NOT_APPLIED` (exit 1), since that
264
+ chunk was not applied.
265
+
229
266
  ### Settings
230
267
 
231
268
  ```
@@ -408,10 +445,26 @@ Retries: network errors and HTTP 429/502/503/504 are retried up to 3 times (`Ret
408
445
  honoured, max 30s per wait; else 0.3s/0.9s/2s), resending the identical request (`pages push`
409
446
  carries one `x-idempotency-key` per push). HTTP 500 is never retried, nor is a `theme push` dry
410
447
  run answered 502 `readback_unverified` (a verdict on a rolled-back plan, not a transient failure).
448
+ `translations import --publish` is never retried either: a resend could not see what the first
449
+ attempt already published, so a failed chunk stops the command with
450
+ `TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN` (5xx, network) or `TRANSLATIONS_IMPORT_NOT_APPLIED` (429).
411
451
  Each retry prints a notice on stderr.
412
452
 
413
453
  ## Changelog
414
454
 
455
+ - **0.12.0** — Translation packages (#925).
456
+ - `blocofy translations export --locale <tag> --out <file> [--format json|xliff] [--only …]`
457
+ writes every text of one language that needs translating to one JSON or XLIFF 1.2 file. It
458
+ follows every export window and lists what the platform left out, by reason.
459
+ - `blocofy translations import <file> [--dry-run] [--publish] [--on-source-change skip|apply]`
460
+ reads a JSON or XLIFF package and imports it in chunks of at most 500 units (a page or record
461
+ is never split, up to 5,000 units). It prints the count per state, what was published, and
462
+ whether the site cache could not be refreshed. A chunk imported with `--publish` is never
463
+ resent automatically: when it gets no definite answer the command stops with
464
+ `TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN` (a 429: `TRANSLATIONS_IMPORT_NOT_APPLIED`), exit 1, names
465
+ the chunk, and running it again is safe. A run that stops part-way still prints the report of
466
+ the chunks done before it.
467
+ - `export` refuses an existing `--out` file unless `--force`.
415
468
  - **0.11.0** — One named context per site, one explicit target (customer item 1.8).
416
469
  The dev token and the v1 API key remain two separate credentials; a context references both.
417
470
  - **Breaking:** conflicting context choices now fail closed. When `BLOCOFY_CONTEXT`, the env
package/bin/blocofy.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * livereload. No monorepo required.
10
10
  */
11
11
  import { randomUUID } from "node:crypto";
12
- import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import { createRequire } from "node:module";
14
14
  import { join, relative, resolve } from "node:path";
15
15
  import { createInterface } from "node:readline/promises";
@@ -49,6 +49,22 @@ import {
49
49
  import { startDevServer } from "../lib/dev-server.mjs";
50
50
  import { readLocalTemplates } from "../lib/local-theme.mjs";
51
51
  import { CliRefusal, DEFAULT_API_URL, decidePageMediaUses, fetchPageMediaUses, isValidApiKey } from "../lib/media-uses.mjs";
52
+ import {
53
+ ImportNotApplied,
54
+ ImportOutcomeUnknown,
55
+ TRANSLATION_ONLY,
56
+ XLIFF_MAX_CHARS,
57
+ addImportReport,
58
+ chunkPackage,
59
+ emptyImportTally,
60
+ exportAllTranslations,
61
+ exportSkipLines,
62
+ fitChunksToBodyCap,
63
+ importSummaryLines,
64
+ importTranslationChunk,
65
+ readPackageFile,
66
+ toXliff,
67
+ } from "../lib/translations.mjs";
52
68
  import { githubNote, healthAdvice, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
53
69
  import { promptSecret } from "../lib/secret-prompt.mjs";
54
70
  import { MANIFEST_PATH, buildManifest, validateSiteStateTree, verifyManifest } from "../lib/site-state.mjs";
@@ -89,6 +105,8 @@ const KNOWN = {
89
105
  sitePlan: ["target", "mode", "accept-live-effects"],
90
106
  siteApply: ["target", "mode", "accept-live-effects"],
91
107
  sitePublish: ["target", "mode", "yes"],
108
+ translationsExport: ["locale", "out", "format", "only", "force"],
109
+ translationsImport: ["dry-run", "publish", "on-source-change"],
92
110
  };
93
111
  // CF-T1/T2: every command accepts the global `--context <name>` and `--json` (machine-readable refusals).
94
112
  function parseArgsOrExit(rest, known) {
@@ -270,6 +288,36 @@ Usage
270
288
  1 usage/auth/network/5xx; 2 the server refused (4xx) — the {error} JSON
271
289
  is printed to stderr.
272
290
 
291
+ blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff]
292
+ [--only all|missing|stale|pending] [--json]
293
+ Write every text of one language that needs translating (pages, image texts, records,
294
+ menus, site settings, theme texts) to ONE file (v1 API). The export asks for every kind,
295
+ so the API key needs the read scopes of all of them: pages:read, content:read,
296
+ navigation:write (menus have no read scope), settings:read, themes:read and models:read.
297
+ The platform answers in windows; the command follows every window and merges them, then
298
+ lists what was left out and why. --format xliff writes XLIFF 1.2 for translation tools.
299
+ --only pending only texts that are missing or need an update
300
+ --force replace --out if it already exists (refused otherwise, before any request)
301
+
302
+ blocofy translations import <file.json|file.xlf> [--dry-run] [--publish]
303
+ [--on-source-change skip|apply] [--json]
304
+ Import a translated package (v1 API; the write scopes of the kinds, and with --publish
305
+ also pages:write and content:write). Empty translations are skipped; the rest is sent in
306
+ chunks of at most 500 units (a page or record is never split, up to 5,000 units). Prints
307
+ the count per state. New pages and records are created as drafts and page content goes to
308
+ the draft; menus, site settings, theme texts, a live record's text and a live page's title
309
+ and SEO texts change at once.
310
+ --dry-run write nothing; report what would change
311
+ --publish also publish the pages and records this import wrote. A publishing
312
+ chunk is never resent automatically: if it gets no definite answer (or a
313
+ 429) the command stops and says so; running it again is safe.
314
+ --on-source-change apply also write texts whose source changed since the export
315
+ (default: skip)
316
+ If a chunk fails, the report of the chunks done before it is printed first (--json: one
317
+ object with "stopped": true on stdout).
318
+ Exit codes: 0 done; 1 usage/file/network/5xx (or a publishing chunk that was stopped);
319
+ 2 the server refused (4xx) — the {error} JSON is printed to stderr.
320
+
273
321
  blocofy settings pull [dir]
274
322
  blocofy settings push [dir] (--instance <handle> | --live [--yes])
275
323
  Download / upload config/settings.json (theme tokens/settings + color schemes).
@@ -2129,6 +2177,169 @@ async function status(rest) {
2129
2177
  console.log("");
2130
2178
  }
2131
2179
 
2180
+ // ── translations (#925): one language's texts as a package file, and back ──────────────────────────────────
2181
+
2182
+ const TRANSLATIONS_EXPORT_USAGE = "blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff] [--only all|missing|stale|pending] [--json]";
2183
+ const TRANSLATIONS_IMPORT_USAGE = "blocofy translations import <file.json|file.xlf> [--dry-run] [--publish] [--on-source-change skip|apply] [--json]";
2184
+
2185
+ function usageExit(usage) {
2186
+ console.error(`Usage: ${usage}`);
2187
+ process.exit(1);
2188
+ }
2189
+
2190
+ const outExists = (out) =>
2191
+ Object.assign(new Error(`${out} already exists. Nothing was requested or written. Choose another --out file, or add --force to replace it.`), { code: "TRANSLATIONS_OUT_EXISTS", details: { file: out } });
2192
+
2193
+ /** Write `text` to `out` through a temp file + rename, so `out` is never left half-written. */
2194
+ function writeOutFile(out, text, force) {
2195
+ const temp = `${out}.${process.pid}.blocofy-tmp`;
2196
+ try {
2197
+ writeFileSync(temp, text, { flag: "wx" });
2198
+ if (!force && existsSync(out)) throw outExists(out);
2199
+ renameSync(temp, out);
2200
+ } catch (error) {
2201
+ rmSync(temp, { force: true });
2202
+ throw error;
2203
+ }
2204
+ }
2205
+
2206
+ async function translationsExport(rest) {
2207
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.translationsExport);
2208
+ const locale = typeof flags.locale === "string" ? flags.locale : null;
2209
+ const out = typeof flags.out === "string" ? resolve(flags.out) : null;
2210
+ const format = flags.format === undefined ? "json" : flags.format;
2211
+ const only = flags.only === undefined ? "all" : flags.only;
2212
+ const force = flags.force === true;
2213
+ if (!locale || !out || positionals.length > 0 || (format !== "json" && format !== "xliff") || !TRANSLATION_ONLY.includes(only)) usageExit(TRANSLATIONS_EXPORT_USAGE);
2214
+ // A file the customer may have filled in is never replaced silently.
2215
+ if (!force && existsSync(out)) throw outExists(out);
2216
+ // A read of the site; the package goes to --out, never into the project tree (so no binding is needed or written).
2217
+ const { apiUrl, apiKey } = (await prepareTarget({ command: "translations export", commandClass: "read", dir: process.cwd(), flags, needs: "api", mode: `read · ${locale}` })).api;
2218
+ const { pkg, skipped } = await exportAllTranslations({ apiUrl, apiKey, locale, only, onRetry, onPage: ({ page, units }) => console.error(`Export: request ${page}, ${units} unit(s) so far`) });
2219
+ // toXliff refuses a text XML cannot carry BEFORE anything is written.
2220
+ const text = format === "xliff" ? toXliff(pkg) : `${JSON.stringify(pkg, null, 2)}\n`;
2221
+ writeOutFile(out, text, force);
2222
+ if (format === "xliff" && text.length > XLIFF_MAX_CHARS) {
2223
+ console.error(`Warning: this XLIFF file is over ${XLIFF_MAX_CHARS.toLocaleString("en-US")} characters and cannot be imported back. Export with --format json, or a smaller scope such as --only pending.`);
2224
+ }
2225
+ if (flags.json) {
2226
+ console.log(JSON.stringify({ file: out, format, target_locale: pkg.target_locale, units: pkg.units.length, skipped }));
2227
+ return;
2228
+ }
2229
+ console.log(`Exported ${pkg.units.length} unit(s) for ${pkg.target_locale} to ${out} (${format === "xliff" ? "XLIFF" : "JSON"}).`);
2230
+ const skipLines = exportSkipLines(skipped);
2231
+ if (skipLines.length > 0) {
2232
+ console.log("Left out:");
2233
+ for (const line of skipLines) console.log(` - ${line}`);
2234
+ }
2235
+ }
2236
+
2237
+ /** The one `--json` shape of an import run: complete, stopped part-way, or with nothing to send. */
2238
+ function importJson(tally, { dryRun, publish, locale, chunks, chunksDone, caches, splitGroups, stopped }) {
2239
+ return {
2240
+ dry_run: dryRun,
2241
+ target_locale: locale,
2242
+ chunks,
2243
+ chunks_done: chunksDone,
2244
+ stopped,
2245
+ counts: tally.counts,
2246
+ created: tally.created,
2247
+ stamped: tally.stamped,
2248
+ publication: !dryRun && publish ? { published: tally.published, blocked: tally.publishBlocked } : null,
2249
+ publish_skipped: tally.publishSkipped,
2250
+ notes: tally.notes,
2251
+ hints: tally.hints,
2252
+ cache: caches,
2253
+ split_groups: splitGroups,
2254
+ };
2255
+ }
2256
+
2257
+ async function translationsImport(rest) {
2258
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.translationsImport);
2259
+ const onSourceChange = flags["on-source-change"] === undefined ? "skip" : flags["on-source-change"];
2260
+ if (positionals.length !== 1 || (onSourceChange !== "skip" && onSourceChange !== "apply")) usageExit(TRANSLATIONS_IMPORT_USAGE);
2261
+ const file = resolve(positionals[0]);
2262
+ const dryRun = flags["dry-run"] === true;
2263
+ const publish = flags.publish === true;
2264
+ // Everything local is checked before the first request: the file, its chunks, every chunk's body size.
2265
+ let pkg;
2266
+ let chunked;
2267
+ try {
2268
+ pkg = readPackageFile(readFileSync(file, "utf8"), file);
2269
+ chunked = chunkPackage(pkg);
2270
+ chunked.chunks = fitChunksToBodyCap(chunked.chunks);
2271
+ } catch (error) {
2272
+ console.error(`Cannot read translation package ${file}: ${String(error?.message ?? error).replace(/\.?$/, ".")} Nothing was sent.`);
2273
+ process.exit(1);
2274
+ }
2275
+ const { chunks, skippedEmpty, splitGroups } = chunked;
2276
+ let tally = emptyImportTally();
2277
+ tally.counts.skipped_empty += skippedEmpty;
2278
+ const caches = [];
2279
+ let done = 0;
2280
+ const shape = { dryRun, publish, locale: pkg.target_locale, chunks: chunks.length, caches, splitGroups };
2281
+ if (chunks.length === 0) {
2282
+ if (flags.json) console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: 0, stopped: false })));
2283
+ else console.log("Every translation in the file is empty; nothing to import. Nothing was sent.");
2284
+ return;
2285
+ }
2286
+ const mode = `${dryRun ? "dry run" : publish ? "write + publish" : "write"} · ${pkg.target_locale}`;
2287
+ const { apiUrl, apiKey } = (await prepareTarget({ command: "translations import", commandClass: dryRun ? "read" : "remote-mutation", dir: process.cwd(), flags, needs: "api", mode })).api;
2288
+ try {
2289
+ for (const chunk of chunks) {
2290
+ const answer = await importTranslationChunk({ apiUrl, apiKey, chunk, dryRun, publish, onSourceChange, onRetry });
2291
+ tally = addImportReport(tally, answer);
2292
+ if (answer?.cache !== undefined && answer.cache !== null) caches.push(answer.cache);
2293
+ done += 1;
2294
+ if (chunks.length > 1) console.error(`${dryRun ? "Checked" : "Imported"} ${done} of ${chunks.length} chunks`);
2295
+ }
2296
+ } catch (error) {
2297
+ // What the chunks before the failure did (published pages, held drafts, a failed cache flush) is reported in
2298
+ // full: a rerun answers `unchanged` for them and could never tell it again.
2299
+ if (done > 0) {
2300
+ if (flags.json) console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: done, stopped: true })));
2301
+ else {
2302
+ console.log(`Report for the ${done} of ${chunks.length} chunks done before the stop:`);
2303
+ for (const line of importSummaryLines(tally, { dryRun, publish, locale: pkg.target_locale, splitGroups })) console.log(line);
2304
+ }
2305
+ }
2306
+ if (!JSON_MODE) {
2307
+ const invalid = error instanceof CliRefusal && Array.isArray(error.error?.details?.units) ? error.error.details.units : [];
2308
+ if (invalid.length > 0) {
2309
+ console.error("These texts were refused; fix the file and import again:");
2310
+ for (const u of invalid.slice(0, 20)) console.error(` ${u.id}: ${u.message ?? u.reason ?? "refused"}`);
2311
+ if (invalid.length > 20) console.error(` …and ${invalid.length - 20} more.`);
2312
+ }
2313
+ if (!dryRun && done > 0) {
2314
+ console.error(`Import stopped after ${done} of ${chunks.length} chunks. The chunks before it were saved; running the same command again is safe (units already applied report as unchanged).`);
2315
+ }
2316
+ }
2317
+ const details = { chunk: done + 1, chunks: chunks.length, reason: error?.reason };
2318
+ if (error instanceof ImportOutcomeUnknown) {
2319
+ throw Object.assign(
2320
+ new Error(
2321
+ `The import of chunk ${done + 1} of ${chunks.length} got no definite answer (${error.reason}) and was not resent, because it publishes: that chunk may or may not have been applied and published. Running the same command again is safe (units already applied report as unchanged).`,
2322
+ ),
2323
+ { code: "TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN", details },
2324
+ );
2325
+ }
2326
+ if (error instanceof ImportNotApplied) {
2327
+ throw Object.assign(
2328
+ new Error(
2329
+ `The import of chunk ${done + 1} of ${chunks.length} was refused (${error.reason}, too many requests) and was not applied; it was not resent, because it publishes. Wait a moment and run the same command again (units already applied report as unchanged).`,
2330
+ ),
2331
+ { code: "TRANSLATIONS_IMPORT_NOT_APPLIED", details },
2332
+ );
2333
+ }
2334
+ throw error;
2335
+ }
2336
+ if (flags.json) {
2337
+ console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: done, stopped: false })));
2338
+ return;
2339
+ }
2340
+ for (const line of importSummaryLines(tally, { dryRun, publish, locale: pkg.target_locale, splitGroups })) console.log(line);
2341
+ }
2342
+
2132
2343
  const [first, ...rest] = args;
2133
2344
 
2134
2345
  function commandKey(a, b) {
@@ -2163,6 +2374,8 @@ const COMMANDS = {
2163
2374
  "site plan": sitePlan,
2164
2375
  "site apply": siteApply,
2165
2376
  "site publish": sitePublish,
2377
+ "translations export": translationsExport,
2378
+ "translations import": translationsImport,
2166
2379
  };
2167
2380
 
2168
2381
  if (first === "--version" || first === "-v") {
package/lib/args.mjs CHANGED
@@ -8,8 +8,9 @@
8
8
  * Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
9
9
  * gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
10
10
  * CF-T1: `--keychain` (login) ve `--adopt` (link) de değersizdir. CF-T3: `--force` (pages push) da; gerekçesi `--reason <text>`.
11
+ * #925: `--publish` (translations import) da değersizdir.
11
12
  */
12
- const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force"]);
13
+ const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force", "publish"]);
13
14
 
14
15
  /**
15
16
  * `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`