scribe-cms 0.0.13 → 0.0.15

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/cli/index.js CHANGED
@@ -343,39 +343,7 @@ function resolveConfig(input, baseDir) {
343
343
  if (localeRouting.strategy === "search-param" && !localeRouting.param) {
344
344
  throw new Error('scribe config: localeRouting search-param requires a "param" name');
345
345
  }
346
- const localeFallbacks = {};
347
- for (const [locale, chain] of Object.entries(raw.localeFallbacks ?? {})) {
348
- if (!raw.locales.includes(locale)) {
349
- throw new Error(
350
- `scribe config: localeFallbacks key "${locale}" is not in locales [${raw.locales.join(", ")}]`
351
- );
352
- }
353
- const seen = /* @__PURE__ */ new Set();
354
- for (const fallback of chain) {
355
- if (!raw.locales.includes(fallback)) {
356
- throw new Error(
357
- `scribe config: localeFallbacks["${locale}"] entry "${fallback}" is not in locales [${raw.locales.join(", ")}]`
358
- );
359
- }
360
- if (fallback === locale) {
361
- throw new Error(
362
- `scribe config: localeFallbacks["${locale}"] must not contain its own key "${locale}"`
363
- );
364
- }
365
- if (fallback === defaultLocale) {
366
- throw new Error(
367
- `scribe config: localeFallbacks["${locale}"] must not contain the defaultLocale "${defaultLocale}" \u2014 it is always the final fallback; remove it`
368
- );
369
- }
370
- if (seen.has(fallback)) {
371
- throw new Error(
372
- `scribe config: localeFallbacks["${locale}"] contains duplicate entry "${fallback}"`
373
- );
374
- }
375
- seen.add(fallback);
376
- }
377
- localeFallbacks[locale] = [...chain];
378
- }
346
+ const localeFallbacks = raw.localeFallbacks === false ? {} : deriveLocaleFallbacks(raw.locales, defaultLocale);
379
347
  const projectRoot = path4.resolve(baseDir ?? process.cwd(), raw.rootDir);
380
348
  const contentRoot = path4.resolve(projectRoot, raw.contentDir ?? "content");
381
349
  const storePath = path4.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
@@ -415,6 +383,25 @@ function resolveConfig(input, baseDir) {
415
383
  });
416
384
  return config;
417
385
  }
386
+ function deriveLocaleFallbacks(locales, defaultLocale) {
387
+ const byLowercase = new Map(locales.map((l) => [l.toLowerCase(), l]));
388
+ const fallbacks = {};
389
+ for (const locale of locales) {
390
+ const subtags = locale.split("-");
391
+ if (subtags.length < 2) continue;
392
+ const chain = [];
393
+ for (let end = subtags.length - 1; end >= 1; end--) {
394
+ const prefix = byLowercase.get(subtags.slice(0, end).join("-").toLowerCase());
395
+ if (prefix && prefix !== locale && prefix !== defaultLocale) {
396
+ chain.push(prefix);
397
+ }
398
+ }
399
+ if (chain.length > 0) {
400
+ fallbacks[locale] = chain;
401
+ }
402
+ }
403
+ return fallbacks;
404
+ }
418
405
 
419
406
  // src/create-project.ts
420
407
  init_esm_shims();
@@ -2550,6 +2537,13 @@ function buildOutputFormatLine(hasFrontmatter, slugStrategy) {
2550
2537
  }
2551
2538
  return slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).";
2552
2539
  }
2540
+ function buildRetryContextSection(previousError) {
2541
+ return [
2542
+ "",
2543
+ "## Previous attempt",
2544
+ `A previous attempt at this translation was rejected with the following validation errors: ${previousError}. Produce a corrected translation that fixes these issues while re-checking every schema constraint (required fields, array minimums, maximum lengths).`
2545
+ ];
2546
+ }
2553
2547
  function buildPageTranslationPrompt(input) {
2554
2548
  const localeName = LOCALE_NAMES[input.targetLocale] ?? input.targetLocale;
2555
2549
  const localizationPrompt = input.resolved.promptOverride ?? defaultLocalizationPrompt(localeName, input.targetLocale);
@@ -2580,7 +2574,10 @@ function buildPageTranslationPrompt(input) {
2580
2574
  buildOutputFormatLine(
2581
2575
  Object.keys(input.translatableFrontmatter).length > 0,
2582
2576
  input.slugStrategy
2583
- )
2577
+ ),
2578
+ // Retry context is locale-specific suffix material: it MUST stay after the
2579
+ // EN body so the cacheable prefix is byte-identical with and without it.
2580
+ ...input.previousError ? buildRetryContextSection(input.previousError) : []
2584
2581
  ];
2585
2582
  return [...prefix, ...suffix].join("\n");
2586
2583
  }
