@elytracms/studio 0.0.102 → 0.0.103

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.
@@ -2,21 +2,28 @@
2
2
  * `translateDocument` — the full-document AI translation engine (EC-332).
3
3
  *
4
4
  * ONE implementation behind two surfaces (the detail-editor "Translate document" header
5
- * action + the chat tool). It walks a document's LOCALIZED fields, computes the units
6
- * still **connected to base** (EC-318 — never overwriting a manually diverged value),
7
- * translates them with bounded client-side concurrency over the EC-331 provider, and
8
- * returns a NEW draft with the translations filled in. The caller writes that draft
9
- * through the operations layer with the user's session (provenance-tagged) — the engine
10
- * itself has no data access, so the deterministic FakeProvider drives the whole flow.
5
+ * action + the chat tool). It walks a document's LOCALIZED fields, plans the field ×
6
+ * locale values to write ({@link planTranslation} — the ONE pre-provider judge, EC-752),
7
+ * translates their units with bounded client-side concurrency over the EC-331 provider,
8
+ * and returns a NEW draft with the translations in. In the default mode (`fill-empty`)
9
+ * only values still **connected to base** are filled (EC-318 — a value with its own
10
+ * translation is never overwritten); `retranslate-all` (EC-752, the dialog's "Re-translate
11
+ * everything", behind a confirm that lists what it replaces) also REPLACES a value with its
12
+ * own translation, rebuilt from the base. The caller writes that draft through the
13
+ * operations layer with the user's session (provenance-tagged) — the engine itself has no
14
+ * data access, so the deterministic FakeProvider drives the whole flow.
11
15
  *
12
- * Unit-walk semantics (fill-empty-only, the default):
16
+ * Unit-walk semantics:
13
17
  * - target locales = configured locales minus the document's base locale;
14
18
  * - only LOCALIZED, writer-unlocked fields participate (a non-localized field is shared
15
19
  * across locales — there is nothing per-locale to fill; EC-716: an `ai.exclude` or
16
20
  * `form.readOnly` position, at any depth, is no unit — and no derived slug either);
17
- * - a field/locale is translated only when it is CONNECTED (no per-locale key yet) and
18
- * the base value is non-empty — so writing the translation DIVERGES it, which is
19
- * exactly what makes a re-run an idempotent no-op;
21
+ * - `fill-empty`: a field/locale is translated only when it is CONNECTED (no per-locale
22
+ * key yet) and the base value is non-empty — so writing the translation DIVERGES it,
23
+ * which is exactly what makes a re-run an idempotent no-op. `retranslate-all`: also when
24
+ * it holds its own value — and then a field/locale is written only WHOLE, when every unit
25
+ * of it landed (a dropped unit, a rejected block, a cancel or an unreadable position
26
+ * leaves the target's value as it was, reported); its locked content is kept (EC-738);
20
27
  * - EVERY text position of the base value is a unit (EC-593, ratified 4e): the walk is
21
28
  * core's ONE value-position traversal, so a `text` position is one unit and a
22
29
  * `richText` / `blocks` sequence one unit per translatable block (structure preserved
@@ -28,10 +35,14 @@
28
35
  * - the target bucket is the BASE value with its text translated — every other value
29
36
  * (an image, a select, a number) carried from it, the displayed-value rule (2c);
30
37
  * - whatever cannot be translated is REPORTED (`skipped`), never silently left: a
31
- * position whose value is unreadable, a unit the provider did not return, a rich-text
32
- * block whose translation did not decode token-faithfully (its original is kept);
33
- * - SLUG fields are never model-translated: after the text fills land, each empty
34
- * per-locale slug is derived (EC-315 `autoDeriveSlug`) from the translated source.
38
+ * position whose value is unreadable (EC-752: text inside a value the traversal cannot
39
+ * read too), a unit the provider did not return, a rich-text block whose translation did
40
+ * not decode token-faithfully (fill-empty keeps the source's block in the fill;
41
+ * retranslate-all leaves the whole target value as it was);
42
+ * - SLUG fields are never model-translated: after the text fills land, each per-locale
43
+ * slug that still FOLLOWS the base is derived (EC-315 `autoDeriveSlug`) from the
44
+ * translated source — in both modes: a slug the editor set is never touched (EC-752 — a
45
+ * changed slug moves the page's address, and nothing redirects it).
35
46
  *
36
47
  * Batching (improves on the Sanity precedent's context-blind per-block calls): units are
37
48
  * grouped per target locale and chunked into batches; each batch is ONE `generate` call
@@ -39,7 +50,7 @@
39
50
  * translates with cross-block context. Bounded concurrency (default 6) keeps a realistic
40
51
  * document well under the route's per-minute rate limit.
41
52
  */
42
- import { autoDeriveSlug, createCollectionRegistry, fieldValuePosition, isFieldLocalized, isWriterLocked, resolveField, slugFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
53
+ import { autoDeriveSlug, childPositions, createCollectionRegistry, declarationMayHold, fieldValuePosition, isFieldLocalized, isOpaquePosition, isWriterLocked, resolveField, slugFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
43
54
  import { introducedErrors, warningsOf } from './proposal-validation.js';
44
55
  import { fieldWritePatch } from '../cms/field-patch.js';
45
56
  import { fieldConnection, hasLocaleVariant } from '../locale/index.js';
@@ -88,6 +99,34 @@ function positionId(locale, field, position) {
88
99
  const key = positionKey(position);
89
100
  return key === '' ? `${locale}|${field}` : `${locale}|${field}|${key}`;
90
101
  }
102
+ /** A declaration whose value is text a writer may translate (a unit, or a sequence of them). */
103
+ function isTranslatableDeclaration(field) {
104
+ return (field.type === 'text' || field.type === 'richText' || field.type === 'blocks') && !isWriterLocked(field);
105
+ }
106
+ /**
107
+ * EC-752 (the EC-748 traversal contract, read by translate): every value at or below
108
+ * `root` the traversal cannot read (an {@link OpaquePosition} — a holder stored in a shape
109
+ * its declaration no longer reads) whose declaration may hold translatable text. Its text
110
+ * is no unit — it cannot be found — so the caller REPORTS it (`unreadable`), never leaves
111
+ * it untranslated with no word. A writer-locked position is not descended (no unit, never
112
+ * a skip — EC-716), exactly as {@link textPositionsOf} does.
113
+ */
114
+ function unreadableTextUnder(root, ctx) {
115
+ const out = [];
116
+ const visit = (position) => {
117
+ if (isWriterLocked(position.field) || position.field.type === 'text')
118
+ return;
119
+ for (const child of childPositions(position, ctx)) {
120
+ if (!isOpaquePosition(child))
121
+ visit(child);
122
+ else if (!isWriterLocked(child.declaration) && declarationMayHold(child.declaration, isTranslatableDeclaration)) {
123
+ out.push(child);
124
+ }
125
+ }
126
+ };
127
+ visit(root);
128
+ return out;
129
+ }
91
130
  /** A minimal bounded-concurrency limiter (the precedent's `createLimiter`). */
92
131
  function createLimiter(max) {
93
132
  let active = 0;
@@ -196,31 +235,39 @@ function chunk(items, size) {
196
235
  out.push(items.slice(i, i + size));
197
236
  return out;
198
237
  }
199
- export async function translateDocument(input) {
200
- const { provider, collection, document, localeConfig } = input;
238
+ /** A skip entry — the component it sits in, when it does. */
239
+ function skipOf(field, locale, site, reason, at = site.at) {
240
+ return { field, locale, at, ...(site.component ? { component: { ...site.component } } : {}), reason };
241
+ }
242
+ /**
243
+ * EC-752 — THE pre-provider judge: which field × locale values a translation of `input`
244
+ * writes when every unit lands (`fill` / `replace`), and what it already knows it will not
245
+ * translate (`locked`, `unreadable`). Pure: no provider, no store, no clock. The studio's
246
+ * confirm lists it; {@link translateDocument} runs exactly it — one judge, never a second
247
+ * count. See {@link TranslatePlan} for why it is an upper bound.
248
+ */
249
+ export function planTranslation(input) {
250
+ return planRun(input).plan;
251
+ }
252
+ function planRun(input) {
253
+ const { collection, document, localeConfig } = input;
201
254
  const mode = input.mode ?? 'fill-empty';
255
+ // EC-752 rule 2: a re-translation writes a field × locale WHOLE or not at all — it
256
+ // replaces a value someone made, so a part of it must never become the base's text.
257
+ const whole = mode === 'retranslate-all';
202
258
  // EC-580: the base is the project's default locale — the only default there is,
203
259
  // and the one the translate dialog names (MODE #12: the engine used to translate
204
260
  // FROM a per-document default while the dialog said "from EN").
205
261
  const base = localeConfig.default;
206
262
  const targets = (input.targetLocales ?? localeConfig.locales).filter((l) => l !== base);
207
- const provenance = aiProvenance(provider, input.now ?? new Date().toISOString());
208
263
  // EC-593: the traversal reaches component props through the validator's own lookup.
209
264
  const lookupManifest = input.validateOptions?.lookupManifest;
210
265
  const positionCtx = lookupManifest ? { lookupManifest } : {};
211
- // 1. Walk every text position of each connected bucket's base value → units + plans.
266
+ // Walk every text position of each eligible bucket's base value → units + plans.
212
267
  const units = [];
213
- const plans = new Map();
268
+ const richPlans = new Map();
214
269
  const pending = [];
215
270
  const skipped = [];
216
- const slugFields = collection.fields.filter((f) => isSlugField(f, collection) && !isWriterLocked(f) && isFieldLocalized(collection, f.name));
217
- const skip = (field, locale, position, reason, at = position.at) => skipped.push({
218
- field,
219
- locale,
220
- at,
221
- ...(position.component ? { component: { ...position.component } } : {}),
222
- reason,
223
- });
224
271
  for (const field of collection.fields) {
225
272
  if (isWriterLocked(field))
226
273
  continue;
@@ -246,35 +293,38 @@ export async function translateDocument(input) {
246
293
  const kept = withLockedFrom(root, current, positionCtx);
247
294
  const changes = lockedChanges(fieldValuePosition(field, current, { locale }), kept, positionCtx);
248
295
  if (changes.lost.length > 0 || changes.created.length > 0) {
249
- skip(field.name, locale, root, 'locked');
296
+ skipped.push(skipOf(field.name, locale, root, 'locked'));
250
297
  continue;
251
298
  }
252
- const before = units.length;
299
+ const bucketUnits = [];
300
+ const bucketPlans = new Map();
301
+ const bucketSkips = [];
302
+ const unreadable = (site) => bucketSkips.push(skipOf(field.name, locale, site, 'unreadable'));
253
303
  // EC-593: every text position of the base value — the converter module's fold
254
304
  // over core's ONE value-position traversal.
255
- for (const position of textPositionsOf(fieldValuePosition(field, conn.baseValue, { locale: base }), positionCtx)) {
305
+ for (const position of textPositionsOf(root, positionCtx)) {
256
306
  const { value } = position;
257
307
  if (value === undefined || value === null)
258
308
  continue;
259
309
  if (position.field.type === 'text') {
260
310
  if (typeof value !== 'string')
261
- skip(field.name, locale, position, 'unreadable');
311
+ unreadable(position);
262
312
  else if (value.trim().length > 0) {
263
- units.push({ kind: 'text', id: positionId(locale, field.name, position), locale, field: field.name, text: value });
313
+ bucketUnits.push({ kind: 'text', id: positionId(locale, field.name, position), locale, field: field.name, text: value });
264
314
  }
265
315
  continue;
266
316
  }
267
317
  const extracted = extractRichTextUnits(value);
268
318
  if (!extracted.readable) {
269
- skip(field.name, locale, position, 'unreadable');
319
+ unreadable(position);
270
320
  continue;
271
321
  }
272
322
  if (extracted.units.length === 0)
273
323
  continue;
274
324
  const planId = positionId(locale, field.name, position);
275
- plans.set(planId, extracted.plan);
325
+ bucketPlans.set(planId, extracted.plan);
276
326
  for (const block of extracted.units) {
277
- units.push({
327
+ bucketUnits.push({
278
328
  kind: 'richText',
279
329
  id: `${planId}|${block.id}`,
280
330
  locale,
@@ -285,10 +335,53 @@ export async function translateDocument(input) {
285
335
  });
286
336
  }
287
337
  }
288
- if (units.length > before)
289
- pending.push({ field, locale, base: conn.baseValue, current });
338
+ // EC-752 (8b): text inside a value the traversal cannot read is no unit — reported, not
339
+ // silent (once per location: an unreadable sequence was named by the walk above).
340
+ for (const opaque of unreadableTextUnder(root, positionCtx)) {
341
+ const at = JSON.stringify(opaque.at);
342
+ if (!bucketSkips.some((entry) => JSON.stringify(entry.at) === at))
343
+ unreadable(opaque);
344
+ }
345
+ skipped.push(...bucketSkips);
346
+ // Rule 2, known before the provider: a value with text that cannot be translated is
347
+ // never re-translated in part — its whole bucket stays as it was (and spends no call).
348
+ if (whole && bucketSkips.length > 0)
349
+ continue;
350
+ if (bucketUnits.length === 0)
351
+ continue;
352
+ units.push(...bucketUnits);
353
+ for (const [id, plan] of bucketPlans)
354
+ richPlans.set(id, plan);
355
+ pending.push({ field, locale, base: conn.baseValue, current, kind: conn.connected ? 'fill' : 'replace' });
290
356
  }
291
357
  }
358
+ return {
359
+ plan: {
360
+ mode,
361
+ base,
362
+ targets,
363
+ buckets: pending.map(({ field, locale, kind }) => ({ field: field.name, locale, kind })),
364
+ skipped,
365
+ total: units.length,
366
+ },
367
+ units,
368
+ richPlans,
369
+ pending,
370
+ positionCtx,
371
+ };
372
+ }
373
+ export async function translateDocument(input) {
374
+ const { provider, collection, document, localeConfig } = input;
375
+ // EC-752 (rule 4): the run executes the ONE pre-provider plan — the list the studio's
376
+ // confirm showed is the list the units come from.
377
+ const { plan, units, richPlans: plans, pending, positionCtx } = planRun(input);
378
+ const { mode, base, targets } = plan;
379
+ const whole = mode === 'retranslate-all';
380
+ const provenance = aiProvenance(provider, input.now ?? new Date().toISOString());
381
+ const lookupManifest = input.validateOptions?.lookupManifest;
382
+ const skipped = [...plan.skipped];
383
+ const skip = (field, locale, position, reason, at = position.at) => skipped.push(skipOf(field, locale, position, reason, at));
384
+ const slugFields = collection.fields.filter((f) => isSlugField(f, collection) && !isWriterLocked(f) && isFieldLocalized(collection, f.name));
292
385
  const total = units.length;
293
386
  // 2. Batch per locale, translate with bounded concurrency, honour the abort signal.
294
387
  const batchSize = Math.max(1, input.batchSize ?? DEFAULT_TRANSLATE_BATCH_SIZE);
@@ -331,11 +424,12 @@ export async function translateDocument(input) {
331
424
  // text position replaced (the traversal's writing fold), then slug derivation.
332
425
  // `translated` / `perLocale` count applied UNITS (comparable to `total`).
333
426
  let next = document;
334
- // EC-595 A2: every bucket this run writes, emitted as its patch at the end.
427
+ // EC-595 A2: every bucket this run writes, emitted as its patch at the end (EC-752:
428
+ // with what the write was — the outcome the studio reports).
335
429
  const written = new Map();
336
- const write = (field, locale, value) => {
430
+ const write = (field, locale, value, kind) => {
337
431
  next = writeField(collection, next, field, locale, value, lookupManifest ? { lookupManifest } : {});
338
- written.set(`${locale}|${field}`, { field, locale });
432
+ written.set(`${locale}|${field}`, { field, locale, kind });
339
433
  };
340
434
  const perLocale = {};
341
435
  let translated = 0;
@@ -357,18 +451,24 @@ export async function translateDocument(input) {
357
451
  'content',
358
452
  ...blockId.split('.').map(Number),
359
453
  ];
360
- for (const { field, locale, base: baseValue, current } of pending) {
454
+ for (const { field, locale, base: baseValue, current, kind } of pending) {
361
455
  let applied = 0;
456
+ // EC-752 rule 2: whether every unit of the bucket landed — a unit never sent (a cancel),
457
+ // not returned, or a block whose tokens did not survive makes it incomplete.
458
+ let complete = true;
362
459
  // EC-593: the base value with each translated text position replaced — the
363
460
  // converter module's writing fold; every other value is carried from the base.
364
461
  const value = rebuildTextPositions(fieldValuePosition(field, baseValue, { locale: base }), positionCtx, (position) => {
365
462
  const id = positionId(locale, field.name, position);
366
463
  if (position.field.type === 'text') {
367
- if (!attempted.has(id))
464
+ if (!attempted.has(id)) {
465
+ complete = false;
368
466
  return position.value;
467
+ }
369
468
  const text = returned(id);
370
469
  if (text === undefined) {
371
470
  skip(field.name, locale, position, 'not-returned');
471
+ complete = false;
372
472
  return position.value;
373
473
  }
374
474
  applied += 1;
@@ -379,11 +479,15 @@ export async function translateDocument(input) {
379
479
  return position.value;
380
480
  const blockTranslations = new Map();
381
481
  for (const [blockId, unit] of blocksOf.get(id) ?? []) {
382
- if (!attempted.has(unit.id))
482
+ if (!attempted.has(unit.id)) {
483
+ complete = false;
383
484
  continue;
485
+ }
384
486
  const text = returned(unit.id);
385
- if (text === undefined)
487
+ if (text === undefined) {
386
488
  skip(field.name, locale, position, 'not-returned', blockAt(position, blockId));
489
+ complete = false;
490
+ }
387
491
  else
388
492
  blockTranslations.set(blockId, text);
389
493
  }
@@ -392,6 +496,7 @@ export async function translateDocument(input) {
392
496
  const rebuilt = rebuildRichTextReport(plan, blockTranslations);
393
497
  for (const blockId of rebuilt.rejected) {
394
498
  skip(field.name, locale, position, 'structure-changed', blockAt(position, blockId));
499
+ complete = false;
395
500
  }
396
501
  if (rebuilt.applied.length === 0)
397
502
  return position.value;
@@ -399,15 +504,22 @@ export async function translateDocument(input) {
399
504
  return rebuilt.value;
400
505
  });
401
506
  if (applied === 0)
402
- continue; // nothing landed — the bucket stays connected to base
403
- // EC-738: the target's locked positions, by key (judged in step 1).
404
- write(field.name, locale, withLockedFrom(fieldValuePosition(field, value, { locale }), current, positionCtx));
507
+ continue; // nothing landed — the bucket stays as it was
508
+ // EC-752 rule 2: a re-translation writes a field × locale only WHOLE. A part of it is
509
+ // never replaced by the base's text: the target keeps its value, the skips say why.
510
+ if (whole && !complete)
511
+ continue;
512
+ // EC-738: the target's locked positions, by key (judged in the plan).
513
+ write(field.name, locale, withLockedFrom(fieldValuePosition(field, value, { locale }), current, positionCtx), kind);
405
514
  bump(locale, applied);
406
515
  }
407
- // 4. Slugs: derive each empty per-locale slug from the (now translated) source. Never
408
- // model-translated; only for a still-connected slug whose SOURCE field this run
409
- // actually translated (so the derived slug differs from the base — no pointless
410
- // divergence, and an aborted run with no translations derives nothing).
516
+ // 4. Slugs: derive each per-locale slug that still follows the base from the (now
517
+ // translated) source. Never model-translated; only for a still-connected slug whose
518
+ // SOURCE field holds its own value in that locale (so the derived slug differs from the
519
+ // base — no pointless divergence, and an aborted run with no translations derives
520
+ // nothing). EC-752 rule 1 — in BOTH modes: a slug the editor set is never touched. A
521
+ // changed slug moves the page's address and nothing redirects the old one, so a
522
+ // re-translation silently changing the URL was the measured ship-stopper.
411
523
  let slugsDerived = 0;
412
524
  for (const field of slugFields) {
413
525
  const sourceField = slugSourceField(field, collection);
@@ -415,7 +527,7 @@ export async function translateDocument(input) {
415
527
  const conn = fieldConnection(collection, next, field.name, locale, localeConfig);
416
528
  if (!conn)
417
529
  continue;
418
- if (mode !== 'retranslate-all' && !conn.connected)
530
+ if (!conn.connected)
419
531
  continue;
420
532
  const sourceTranslated = sourceField
421
533
  ? hasLocaleVariant(next, sourceField, locale)
@@ -426,7 +538,7 @@ export async function translateDocument(input) {
426
538
  const derived = autoDeriveSlug(field.slug, collection, valuesInLocale(collection, next, locale, localeConfig));
427
539
  if (!derived || derived === conn.baseValue)
428
540
  continue;
429
- write(field.name, locale, derived);
541
+ write(field.name, locale, derived, 'slug');
430
542
  slugsDerived += 1;
431
543
  }
432
544
  }
@@ -442,9 +554,17 @@ export async function translateDocument(input) {
442
554
  ...(input.validateOptions ?? {}),
443
555
  };
444
556
  const producedIssues = validateDocument(registry, next, validateOptions);
557
+ const writes = [...written.values()].map(({ field, locale, kind }) => ({
558
+ field,
559
+ locale,
560
+ kind,
561
+ patch: fieldWritePatch(collection, next, field, locale),
562
+ }));
445
563
  return {
446
564
  document: next,
447
- patches: [...written.values()].map(({ field, locale }) => fieldWritePatch(collection, next, field, locale)),
565
+ plan,
566
+ writes,
567
+ patches: writes.map((entry) => entry.patch),
448
568
  total,
449
569
  translated,
450
570
  cancelled,