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/index.js CHANGED
@@ -244,39 +244,7 @@ function resolveConfig(input, baseDir) {
244
244
  if (localeRouting.strategy === "search-param" && !localeRouting.param) {
245
245
  throw new Error('scribe config: localeRouting search-param requires a "param" name');
246
246
  }
247
- const localeFallbacks = {};
248
- for (const [locale, chain] of Object.entries(raw.localeFallbacks ?? {})) {
249
- if (!raw.locales.includes(locale)) {
250
- throw new Error(
251
- `scribe config: localeFallbacks key "${locale}" is not in locales [${raw.locales.join(", ")}]`
252
- );
253
- }
254
- const seen = /* @__PURE__ */ new Set();
255
- for (const fallback of chain) {
256
- if (!raw.locales.includes(fallback)) {
257
- throw new Error(
258
- `scribe config: localeFallbacks["${locale}"] entry "${fallback}" is not in locales [${raw.locales.join(", ")}]`
259
- );
260
- }
261
- if (fallback === locale) {
262
- throw new Error(
263
- `scribe config: localeFallbacks["${locale}"] must not contain its own key "${locale}"`
264
- );
265
- }
266
- if (fallback === defaultLocale) {
267
- throw new Error(
268
- `scribe config: localeFallbacks["${locale}"] must not contain the defaultLocale "${defaultLocale}" \u2014 it is always the final fallback; remove it`
269
- );
270
- }
271
- if (seen.has(fallback)) {
272
- throw new Error(
273
- `scribe config: localeFallbacks["${locale}"] contains duplicate entry "${fallback}"`
274
- );
275
- }
276
- seen.add(fallback);
277
- }
278
- localeFallbacks[locale] = [...chain];
279
- }
247
+ const localeFallbacks = raw.localeFallbacks === false ? {} : deriveLocaleFallbacks(raw.locales, defaultLocale);
280
248
  const projectRoot = path3.resolve(baseDir ?? process.cwd(), raw.rootDir);
281
249
  const contentRoot = path3.resolve(projectRoot, raw.contentDir ?? "content");
282
250
  const storePath = path3.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
@@ -316,6 +284,25 @@ function resolveConfig(input, baseDir) {
316
284
  });
317
285
  return config;
318
286
  }
287
+ function deriveLocaleFallbacks(locales, defaultLocale) {
288
+ const byLowercase = new Map(locales.map((l) => [l.toLowerCase(), l]));
289
+ const fallbacks = {};
290
+ for (const locale of locales) {
291
+ const subtags = locale.split("-");
292
+ if (subtags.length < 2) continue;
293
+ const chain = [];
294
+ for (let end = subtags.length - 1; end >= 1; end--) {
295
+ const prefix = byLowercase.get(subtags.slice(0, end).join("-").toLowerCase());
296
+ if (prefix && prefix !== locale && prefix !== defaultLocale) {
297
+ chain.push(prefix);
298
+ }
299
+ }
300
+ if (chain.length > 0) {
301
+ fallbacks[locale] = chain;
302
+ }
303
+ }
304
+ return fallbacks;
305
+ }
319
306
 
320
307
  // src/core/introspect-schema.ts
321
308
  function getArrayElement(schema) {
@@ -2840,6 +2827,13 @@ function buildOutputFormatLine(hasFrontmatter, slugStrategy) {
2840
2827
  }
2841
2828
  return slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).";
2842
2829
  }
2830
+ function buildRetryContextSection(previousError) {
2831
+ return [
2832
+ "",
2833
+ "## Previous attempt",
2834
+ `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).`
2835
+ ];
2836
+ }
2843
2837
  function buildPageTranslationPrompt(input) {
2844
2838
  const localeName = LOCALE_NAMES[input.targetLocale] ?? input.targetLocale;
2845
2839
  const localizationPrompt = input.resolved.promptOverride ?? defaultLocalizationPrompt(localeName, input.targetLocale);
@@ -2870,7 +2864,10 @@ function buildPageTranslationPrompt(input) {
2870
2864
  buildOutputFormatLine(
2871
2865
  Object.keys(input.translatableFrontmatter).length > 0,
2872
2866
  input.slugStrategy
2873
- )
2867
+ ),
2868
+ // Retry context is locale-specific suffix material: it MUST stay after the
2869
+ // EN body so the cacheable prefix is byte-identical with and without it.
2870
+ ...input.previousError ? buildRetryContextSection(input.previousError) : []
2874
2871
  ];
2875
2872
  return [...prefix, ...suffix].join("\n");
2876
2873
  }
@@ -3085,7 +3082,8 @@ function prepareTranslation(config, item, options, startedAt) {
3085
3082
  contextLabel: resolveContextLabel(enDoc, item.enSlug),
3086
3083
  translatableFrontmatter: payload.frontmatter,
3087
3084
  enBody: payload.body,
3088
- slugStrategy: type.slugStrategy
3085
+ slugStrategy: type.slugStrategy,
3086
+ previousError: item.previousError
3089
3087
  });
3090
3088
  const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
