@elytracms/studio 0.0.104 → 0.0.105

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.
@@ -56,6 +56,34 @@ export interface FromWriterFormResult {
56
56
  export declare function fromWriterForm(position: ValuePosition, current: unknown, ctx: WriterFormContext): FromWriterFormResult;
57
57
  /** {@link fromWriterForm} for a top-level field's value. */
58
58
  export declare function fromWriterFieldValue(field: FieldDef, raw: unknown, current: unknown, ctx: WriterFormContext, locale?: string): FromWriterFormResult;
59
+ /**
60
+ * **THE position key** (EC-774, Codex r11 F56) — what names ONE position inside one field
61
+ * value (one `(field, locale)` bucket), so that two positions never share a name: the JSON of
62
+ * its typed {@link KeySegment}s, built parent → child over core's ONE traversal
63
+ * (`childPositions`).
64
+ *
65
+ * - A component PROP: its enclosing SEQUENCE's key + `['node', id]` + the prop name. Anchored
66
+ * by the node — an inline embed a translation moves keeps its key — and scoped to the
67
+ * sequence that holds it: one node id in two rich-text values of an object, or in two
68
+ * repeater items, is two keys.
69
+ * - Any other child: its parent's key + its location below the parent (a sub-field by name,
70
+ * a repeater item by index, an asset-list entry by asset id and occurrence).
71
+ *
72
+ * Injective: a node step cannot alias a name or an index, and no character inside a name can
73
+ * split it. (The translator used to join locations with `/` and `|` and find a prop by
74
+ * `lastIndexOf('props')`; a sub-field named `a/b` or a holder named `props` then shared a
75
+ * name with another position, and both got the second one's translation.)
76
+ *
77
+ * Every fold that matches positions across two walks or two values reads it: the translator's
78
+ * plan and rebuild ({@link textPositionsOf}, {@link rebuildTextPositions}), the locked carry
79
+ * ({@link withLockedFrom}, {@link lockedChanges}) and a write's own child matching.
80
+ *
81
+ * A position under a component node whose id its sequence holds MORE THAN ONCE has no key
82
+ * (`undefined`): core's writing fold addresses a prop by its node id within one sequence
83
+ * (`rebuildFromChildren`), so it can never be written apart from its twin's. The translator
84
+ * refuses it (`unreadable`); the locked carry leaves it as it is.
85
+ */
86
+ export type PositionKey = string;
59
87
  /**
60
88
  * One writer-locked position under a value — the TOPMOST locked one on its branch (it stands
61
89
  * for everything below it, which is locked too) — with its value (possibly absent: the
@@ -144,6 +172,18 @@ export type FromWriterFormAtNodeResult = {
144
172
  export declare function fromWriterFormAtNode(field: FieldDef, displayed: unknown, nodeId: string, prop: string, raw: unknown, ctx: WriterFormContext, locale?: string): FromWriterFormAtNodeResult;
145
173
  /** Whether a field-def holds TEXT a translation turns into units: plain text, or a sequence. */
146
174
  export declare function isTextPosition(position: ValuePosition): boolean;
175
+ /**
176
+ * A text position with its {@link PositionKey} below the walk's root (EC-774) — `undefined`
177
+ * when it has none: it sits under a component node whose id its sequence holds twice, and so
178
+ * can never be written apart from its twin's.
179
+ */
180
+ export interface TextPosition extends ValuePosition {
181
+ readonly key: PositionKey | undefined;
182
+ }
183
+ /** A {@link TextPosition} that has a key — the only kind the writing fold hands back. */
184
+ export interface KeyedTextPosition extends TextPosition {
185
+ readonly key: PositionKey;
186
+ }
147
187
  /**
148
188
  * Every TEXT position at or below `position` (EC-593, ratified 4e — "translate uses the
149
189
  * same traversal"): a `text` value, and a `richText` / `blocks` sequence (its prose —
@@ -151,17 +191,20 @@ export declare function isTextPosition(position: ValuePosition): boolean;
151
191
  * below it: component text props in a composition and in rich-text embeds, object
152
192
  * sub-fields, repeater items, asset placement sub-fields. A writer-locked position
153
193
  * (EC-716: `ai.exclude` or `form.readOnly`) and everything below it is not text a writer
154
- * may touch — not a unit, and so never a skip either. Depth-first, document order.
194
+ * may touch — not a unit, and so never a skip either. Depth-first, document order. Each
195
+ * carries its {@link PositionKey} (EC-774) — the one name the plan and the rebuild share.
155
196
  */
156
- export declare function textPositionsOf(position: ValuePosition, ctx: Pick<WriterFormContext, 'lookupManifest'>): ValuePosition[];
197
+ export declare function textPositionsOf(position: ValuePosition, ctx: Pick<WriterFormContext, 'lookupManifest'>): TextPosition[];
157
198
  /**
158
199
  * The writing fold over {@link textPositionsOf}: the value at `position` with each text
159
200
  * position's value replaced by `replace(textPosition)` — for a sequence, its own value
160
201
  * first (its prose), then the text positions below it, read off the REPLACED value (a
161
- * component keeps its node id, so its props are found again). Every other value — an
162
- * image, a select, a component's structure — comes back as it was (identity-preserving).
202
+ * component keeps its node id, so its props are found again, under the same key). Every
203
+ * other value — an image, a select, a component's structure — comes back as it was
204
+ * (identity-preserving), and so does a text position with no key (EC-774: `replace` never
205
+ * sees one — it cannot be written apart from its twin).
163
206
  */
164
- export declare function rebuildTextPositions(position: ValuePosition, ctx: Pick<WriterFormContext, 'lookupManifest'>, replace: (textPosition: ValuePosition) => unknown): unknown;
207
+ export declare function rebuildTextPositions(position: ValuePosition, ctx: Pick<WriterFormContext, 'lookupManifest'>, replace: (textPosition: KeyedTextPosition) => unknown): unknown;
165
208
  /**
166
209
  * The form a model READS a stored value in — the mirror of {@link fromWriterForm}, so
167
210
  * what `getDocument` shows is what `updateFields` takes back: rich text as a markdown
@@ -150,19 +150,64 @@ function canonicalJson(value) {
150
150
  .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`)
151
151
  .join(',')}}`;
152
152
  }
153
- /** The stable key matching a child position of the written value to the current value's. */
153
+ function keyString(path) {
154
+ return JSON.stringify(path);
155
+ }
156
+ /** The node a child is a prop of — a readable prop or one stored as no static member. */
157
+ function propNodeOf(child) {
158
+ if (isOpaquePosition(child))
159
+ return child.opaque === 'not-a-static-prop' ? child.component?.nodeId : undefined;
160
+ return child.kind === 'prop' ? child.component?.nodeId : undefined;
161
+ }
162
+ /** A child's steps below its parent — the relative part of its {@link PositionKey}. */
163
+ function childSegments(parent, child) {
164
+ const node = propNodeOf(child);
165
+ if (isOpaquePosition(child)) {
166
+ return node !== undefined
167
+ ? [['opaque'], ['node', node], child.declaration.name]
168
+ : [['opaque'], ...child.at.slice(parent.at.length)];
169
+ }
170
+ if (node !== undefined)
171
+ return [['node', node], child.field.name];
172
+ const below = child.at.slice(parent.at.length);
173
+ if (parent.field.type === 'asset' && parent.field.multiple !== false && Array.isArray(parent.value)) {
174
+ const index = below[0];
175
+ const entries = parent.value;
176
+ const id = readAssetId(entries[index]);
177
+ const occurrence = entries.slice(0, index).filter((entry) => readAssetId(entry) === id).length;
178
+ return [['asset', id ?? null, occurrence], ...below.slice(1)];
179
+ }
180
+ return below;
181
+ }
182
+ /** The node ids a `blocks` / `richText` value holds more than once (none for any other value). */
183
+ function twinNodeIds(position) {
184
+ const twins = new Set();
185
+ if (position.field.type !== 'blocks' && position.field.type !== 'richText')
186
+ return twins;
187
+ const seen = new Set();
188
+ for (const { node } of sequenceComponentNodes(position.value))
189
+ (seen.has(node.id) ? twins : seen).add(node.id);
190
+ return twins;
191
+ }
192
+ /**
193
+ * The key of each child of `parent`, whose own key is `parentKey`: `undefined` below a keyless
194
+ * parent, and for a prop of a node its sequence holds twice (see {@link PositionKey}).
195
+ */
196
+ function childKeysUnder(parent, parentKey) {
197
+ if (parentKey === undefined)
198
+ return () => undefined;
199
+ const twins = twinNodeIds(parent);
200
+ return (child) => {
201
+ const node = propNodeOf(child);
202
+ return node !== undefined && twins.has(node) ? undefined : [...parentKey, ...childSegments(parent, child)];
203
+ };
204
+ }
205
+ /**
206
+ * The key matching a child position of the written value to the current value's, one level
207
+ * down — the child's {@link PositionKey} steps below its parent.
208
+ */
154
209
  function childKey(parent, child) {
155
- // A prop by its node (ids are stable across a prose edit that shifts indices);
156
- // everything else by its location relative to the parent.
157
- return child.kind === 'prop' && child.component
158
- ? `node:${child.component.nodeId}:${child.field.name}`
159
- : `at:${JSON.stringify(child.at.slice(parent.at.length))}`;
160
- }
161
- /** {@link childKey} for a value the traversal cannot read, in its own key space (EC-748). */
162
- function opaqueChildKey(parent, child) {
163
- return child.opaque === 'not-a-static-prop' && child.component
164
- ? `opaque:node:${child.component.nodeId}:${child.declaration.name}`
165
- : `opaque:at:${JSON.stringify(child.at.slice(parent.at.length))}`;
210
+ return keyString(childSegments(parent, child));
166
211
  }
167
212
  /** The model-facing name of an unreadable value: its declaration's name, located when nested. */
168
213
  function opaqueSubject(position, root) {
@@ -198,12 +243,9 @@ function convertChildren(pos, current, ctx, problems, root) {
198
243
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
199
244
  const currentChildren = new Map();
200
245
  if (current !== undefined) {
201
- for (const child of childPositions({ ...pos, value: current }, positionCtx)) {
202
- if (isOpaquePosition(child))
203
- currentChildren.set(opaqueChildKey(pos, child), child.value);
204
- else
205
- currentChildren.set(childKey(pos, child), child.value);
206
- }
246
+ const held = { ...pos, value: current };
247
+ for (const child of childPositions(held, positionCtx))
248
+ currentChildren.set(childKey(held, child), child.value);
207
249
  }
208
250
  return rebuildFromChildren(pos, positionCtx, (child) => convert(child, currentChildren.get(childKey(pos, child)), ctx, problems, root, false),
209
251
  // EC-748: a value the model wrote in a shape its declaration does not read — nothing
@@ -211,7 +253,7 @@ function convertChildren(pos, current, ctx, problems, root) {
211
253
  // object would reach a locked sub-field through the canonicaliser's unwrap). The stored
212
254
  // value echoed back, unchanged, is no write; anything else is a problem, and not written.
213
255
  (unreadable) => {
214
- const stored = currentChildren.get(opaqueChildKey(pos, unreadable));
256
+ const stored = currentChildren.get(childKey(pos, unreadable));
215
257
  if (stored !== undefined && canonicalJson(stored) === canonicalJson(unreadable.value))
216
258
  return unreadable.value;
217
259
  problems.push({ at: unreadable.at, code: 'invalid-field-value', message: unreadableWriteMessage(unreadable, root) });
@@ -307,54 +349,40 @@ export function declaredWriterLock(declaration) {
307
349
  return 'read-only';
308
350
  return undefined;
309
351
  }
310
- /** {@link lockedChildKey} for a value the traversal cannot read — its own key space (EC-748). */
311
- function lockedOpaqueKey(parent, parentKey, child) {
312
- if (child.opaque === 'not-a-static-prop' && child.component) {
313
- return `opaque:node:${child.component.nodeId}:${child.declaration.name}`;
314
- }
315
- return `${parentKey}/opaque:${JSON.stringify(child.at.slice(parent.at.length))}`;
316
- }
317
352
  /**
318
- * The key matching a child position across two values of one position: a component prop by
319
- * its NODE (ids are unique in a document and survive a prose edit that shifts indices), an
320
- * entry of an asset LIST by its asset id and occurrence (the carry's own matching,
321
- * `carryAssetPlacements` — a reordered gallery keeps each placement), anything else by its
322
- * location below the parent (a repeater item by index, a sub-field by name).
353
+ * The locked judge's name for a position: its {@link PositionKey} — a component prop by its
354
+ * NODE within its sequence (ids survive a prose edit that shifts indices), an asset-list
355
+ * entry by its asset id and occurrence, anything else by its location below the parent.
356
+ * EC-774: a position with no key (under a node its sequence holds twice) is named by its
357
+ * LOCATION, in its own key space: {@link withLockedFrom} never takes it from the other value
358
+ * (core's write fold would write it into its twin too), and it counts as kept only where the
359
+ * other value holds the same locked value at the same location — fail closed.
323
360
  */
324
- function lockedChildKey(parent, parentKey, child) {
325
- if (child.kind === 'prop' && child.component)
326
- return `node:${child.component.nodeId}:${child.field.name}`;
327
- const below = child.at.slice(parent.at.length);
328
- if (parent.field.type === 'asset' && parent.field.multiple !== false && Array.isArray(parent.value)) {
329
- const index = below[0];
330
- const entries = parent.value;
331
- const id = readAssetId(entries[index]);
332
- const occurrence = entries.slice(0, index).filter((entry) => readAssetId(entry) === id).length;
333
- return `${parentKey}/asset:${JSON.stringify(id)}#${occurrence}/${JSON.stringify(below.slice(1))}`;
334
- }
335
- return `${parentKey}/${JSON.stringify(below)}`;
361
+ function lockedKey(key, at) {
362
+ return key !== undefined ? keyString(key) : JSON.stringify([['twin'], ...at]);
336
363
  }
337
- /** Every topmost writer-locked position at or below `position`, by {@link lockedChildKey}. */
364
+ /** Every topmost writer-locked position at or below `position`, by {@link lockedKey}. */
338
365
  function lockedPositionsUnder(position, positionCtx) {
339
366
  const out = new Map();
340
367
  const visit = (pos, key) => {
341
368
  const lock = writerLockOf(pos.field);
342
369
  if (lock !== undefined) {
343
- out.set(key, { value: pos.value, lock, at: pos.at });
370
+ out.set(lockedKey(key, pos.at), { value: pos.value, lock, at: pos.at });
344
371
  return;
345
372
  }
373
+ const keyOf = childKeysUnder(pos, key);
346
374
  for (const child of childPositions(pos, positionCtx)) {
347
375
  if (!isOpaquePosition(child)) {
348
- visit(child, lockedChildKey(pos, key, child));
376
+ visit(child, keyOf(child));
349
377
  continue;
350
378
  }
351
379
  // EC-748: what cannot be read is judged by its declaration — never "nothing locked here".
352
380
  const lock = declaredWriterLock(child.declaration);
353
381
  if (lock !== undefined)
354
- out.set(lockedOpaqueKey(pos, key, child), { value: child.value, lock, at: child.at, opaque: child.opaque });
382
+ out.set(lockedKey(keyOf(child), child.at), { value: child.value, lock, at: child.at, opaque: child.opaque });
355
383
  }
356
384
  };
357
- visit(position, '');
385
+ visit(position, []);
358
386
  return out;
359
387
  }
360
388
  /** Whether `entry` sits in `other` unchanged at `key` (a defined value, the same JSON). */
@@ -411,13 +439,20 @@ export function withLockedFrom(position, current, ctx) {
411
439
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
412
440
  const held = lockedPositionsUnder({ ...position, value: current }, positionCtx);
413
441
  const rebuild = (pos, key) => {
414
- if (writerLockOf(pos.field) !== undefined)
415
- return held.has(key) ? held.get(key).value : pos.value;
442
+ if (writerLockOf(pos.field) !== undefined) {
443
+ // EC-774: a position with no key (under a node its sequence holds twice) keeps its own
444
+ // value — taken from `current`, core's write fold would write it into its twin too.
445
+ if (key === undefined)
446
+ return pos.value;
447
+ const name = keyString(key);
448
+ return held.has(name) ? held.get(name).value : pos.value;
449
+ }
416
450
  // EC-748: a value `position` holds unreadably is kept as it is — nothing below it can be
417
451
  // matched by key — and {@link lockedChanges} counts it lost unless `current` holds it too.
418
- return rebuildFromChildren(pos, positionCtx, (child) => rebuild(child, lockedChildKey(pos, key, child)), (unreadable) => unreadable.value);
452
+ const keyOf = childKeysUnder(pos, key);
453
+ return rebuildFromChildren(pos, positionCtx, (child) => rebuild(child, keyOf(child)), (unreadable) => unreadable.value);
419
454
  };
420
- return rebuild(position, '');
455
+ return rebuild(position, []);
421
456
  }
422
457
  /**
423
458
  * The locked values a write from `position.value` (the CURRENT value) to `next` would lose or
@@ -625,6 +660,10 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
625
660
  const root = fieldValuePosition(field, displayed, locale !== undefined ? { locale } : {});
626
661
  const isTarget = (position) => position.kind === 'prop' && position.component?.nodeId === nodeId && position.field.name === prop;
627
662
  let target;
663
+ // Codex r12 F58: every position the address names. One node id may sit in two sequences of one
664
+ // field (ids are checked per `blocks` value), and the rebuild below replaces EVERY match — so an
665
+ // address naming two positions is refused, never written (the node-id scope is EC-776's).
666
+ let targets = 0;
628
667
  // EC-716: the lock the target sits under — its own, or the nearest locked ancestor's
629
668
  // (a component embedded in a read-only rich-text prop, a prop of a locked object…).
630
669
  let targetLock;
@@ -637,6 +676,8 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
637
676
  if (position.kind === 'prop' && position.component?.nodeId === nodeId) {
638
677
  componentId = position.component.componentId;
639
678
  declared.push(position.field.name);
679
+ if (position.field.name === prop)
680
+ targets++;
640
681
  if (target === undefined && position.field.name === prop) {
641
682
  target = position;
642
683
  targetLock = lock;
@@ -659,6 +700,12 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
659
700
  }
660
701
  };
661
702
  visit(root, undefined);
703
+ if (targets > 1) {
704
+ return {
705
+ ok: false,
706
+ message: `Component "${nodeId}" (${componentId}) occurs in ${targets} positions of "${field.name}" (one id in separate sequences), so this address does not name one of them; the assistant cannot change prop "${prop}" here. Change it in the editor.`,
707
+ };
708
+ }
662
709
  if (!target && unreadableTarget) {
663
710
  return {
664
711
  ok: false,
@@ -710,46 +757,53 @@ function isLocked(position) {
710
757
  * below it: component text props in a composition and in rich-text embeds, object
711
758
  * sub-fields, repeater items, asset placement sub-fields. A writer-locked position
712
759
  * (EC-716: `ai.exclude` or `form.readOnly`) and everything below it is not text a writer
713
- * may touch — not a unit, and so never a skip either. Depth-first, document order.
760
+ * may touch — not a unit, and so never a skip either. Depth-first, document order. Each
761
+ * carries its {@link PositionKey} (EC-774) — the one name the plan and the rebuild share.
714
762
  */
715
763
  export function textPositionsOf(position, ctx) {
716
764
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
717
765
  const out = [];
718
- const visit = (current) => {
766
+ const visit = (current, key) => {
719
767
  if (isLocked(current))
720
768
  return;
721
769
  if (isTextPosition(current))
722
- out.push(current);
770
+ out.push({ ...current, key: key && keyString(key) });
723
771
  if (current.field.type === 'text')
724
772
  return;
725
773
  // EC-748: a value the traversal cannot read yields no unit — its text cannot be found
726
774
  // (and translate skips a bucket where one may hold locked content: `lockedChanges`).
775
+ const keyOf = childKeysUnder(current, key);
727
776
  for (const child of childPositions(current, positionCtx))
728
777
  if (!isOpaquePosition(child))
729
- visit(child);
778
+ visit(child, keyOf(child));
730
779
  };
731
- visit(position);
780
+ visit(position, []);
732
781
  return out;
733
782
  }
734
783
  /**
735
784
  * The writing fold over {@link textPositionsOf}: the value at `position` with each text
736
785
  * position's value replaced by `replace(textPosition)` — for a sequence, its own value
737
786
  * first (its prose), then the text positions below it, read off the REPLACED value (a
738
- * component keeps its node id, so its props are found again). Every other value — an
739
- * image, a select, a component's structure — comes back as it was (identity-preserving).
787
+ * component keeps its node id, so its props are found again, under the same key). Every
788
+ * other value — an image, a select, a component's structure — comes back as it was
789
+ * (identity-preserving), and so does a text position with no key (EC-774: `replace` never
790
+ * sees one — it cannot be written apart from its twin).
740
791
  */
741
792
  export function rebuildTextPositions(position, ctx, replace) {
742
793
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
743
- const rebuild = (current) => {
794
+ const rebuild = (current, key) => {
744
795
  if (isLocked(current))
745
796
  return current.value;
797
+ const keyed = key && { ...current, key: keyString(key) };
746
798
  if (current.field.type === 'text')
747
- return replace(current);
748
- const value = isTextPosition(current) ? replace(current) : current.value;
799
+ return keyed ? replace(keyed) : current.value;
800
+ const value = keyed && isTextPosition(current) ? replace(keyed) : current.value;
801
+ const replaced = { ...current, value };
802
+ const keyOf = childKeysUnder(replaced, key);
749
803
  // EC-748: an unreadable value comes back as it was (it held no unit).
750
- return rebuildFromChildren({ ...current, value }, positionCtx, rebuild, (unreadable) => unreadable.value);
804
+ return rebuildFromChildren(replaced, positionCtx, (child) => rebuild(child, keyOf(child)), (unreadable) => unreadable.value);
751
805
  };
752
- return rebuild(position);
806
+ return rebuild(position, []);
753
807
  }
754
808
  // --- Stored form → the read (writer) form --------------------------------------------
755
809
  /**