@@ -2800,7 +2797,8 @@ function prepareTranslation(config, item, options, startedAt) {
2800
2797
  contextLabel: resolveContextLabel(enDoc, item.enSlug),
2801
2798
  translatableFrontmatter: payload.frontmatter,
2802
2799
  enBody: payload.body,
2803
- slugStrategy: type.slugStrategy
2800
+ slugStrategy: type.slugStrategy,
2801
+ previousError: item.previousError
2804
2802
  });
2805
2803
  const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
2806
2804
  return {
@@ -3212,6 +3210,82 @@ async function runPool(items, concurrency, worker) {
3212
3210
  function labelForItem(item) {
3213
3211
  return `${item.contentType}/${item.enSlug}@${item.locale}`;
3214
3212
  }
3213
+ function buildRetryWorklist(failed, originalByKey) {
3214
+ return failed.map((result) => {
3215
+ const key = translationItemKey(result);
3216
+ const original = originalByKey.get(key);
3217
+ return {
3218
+ contentType: result.contentType,
3219
+ enSlug: result.enSlug,
3220
+ locale: result.locale,
3221
+ reason: original?.reason ?? "missing",
3222
+ currentEnHash: original?.currentEnHash ?? "",
3223
+ storedEnHash: original?.storedEnHash,
3224
+ previousError: result.error ?? "Translation failed"
3225
+ };
3226
+ });
3227
+ }
3228
+ async function runRetryRound(config, retryItems, options) {
3229
+ const startedAt = Date.now();
3230
+ const translateOne = options.translateOne ?? ((item) => translatePage(config, item, { model: options.model, force: true }));
3231
+ if (options.mode === "direct") {
3232
+ const active = /* @__PURE__ */ new Set();
3233
+ await runPool(retryItems, options.concurrency, async (item) => {
3234
+ const label = labelForItem(item);
3235
+ active.add(label);
3236
+ options.onProgress?.({ type: "item-start", item, active: [...active] });
3237
+ const result = await translateOne(item);
3238
+ active.delete(label);
3239
+ options.onResult(result);
3240
+ });
3241
+ return;
3242
+ }
3243
+ const entries = [];
3244
+ for (const item of retryItems) {
3245
+ const itemStartedAt = Date.now();
3246
+ try {
3247
+ const outcome = prepareTranslation(config, item, { model: options.model, force: true }, itemStartedAt);
3248
+ if (outcome.status === "done") {
3249
+ options.onResult(outcome.result);
3250
+ } else {
3251
+ entries.push({
3252
+ apiModel: resolveGeminiModelId(displayModelFor(outcome.prepared)),
3253
+ prompt: outcome.prepared.prompt,
3254
+ prepared: outcome.prepared
3255
+ });
3256
+ }
3257
+ } catch (error) {
3258
+ options.onResult(failedResultForItem(item, error, itemStartedAt));
3259
+ }
3260
+ }
3261
+ const plans = planBatchJobs(entries);
3262
+ const submitted = await Promise.all(
3263
+ plans.map(async (plan, planIndex) => {
3264
+ try {
3265
+ return await submitBatchJobPlan(config, {
3266
+ plan,
3267
+ displayModel: normalizeGeminiDisplayName(plan.apiModel),
3268
+ jobIndex: planIndex,
3269
+ jobCount: plans.length,
3270
+ onProgress: options.onProgress
3271
+ });
3272
+ } catch (error) {
3273
+ for (const entry of plan.entries) {
3274
+ options.onResult(failedResultForItem(entry.prepared.item, error, startedAt));
3275
+ }
3276
+ return void 0;
3277
+ }
3278
+ })
3279
+ );
3280
+ const jobsToPoll = submitted.filter((row) => row !== void 0);
3281
+ if (jobsToPoll.length > 0) {
3282
+ await pollAndIngestBatchJobs(config, jobsToPoll, {
3283
+ jobCount: jobsToPoll.length,
3284
+ onProgress: options.onProgress,
3285
+ onResult: options.onResult
3286
+ });
3287
+ }
3288
+ }
3215
3289
  async function translatePage(config, item, options = {}) {
3216
3290
  const startedAt = Date.now();
3217
3291
  let outcome;
@@ -3384,9 +3458,40 @@ async function translateWorklist(config, items, options = {}) {
3384
3458
  });
3385
3459
  }
3386
3460
  }