3091
3089
  return {
@@ -3497,6 +3495,82 @@ async function runPool(items, concurrency, worker) {
3497
3495
  function labelForItem(item) {
3498
3496
  return `${item.contentType}/${item.enSlug}@${item.locale}`;
3499
3497
  }
3498
+ function buildRetryWorklist(failed, originalByKey) {
3499
+ return failed.map((result) => {
3500
+ const key = translationItemKey(result);
3501
+ const original = originalByKey.get(key);
3502
+ return {
3503
+ contentType: result.contentType,
3504
+ enSlug: result.enSlug,
3505
+ locale: result.locale,
3506
+ reason: original?.reason ?? "missing",
3507
+ currentEnHash: original?.currentEnHash ?? "",
3508
+ storedEnHash: original?.storedEnHash,
3509
+ previousError: result.error ?? "Translation failed"
3510
+ };
3511
+ });
3512
+ }
3513
+ async function runRetryRound(config, retryItems, options) {
3514
+ const startedAt = Date.now();
3515
+ const translateOne = options.translateOne ?? ((item) => translatePage(config, item, { model: options.model, force: true }));
3516
+ if (options.mode === "direct") {
3517
+ const active = /* @__PURE__ */ new Set();
3518
+ await runPool(retryItems, options.concurrency, async (item) => {
3519
+ const label = labelForItem(item);
3520
+ active.add(label);
3521
+ options.onProgress?.({ type: "item-start", item, active: [...active] });
3522
+ const result = await translateOne(item);
3523
+ active.delete(label);
3524
+ options.onResult(result);
3525
+ });
3526
+ return;
3527
+ }
3528
+ const entries = [];
3529
+ for (const item of retryItems) {
3530
+ const itemStartedAt = Date.now();
3531
+ try {
3532
+ const outcome = prepareTranslation(config, item, { model: options.model, force: true }, itemStartedAt);
3533
+ if (outcome.status === "done") {
3534
+ options.onResult(outcome.result);
3535
+ } else {
3536
+ entries.push({
3537
+ apiModel: resolveGeminiModelId(displayModelFor(outcome.prepared)),
3538
+ prompt: outcome.prepared.prompt,
3539
+ prepared: outcome.prepared
3540
+ });
3541
+ }
3542
+ } catch (error) {
3543
+ options.onResult(failedResultForItem(item, error, itemStartedAt));
3544
+ }
3545
+ }
3546
+ const plans = planBatchJobs(entries);
3547
+ const submitted = await Promise.all(
3548
+ plans.map(async (plan, planIndex) => {
3549
+ try {
3550
+ return await submitBatchJobPlan(config, {
3551
+ plan,
3552
+ displayModel: normalizeGeminiDisplayName(plan.apiModel),
3553
+ jobIndex: planIndex,
3554
+ jobCount: plans.length,
3555
+ onProgress: options.onProgress
3556
+ });
3557
+ } catch (error) {
3558
+ for (const entry of plan.entries) {
3559
+ options.onResult(failedResultForItem(entry.prepared.item, error, startedAt));
3560
+ }
3561
+ return void 0;
3562
+ }
3563
+ })
3564
+ );
3565
+ const jobsToPoll = submitted.filter((row) => row !== void 0);
3566
+ if (jobsToPoll.length > 0) {
3567
+ await pollAndIngestBatchJobs(config, jobsToPoll, {
3568
+ jobCount: jobsToPoll.length,
3569
+ onProgress: options.onProgress,
3570
+ onResult: options.onResult
3571
+ });
3572
+ }
3573
+ }
3500
3574
  async function translatePage(config, item, options = {}) {
3501
3575
  const startedAt = Date.now();
3502
3576
  let outcome;
@@ -3669,9 +3743,40 @@ async function translateWorklist(config, items, options = {}) {
3669
3743
  });
3670
3744
  }
3671
3745
  }
3672
- const results = collector.assemble();
3746
+ const mainResults = collector.assemble();
3747
+ const failed = mainResults.filter((result) => result.failed);
3748
+ if (failed.length === 0) {
3749
+ const totals2 = summarizeResults(mainResults, Date.now() - startedAt);
3750
+ options.onProgress?.({ type: "done", results: mainResults, totals: totals2 });
3751
+ return mainResults;
3752
+ }
3753
+ options.onProgress?.({ type: "retry-start", failed });
3754
+ const originalByKey = new Map(
3755
+ items.map((item) => [translationItemKey(item), item])
3756
+ );
3757
+ const retryItems = buildRetryWorklist(failed, originalByKey);
3758
+ const retryByKey = /* @__PURE__ */ new Map();
3759
+ await runRetryRound(config, retryItems, {
3760
+ model: options.model,
3761
+ force: options.force,
3762
+ concurrency,
3763
+ mode,
3764
+ onProgress: options.onProgress,
3765
+ onResult: (result) => {
3766
+ retryByKey.set(translationItemKey(result), result);
3767
+ options.onProgress?.({ type: "item-done", result });
3768
+ }
3769
+ });
3770
+ const results = mainResults.map((result) => {
3771
+ if (!result.failed) return result;
3772
+ return retryByKey.get(translationItemKey(result)) ?? result;
3773
+ });
3774
+ const retriedTranslated = failed.filter((original) => {
3775
+ const retried = retryByKey.get(translationItemKey(original));
3776
+ return retried !== void 0 && !retried.failed && !retried.skipped;
3777
+ }).length;
3673
3778
  const totals = summarizeResults(results, Date.now() - startedAt);
3674
- options.onProgress?.({ type: "done", results, totals });
3779
+ options.onProgress?.({ type: "done", results, totals, retriedTranslated });
3675
3780
  return results;
3676
3781
  }
3677
3782
  function countPendingItems(config, jobs) {