3387
- const results = collector.assemble();
3461
+ const mainResults = collector.assemble();
3462
+ const failed = mainResults.filter((result) => result.failed);
3463
+ if (failed.length === 0) {
3464
+ const totals2 = summarizeResults(mainResults, Date.now() - startedAt);
3465
+ options.onProgress?.({ type: "done", results: mainResults, totals: totals2 });
3466
+ return mainResults;
3467
+ }
3468
+ options.onProgress?.({ type: "retry-start", failed });
3469
+ const originalByKey = new Map(
3470
+ items.map((item) => [translationItemKey(item), item])
3471
+ );
3472
+ const retryItems = buildRetryWorklist(failed, originalByKey);
3473
+ const retryByKey = /* @__PURE__ */ new Map();
3474
+ await runRetryRound(config, retryItems, {
3475
+ model: options.model,
3476
+ force: options.force,
3477
+ concurrency,
3478
+ mode,
3479
+ onProgress: options.onProgress,
3480
+ onResult: (result) => {
3481
+ retryByKey.set(translationItemKey(result), result);
3482
+ options.onProgress?.({ type: "item-done", result });
3483
+ }
3484
+ });
3485
+ const results = mainResults.map((result) => {
3486
+ if (!result.failed) return result;
3487
+ return retryByKey.get(translationItemKey(result)) ?? result;
3488
+ });
3489
+ const retriedTranslated = failed.filter((original) => {
3490
+ const retried = retryByKey.get(translationItemKey(original));
3491
+ return retried !== void 0 && !retried.failed && !retried.skipped;
3492
+ }).length;
3388
3493
  const totals = summarizeResults(results, Date.now() - startedAt);
3389
- options.onProgress?.({ type: "done", results, totals });
3494
+ options.onProgress?.({ type: "done", results, totals, retriedTranslated });
3390
3495
  return results;
3391
3496
  }
3392
3497
  function countPendingItems(config, jobs) {
@@ -4208,10 +4313,11 @@ function detailForResult(result) {
4208
4313
  if (result.error) parts.push(red(result.error));
4209
4314
  return parts.length > 0 ? dim(parts.join(" \xB7 ")) : "";
4210
4315
  }
4211
- function formatSummaryLine(totals, dryRun) {
4316
+ function formatSummaryLine(totals, dryRun, retriedTranslated = 0) {
4212
4317
  const action = dryRun ? "Would translate" : "Translated";
4318
+ const base = totals.translated - retriedTranslated;
4213
4319
  const parts = [
4214
- `${action} ${totals.translated}`,
4320
+ retriedTranslated > 0 ? `${action} ${base} +${retriedTranslated} on retry` : `${action} ${totals.translated}`,
4215
4321
  `skipped ${totals.skipped}`
4216
4322
  ];
4217
4323
  if (totals.failed > 0) parts.push(red(`failed ${totals.failed}`));
@@ -4260,8 +4366,18 @@ function createTranslateProgressReporter(options = {}) {
4260
4366
  }
4261
4367
  return;
4262
4368
  }
4369
+ if (event.type === "retry-start") {
4370
+ for (const result of event.failed) {
4371
+ console.log(`${labelForResult(result)}: ${result.error ?? "failed"}`);
4372
+ }
4373
+ console.log(`Retrying ${event.failed.length} failed items with validation context...`);
4374
+ return;
4375
+ }
4263
4376
  if (event.type === "done") {
4264
- console.log(formatSummaryLine(event.totals, dryRun));
4377
+ console.log(formatSummaryLine(event.totals, dryRun, event.retriedTranslated));
4378
+ for (const result of event.results.filter((entry) => entry.failed)) {
4379
+ console.error(`${labelForResult(result)}: ${result.error ?? "failed"}`);
4380
+ }
4265
4381
  }
4266
4382
  },
4267
4383
  finish() {
@@ -4428,12 +4544,25 @@ function createTranslateProgressReporter(options = {}) {
4428
4544
  render();
4429
4545
  break;
4430
4546
  }
4547
+ case "retry-start": {
4548
+ total += event.failed.length;
4549
+ printPersistent([
4550
+ ...event.failed.map(
4551
+ (result) => red(`${labelForResult(result)}: ${result.error ?? "failed"}`)
4552
+ ),
4553
+ cyan(`Retrying ${event.failed.length} failed items with validation context...`)
4554
+ ]);
4555
+ render();
4556
+ break;
4557
+ }
4431
4558
  case "done":
4432
4559
  showCursor();
4433
4560
  if (renderedLines > 0) {
4434
4561
  process.stdout.write(`\x1B[${renderedLines}A`);
4435
4562
  }
4436
- process.stdout.write("\x1B[2K" + green("Done") + " \xB7 " + formatSummaryLine(event.totals, dryRun) + "\n");
4563
+ process.stdout.write(
4564
+ "\x1B[2K" + green("Done") + " \xB7 " + formatSummaryLine(event.totals, dryRun, event.retriedTranslated) + "\n"
4565
+ );
4437
4566
  renderedLines = 0;
4438
4567
  for (const result of event.results.filter((entry) => entry.failed)) {
4439
4568
  process.stdout.write("\x1B[2K" + red(`${labelForResult(result)}: ${result.error ?? "failed"}`) + "\n");