@elytracms/studio 0.0.100 → 0.0.102

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.
Files changed (170) hide show
  1. package/convex/analytics-cost.test.ts +1 -1
  2. package/convex/asset-meta-patch.test.ts +7 -27
  3. package/convex/assets.ts +6 -41
  4. package/convex/cms.ts +2 -29
  5. package/convex/delivery.ts +2 -2
  6. package/convex/record-functions.ts +15 -82
  7. package/convex/redirects.ts +11 -116
  8. package/convex/schema.ts +4 -23
  9. package/convex/shared-capture.convex.test.ts +165 -0
  10. package/convex/triggers.ts +7 -14
  11. package/dist/components/ai/ai-chat-panel-lazy.js +7 -2
  12. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  13. package/dist/components/ai/ai-chat-panel.js +8 -5
  14. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  15. package/dist/components/ai/proposal-card.js +2 -1
  16. package/dist/components/ai/proposal-card.js.map +1 -1
  17. package/dist/components/ai/proposal-unfilled.d.ts +14 -0
  18. package/dist/components/ai/proposal-unfilled.js +33 -0
  19. package/dist/components/ai/proposal-unfilled.js.map +1 -0
  20. package/dist/components/ai/translate-document-action.js +8 -6
  21. package/dist/components/ai/translate-document-action.js.map +1 -1
  22. package/dist/components/ai/translate-skipped.js +1 -0
  23. package/dist/components/ai/translate-skipped.js.map +1 -1
  24. package/dist/components/app-views/studio-config.d.ts +12 -12
  25. package/dist/components/app-views/studio-config.js.map +1 -1
  26. package/dist/components/assets/asset-detail.js +1 -2
  27. package/dist/components/assets/asset-detail.js.map +1 -1
  28. package/dist/components/assets/asset-metadata.d.ts +2 -9
  29. package/dist/components/assets/asset-metadata.js +3 -10
  30. package/dist/components/assets/asset-metadata.js.map +1 -1
  31. package/dist/components/assets/asset-search.js +1 -1
  32. package/dist/components/assets/asset-search.js.map +1 -1
  33. package/dist/components/assets/asset-selection.js +2 -2
  34. package/dist/components/assets/asset-selection.js.map +1 -1
  35. package/dist/components/assets/assets-collection-view-source.d.ts +2 -3
  36. package/dist/components/assets/assets-collection-view-source.js +2 -3
  37. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  38. package/dist/components/builder/__fixtures__/reuse-race.d.ts +30 -0
  39. package/dist/components/builder/__fixtures__/reuse-race.js +77 -0
  40. package/dist/components/builder/__fixtures__/reuse-race.js.map +1 -0
  41. package/dist/components/builder/builder-store.d.ts +37 -1
  42. package/dist/components/builder/builder-store.js +21 -6
  43. package/dist/components/builder/builder-store.js.map +1 -1
  44. package/dist/components/builder/canvas-chrome-css.d.ts +1 -1
  45. package/dist/components/builder/canvas-chrome-css.js +1 -1
  46. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  47. package/dist/components/builder/canvas-chrome.d.ts +1 -1
  48. package/dist/components/builder/canvas-chrome.js +1 -1
  49. package/dist/components/builder/canvas-chrome.js.map +1 -1
  50. package/dist/components/builder/canvas-rebase.d.ts +4 -4
  51. package/dist/components/builder/canvas-rebase.js +3 -2
  52. package/dist/components/builder/canvas-rebase.js.map +1 -1
  53. package/dist/components/builder/canvas-render-context.d.ts +1 -1
  54. package/dist/components/builder/canvas-render-context.js +1 -1
  55. package/dist/components/builder/canvas-render-context.js.map +1 -1
  56. package/dist/components/builder/composition-field-editor-lazy.js +2 -1
  57. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  58. package/dist/components/builder/composition-field-editor.js +3 -3
  59. package/dist/components/builder/composition-field-editor.js.map +1 -1
  60. package/dist/components/builder/reuse-context.d.ts +9 -2
  61. package/dist/components/builder/reuse-context.js +46 -6
  62. package/dist/components/builder/reuse-context.js.map +1 -1
  63. package/dist/components/builder/reuse.d.ts +113 -70
  64. package/dist/components/builder/reuse.js +123 -108
  65. package/dist/components/builder/reuse.js.map +1 -1
  66. package/dist/components/cms/asset-field.js +1 -3
  67. package/dist/components/cms/asset-field.js.map +1 -1
  68. package/dist/components/cms/cell.d.ts +2 -2
  69. package/dist/components/cms/cell.js +8 -18
  70. package/dist/components/cms/cell.js.map +1 -1
  71. package/dist/components/cms/cms-store.d.ts +9 -1
  72. package/dist/components/cms/cms-store.js +1 -0
  73. package/dist/components/cms/cms-store.js.map +1 -1
  74. package/dist/components/cms/detail-editor.js +15 -14
  75. package/dist/components/cms/detail-editor.js.map +1 -1
  76. package/dist/components/cms/field-control.js +13 -1
  77. package/dist/components/cms/field-control.js.map +1 -1
  78. package/dist/components/cms/prop-fields.js +1 -1
  79. package/dist/components/cms/prop-fields.js.map +1 -1
  80. package/dist/components/cms/rich-text/lazy.js +2 -1
  81. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  82. package/dist/components/cms/rich-text/toolbar-state.d.ts +1 -1
  83. package/dist/components/cms/rich-text/toolbar-state.js +1 -1
  84. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  85. package/dist/components/cms/use-field-focus.js +3 -3
  86. package/dist/components/cms/use-field-focus.js.map +1 -1
  87. package/dist/components/cms/version-compare-lazy.js +2 -1
  88. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  89. package/dist/components/components/components-index.js +1 -1
  90. package/dist/components/components/components-index.js.map +1 -1
  91. package/dist/components/deployment-setup-gate.d.ts +1 -1
  92. package/dist/components/deployment-setup-gate.js +1 -1
  93. package/dist/components/deployment-setup-gate.js.map +1 -1
  94. package/dist/components/load-failure.d.ts +62 -0
  95. package/dist/components/load-failure.js +110 -0
  96. package/dist/components/load-failure.js.map +1 -0
  97. package/dist/components/notifications/shell-findings.js +3 -3
  98. package/dist/components/notifications/shell-findings.js.map +1 -1
  99. package/dist/customization.d.ts +3 -3
  100. package/dist/customization.js.map +1 -1
  101. package/dist/lib/ai/apply-content.js +99 -28
  102. package/dist/lib/ai/apply-content.js.map +1 -1
  103. package/dist/lib/ai/chat-context.d.ts +77 -7
  104. package/dist/lib/ai/chat-context.js +139 -14
  105. package/dist/lib/ai/chat-context.js.map +1 -1
  106. package/dist/lib/ai/chat-state.d.ts +7 -1
  107. package/dist/lib/ai/chat-state.js.map +1 -1
  108. package/dist/lib/ai/constraints.d.ts +5 -0
  109. package/dist/lib/ai/constraints.js +21 -6
  110. package/dist/lib/ai/constraints.js.map +1 -1
  111. package/dist/lib/ai/fake-provider.d.ts +76 -26
  112. package/dist/lib/ai/fake-provider.js +210 -66
  113. package/dist/lib/ai/fake-provider.js.map +1 -1
  114. package/dist/lib/ai/index.d.ts +1 -0
  115. package/dist/lib/ai/index.js +1 -0
  116. package/dist/lib/ai/index.js.map +1 -1
  117. package/dist/lib/ai/keyless-assistant.d.ts +37 -0
  118. package/dist/lib/ai/keyless-assistant.js +193 -0
  119. package/dist/lib/ai/keyless-assistant.js.map +1 -0
  120. package/dist/lib/ai/knowledge.d.ts +9 -9
  121. package/dist/lib/ai/knowledge.js +22 -0
  122. package/dist/lib/ai/knowledge.js.map +1 -1
  123. package/dist/lib/ai/shape.d.ts +25 -2
  124. package/dist/lib/ai/shape.js +58 -7
  125. package/dist/lib/ai/shape.js.map +1 -1
  126. package/dist/lib/ai/tools.d.ts +18 -1
  127. package/dist/lib/ai/tools.js +74 -11
  128. package/dist/lib/ai/tools.js.map +1 -1
  129. package/dist/lib/ai/translate-document.d.ts +8 -1
  130. package/dist/lib/ai/translate-document.js +25 -9
  131. package/dist/lib/ai/translate-document.js.map +1 -1
  132. package/dist/lib/ai/types.d.ts +22 -7
  133. package/dist/lib/ai/types.js.map +1 -1
  134. package/dist/lib/ai/update-fields.js +13 -17
  135. package/dist/lib/ai/update-fields.js.map +1 -1
  136. package/dist/lib/ai/writer-form.d.ts +72 -3
  137. package/dist/lib/ai/writer-form.js +320 -42
  138. package/dist/lib/ai/writer-form.js.map +1 -1
  139. package/dist/lib/assets/upload.d.ts +1 -2
  140. package/dist/lib/assets/upload.js +1 -2
  141. package/dist/lib/assets/upload.js.map +1 -1
  142. package/dist/lib/auth-errors.d.ts +1 -1
  143. package/dist/lib/auth-errors.js +1 -1
  144. package/dist/lib/auth-errors.js.map +1 -1
  145. package/dist/lib/engine-version.d.ts +1 -1
  146. package/dist/lib/engine-version.js +1 -1
  147. package/dist/lib/engine-version.js.map +1 -1
  148. package/dist/lib/i18n/messages.de.d.ts +1 -1
  149. package/dist/lib/i18n/messages.de.js +32 -8
  150. package/dist/lib/i18n/messages.de.js.map +1 -1
  151. package/dist/lib/i18n/messages.en.d.ts +25 -3
  152. package/dist/lib/i18n/messages.en.js +40 -10
  153. package/dist/lib/i18n/messages.en.js.map +1 -1
  154. package/dist/lib/operations/cms-persistence.d.ts +28 -0
  155. package/dist/lib/operations/cms-persistence.js +55 -0
  156. package/dist/lib/operations/cms-persistence.js.map +1 -1
  157. package/dist/lib/persistence/convex-adapter.js +7 -2
  158. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  159. package/dist/lib/workspace/index.d.ts +3 -3
  160. package/dist/lib/workspace/index.js +3 -3
  161. package/dist/lib/workspace/index.js.map +1 -1
  162. package/dist/router/navigate-once.d.ts +1 -1
  163. package/dist/router/navigate-once.js.map +1 -1
  164. package/dist/router/root.d.ts +3 -2
  165. package/dist/router/root.js +25 -4
  166. package/dist/router/root.js.map +1 -1
  167. package/package.json +3 -3
  168. package/convex/asset-alt-migration.test.ts +0 -127
  169. package/convex/redirect-migration.test.ts +0 -205
  170. package/convex/version-kind-stamping.test.ts +0 -118
@@ -1,4 +1,4 @@
1
- import type { CmsDocument, CollectionDef, FieldDef, PropFieldLookup, ValuePath, ValuePosition } from '@elytracms/core/cms-core';
1
+ import type { CmsDocument, CollectionDef, FieldDef, PropFieldLookup, ValuePath, ValuePosition, WriterLock } from '@elytracms/core/cms-core';
2
2
  import type { WriterLeash } from './types.js';
3
3
  /** The ids a document already references — reusable anywhere in it without a search. */
4
4
  export interface HeldReferences {
@@ -34,7 +34,14 @@ export interface WriterFormProblem {
34
34
  readonly message: string;
35
35
  /** An id outside the leash: it was REMOVED from the converted value. */
36
36
  readonly leash?: true;
37
+ /** A write to a writer-locked position (EC-716): the position KEPT its current value. */
38
+ readonly locked?: true;
37
39
  }
40
+ /**
41
+ * What a writer is told about a write to a locked position (EC-716) — one wording for the
42
+ * create's dropped-value warning, the edit's refusal and a component prop's refusal.
43
+ */
44
+ export declare function lockedWriteMessage(subjectText: string, lock: WriterLock): string;
38
45
  export interface FromWriterFormResult {
39
46
  /** The stored form (`undefined` — clear the position). */
40
47
  readonly value: unknown;
@@ -49,6 +56,40 @@ export interface FromWriterFormResult {
49
56
  export declare function fromWriterForm(position: ValuePosition, current: unknown, ctx: WriterFormContext): FromWriterFormResult;
50
57
  /** {@link fromWriterForm} for a top-level field's value. */
51
58
  export declare function fromWriterFieldValue(field: FieldDef, raw: unknown, current: unknown, ctx: WriterFormContext, locale?: string): FromWriterFormResult;
59
+ /**
60
+ * One writer-locked position under a value — the TOPMOST locked one on its branch (it stands
61
+ * for everything below it, which is locked too) — with its value (possibly absent: the
62
+ * position exists because its holder does), its lock and its location below the field.
63
+ */
64
+ export interface LockedPosition {
65
+ readonly value: unknown;
66
+ readonly lock: WriterLock;
67
+ readonly at: readonly (string | number)[];
68
+ }
69
+ /**
70
+ * What a writer is told about a write that would remove locked content (EC-738), located at
71
+ * the HOLDER the model wrote. Read-only content is named (the model reads it); excluded
72
+ * content never is (EC-741: the model never sees it, so naming its path would disclose it).
73
+ */
74
+ export declare function lockedRemovalMessage(holder: string, root: string, locked: readonly LockedPosition[], shortened: boolean): string;
75
+ /**
76
+ * The value at `position` with every writer-locked position taken from `current` by key
77
+ * (EC-738, translate): where `current` holds that position — its value, an absent one
78
+ * included — the current one wins; a position `current` does not hold at all (a new item, a
79
+ * node it lacks) keeps `position`'s own. {@link lockedChanges} then says whether that kept
80
+ * the locked content as it is.
81
+ */
82
+ export declare function withLockedFrom(position: ValuePosition, current: unknown, ctx: Pick<WriterFormContext, 'lookupManifest'>): unknown;
83
+ /**
84
+ * The locked values a write from `position.value` (the CURRENT value) to `next` would lose or
85
+ * create, judged both ways (EC-738, translate): `lost` — a defined locked value `next` does
86
+ * not hold unchanged; `created` — a defined locked value `next` holds where the current value
87
+ * holds none.
88
+ */
89
+ export declare function lockedChanges(position: ValuePosition, next: unknown, ctx: Pick<WriterFormContext, 'lookupManifest'>): {
90
+ readonly lost: readonly LockedPosition[];
91
+ readonly created: readonly LockedPosition[];
92
+ };
52
93
  export type FromWriterFormAtNodeResult = {
53
94
  readonly ok: true;
54
95
  /** The field's next value (the displayed value with the one prop converted). */
@@ -84,8 +125,9 @@ export declare function isTextPosition(position: ValuePosition): boolean;
84
125
  * same traversal"): a `text` value, and a `richText` / `blocks` sequence (its prose —
85
126
  * the sequence's own text — is the caller's to split) together with every text position
86
127
  * below it: component text props in a composition and in rich-text embeds, object
87
- * sub-fields, repeater items, asset placement sub-fields. An `ai.exclude` position (and
88
- * everything below it) is not text a writer may touch. Depth-first, document order.
128
+ * sub-fields, repeater items, asset placement sub-fields. A writer-locked position
129
+ * (EC-716: `ai.exclude` or `form.readOnly`) and everything below it is not text a writer
130
+ * may touch — not a unit, and so never a skip either. Depth-first, document order.
89
131
  */
90
132
  export declare function textPositionsOf(position: ValuePosition, ctx: Pick<WriterFormContext, 'lookupManifest'>): ValuePosition[];
91
133
  /**
@@ -104,6 +146,16 @@ export declare function rebuildTextPositions(position: ValuePosition, ctx: Pick<
104
146
  * stays, so nothing echoed back can silently lose structure), at EVERY position: a
105
147
  * top-level field, an object sub-field, a repeater item, a component's rich-text prop.
106
148
  * Everything else reads as stored (envelopes are writer forms too).
149
+ *
150
+ * **THE model-read projection** (EC-741): a position core's `writerLockOf` marks
151
+ * `excluded` (`ai: { exclude: true }` — "the model never sees or writes the field") reads
152
+ * as ABSENT, and so does everything below it, at every depth: a sub-field, a repeater
153
+ * item's sub-field, a placement sub-field, a component prop in a composition, a slot or
154
+ * typography content. A `read-only` position stays readable. Every model read crosses this
155
+ * one fold — `getDocument`'s output, the titles the model is shown
156
+ * ({@link modelFacingTitle}) and the echo checks (a value the model read and sent back is
157
+ * no change) — so what the model can see and what it can echo are one declaration. The raw
158
+ * stored document stays local: proposals are always built and validated against it.
107
159
  */
108
160
  export declare function toWriterForm(position: ValuePosition, ctx: Pick<WriterFormContext, 'lookupManifest'>): unknown;
109
161
  /**
@@ -112,5 +164,22 @@ export declare function toWriterForm(position: ValuePosition, ctx: Pick<WriterFo
112
164
  * — in first-seen order. What the `getDocument` output's prop catalog lists.
113
165
  */
114
166
  export declare function componentIdsOf(collection: CollectionDef, document: CmsDocument, lookupManifest: PropFieldLookup): string[];
167
+ /**
168
+ * {@link toWriterForm} over one values bag: each declared field in its read form, an
169
+ * excluded one (and an excluded position inside any field) left out. A key the collection
170
+ * does not declare reads as stored (AD-14: the schema is a lens).
171
+ */
172
+ export declare function toWriterValues(collection: CollectionDef, bag: Readonly<Record<string, unknown>>, ctx: Pick<WriterFormContext, 'lookupManifest'>, locale?: string): Record<string, unknown>;
115
173
  /** {@link toWriterForm} over every field of every bucket of a document. */
116
174
  export declare function toWriterDocument(collection: CollectionDef, document: CmsDocument, ctx: Pick<WriterFormContext, 'lookupManifest'>): CmsDocument;
175
+ /**
176
+ * The title a MODEL is shown for a document (EC-741) — core's `resolveDocumentTitle` over
177
+ * the projected values ({@link toWriterValues}), so an excluded field never reaches the
178
+ * model as a title: neither as the named or first-field fallback, nor through a function
179
+ * `titleField` that reads it (the function sees the field absent). Every title the model
180
+ * reads goes through here: the `updateFields` / `createDocument` outputs, the
181
+ * `searchDocuments` results (their query is matched against this title too, so an
182
+ * excluded value cannot be probed by search) and the system prompt's "currently open"
183
+ * line. `values` is the bag the caller resolves titles from (a locale's merged values).
184
+ */
185
+ export declare function modelFacingTitle(collection: CollectionDef, values: Readonly<Record<string, unknown>>, fallbackId: string, ctx?: Pick<WriterFormContext, 'lookupManifest'>): string;
@@ -36,11 +36,25 @@
36
36
  * (`FIELD_TYPES.blocks.writer === 'composition'`), and a rich-text value must keep the
37
37
  * component nodes it holds (same ids, same components, same places). A component's
38
38
  * PROP is content — {@link fromWriterFormAtNode} changes one, by its node, at any depth
39
- * the traversal reaches.
39
+ * the traversal reaches;
40
+ * - **the writer lock** (EC-716, owner ruling "Yes, honour readOnly") — a position core's
41
+ * `writerLockOf` locks (`ai.exclude` or `form.readOnly`, and everything below it) keeps
42
+ * its CURRENT value. Leaving it out of the written value, or echoing it back unchanged,
43
+ * is no write; any other value is reported as a `locked` problem (create drops it, edit
44
+ * refuses — the same two policies as the id leash);
45
+ * - **the locked-content judge** (EC-738) — a write to a WRITABLE position must not erase
46
+ * the locked content below it, which its own walk never visits: a holder omitted below
47
+ * the write's root keeps its current value whole (omission is no write — and the model
48
+ * may not know the holder holds anything, EC-741); an explicit removal (a root `null` /
49
+ * omitted value, a nested `null`, a shorter list, a removed asset entry) of a holder whose
50
+ * current value holds a defined locked value is a `locked` problem, located at the holder,
51
+ * and the holder keeps its current value. A repeater whose items hold one cannot be
52
+ * shortened at all (items are matched by index). Translate reuses the judge
53
+ * ({@link withLockedFrom}, {@link lockedChanges}).
40
54
  *
41
55
  * Pure and deterministic; no provider, no I/O.
42
56
  */
43
- import { carryAssetPlacements, childPositions, fieldPath, fieldTypeOf, fieldValuePosition, isSequenceValue, rebuildFromChildren, referenceNode, sequenceComponentNodes, walkReferences, walkValuePositions, } from '@elytracms/core/cms-core';
57
+ import { carryAssetPlacements, childPositions, fieldPath, fieldTypeOf, fieldValuePosition, isSequenceValue, readAssetId, rebuildFromChildren, referenceNode, resolveDocumentTitle, seedObjectItem, sequenceComponentNodes, walkReferences, walkValuePositions, writerLockOf, } from '@elytracms/core/cms-core';
44
58
  import { parseRichTextValue, richTextValueToMarkdownProse } from '@elytracms/core/rich-text';
45
59
  import { richTextValueFromModelInput } from './rich-text-input.js';
46
60
  export const NO_HELD_REFERENCES = { documents: new Map(), assets: new Set() };
@@ -65,6 +79,15 @@ export function heldReferencesOf(document) {
65
79
  });
66
80
  return { documents, assets };
67
81
  }
82
+ /**
83
+ * What a writer is told about a write to a locked position (EC-716) — one wording for the
84
+ * create's dropped-value warning, the edit's refusal and a component prop's refusal.
85
+ */
86
+ export function lockedWriteMessage(subjectText, lock) {
87
+ return lock === 'excluded'
88
+ ? `${subjectText} must not be edited by the assistant.`
89
+ : `${subjectText} is read-only — no one changes it in the studio, the assistant included.`;
90
+ }
68
91
  // --- Writer form → stored form -------------------------------------------------------
69
92
  /**
70
93
  * Convert a value a model authored at `position` (its `value` is the WRITER form) into
@@ -73,8 +96,12 @@ export function heldReferencesOf(document) {
73
96
  * which the placement carry and the composition leash read. `null` / `undefined` clears.
74
97
  */
75
98
  export function fromWriterForm(position, current, ctx) {
99
+ return convertRoot(position, current, ctx, position.field.name);
100
+ }
101
+ /** One write, rooted at `position`; `root` names the top-level field in messages. */
102
+ function convertRoot(position, current, ctx, root) {
76
103
  const problems = [];
77
- const value = convert(position, current, ctx, problems, position.field.name);
104
+ const value = convert(position, current, ctx, problems, root, true);
78
105
  return { value, problems };
79
106
  }
80
107
  /** {@link fromWriterForm} for a top-level field's value. */
@@ -132,9 +159,196 @@ function convertChildren(pos, current, ctx, problems, root) {
132
159
  currentChildren.set(childKey(pos, child), child.value);
133
160
  }
134
161
  }
135
- return rebuildFromChildren(pos, positionCtx, (child) => convert(child, currentChildren.get(childKey(pos, child)), ctx, problems, root));
162
+ return rebuildFromChildren(pos, positionCtx, (child) => convert(child, currentChildren.get(childKey(pos, child)), ctx, problems, root, false));
163
+ }
164
+ /**
165
+ * `isRoot` — whether `pos` is the write's root (the field, or the prop a node change
166
+ * names): there an omitted value CLEARS (`update-fields`: "`null` / omitted clears");
167
+ * below it an omitted holder of locked content is kept whole (EC-738).
168
+ */
169
+ function convert(pos, current, ctx, problems, root, isRoot) {
170
+ // EC-716: a locked position keeps its current value — and so, by not being visited,
171
+ // does everything below it (the lock cascades as the form's `readOnly` does).
172
+ const lock = writerLockOf(pos.field);
173
+ if (lock === undefined)
174
+ return convertWritable(pos, current, ctx, problems, root, isRoot);
175
+ if (pos.value === undefined || isEcho(pos, current, ctx, root))
176
+ return current;
177
+ problems.push({
178
+ at: pos.at,
179
+ code: 'invalid-field-value',
180
+ message: lockedWriteMessage(`Field ${subject(pos, root)}`, lock),
181
+ locked: true,
182
+ });
183
+ return current;
184
+ }
185
+ /**
186
+ * {@link convert} for a WRITABLE position, judged for the locked content below it (EC-738).
187
+ * The conversion never visits a locked position its written value no longer holds (a
188
+ * cleared object, a shortened list, a removed asset entry), so the judge compares the locked
189
+ * values under the CURRENT value with those under the converted one.
190
+ */
191
+ function convertWritable(pos, current, ctx, problems, root, isRoot) {
192
+ if (!isPresent(current))
193
+ return convertValue(pos, current, ctx, problems, root);
194
+ const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
195
+ const before = lockedPositionsUnder({ ...pos, value: current }, positionCtx);
196
+ const held = [...before.values()].filter((entry) => isPresent(entry.value));
197
+ if (held.length === 0)
198
+ return convertValue(pos, current, ctx, problems, root);
199
+ // An omission below the write's root is no write (EC-716's rule for a locked position,
200
+ // applied to the holder of one): the holder keeps its current value, whole.
201
+ if (!isRoot && pos.value === undefined)
202
+ return current;
203
+ const next = convertValue(pos, current, ctx, problems, root);
204
+ const lost = lockedLosses(before, lockedPositionsUnder({ ...pos, value: next }, positionCtx));
205
+ // A repeater's items are matched by INDEX: removing one shifts every later item's
206
+ // writable content onto the locked values of the item before it — invisible to a per-key
207
+ // comparison, so any shortening of a repeater that holds locked content is refused.
208
+ const shortened = pos.field.type === 'object' &&
209
+ pos.field.multiple === true &&
210
+ Array.isArray(current) &&
211
+ (Array.isArray(next) ? next.length : 0) < current.length;
212
+ if (lost.length === 0 && !shortened)
213
+ return next;
214
+ problems.push({
215
+ at: pos.at,
216
+ code: 'invalid-field-value',
217
+ message: lockedRemovalMessage(subject(pos, root), root, lost.length > 0 ? lost : held, shortened),
218
+ locked: true,
219
+ });
220
+ return current;
221
+ }
222
+ // --- The locked-content judge (EC-738) -----------------------------------------------
223
+ /** A value that is there (`undefined` / `null` — nothing to lose; the converter's own rule). */
224
+ function isPresent(value) {
225
+ return value !== undefined && value !== null;
226
+ }
227
+ /**
228
+ * The key matching a child position across two values of one position: a component prop by
229
+ * its NODE (ids are unique in a document and survive a prose edit that shifts indices), an
230
+ * entry of an asset LIST by its asset id and occurrence (the carry's own matching,
231
+ * `carryAssetPlacements` — a reordered gallery keeps each placement), anything else by its
232
+ * location below the parent (a repeater item by index, a sub-field by name).
233
+ */
234
+ function lockedChildKey(parent, parentKey, child) {
235
+ if (child.kind === 'prop' && child.component)
236
+ return `node:${child.component.nodeId}:${child.field.name}`;
237
+ const below = child.at.slice(parent.at.length);
238
+ if (parent.field.type === 'asset' && parent.field.multiple !== false && Array.isArray(parent.value)) {
239
+ const index = below[0];
240
+ const entries = parent.value;
241
+ const id = readAssetId(entries[index]);
242
+ const occurrence = entries.slice(0, index).filter((entry) => readAssetId(entry) === id).length;
243
+ return `${parentKey}/asset:${JSON.stringify(id)}#${occurrence}/${JSON.stringify(below.slice(1))}`;
244
+ }
245
+ return `${parentKey}/${JSON.stringify(below)}`;
246
+ }
247
+ /** Every topmost writer-locked position at or below `position`, by {@link lockedChildKey}. */
248
+ function lockedPositionsUnder(position, positionCtx) {
249
+ const out = new Map();
250
+ const visit = (pos, key) => {
251
+ const lock = writerLockOf(pos.field);
252
+ if (lock !== undefined) {
253
+ out.set(key, { value: pos.value, lock, at: pos.at });
254
+ return;
255
+ }
256
+ for (const child of childPositions(pos, positionCtx))
257
+ visit(child, lockedChildKey(pos, key, child));
258
+ };
259
+ visit(position, '');
260
+ return out;
261
+ }
262
+ /** The defined locked values of `before` that `after` does not hold, unchanged, at their key. */
263
+ function lockedLosses(before, after) {
264
+ return [...before].flatMap(([key, entry]) => {
265
+ if (!isPresent(entry.value))
266
+ return [];
267
+ const now = after.get(key);
268
+ return now && isPresent(now.value) && canonicalJson(now.value) === canonicalJson(entry.value) ? [] : [entry];
269
+ });
270
+ }
271
+ /**
272
+ * What a writer is told about a write that would remove locked content (EC-738), located at
273
+ * the HOLDER the model wrote. Read-only content is named (the model reads it); excluded
274
+ * content never is (EC-741: the model never sees it, so naming its path would disclose it).
275
+ */
276
+ export function lockedRemovalMessage(holder, root, locked, shortened) {
277
+ const excluded = locked.some((entry) => entry.lock === 'excluded');
278
+ const what = excluded
279
+ ? 'content the assistant must not change'
280
+ : `read-only content (${locked.map((entry) => label(root, entry.at)).join(', ')}) — no one changes it in the studio, the assistant included`;
281
+ return shortened
282
+ ? `Field ${holder} holds ${what}; the assistant cannot remove items from it (items are matched by position, so removing one would delete that content or move it onto another item). Keep every item and change only their other values.`
283
+ : `Field ${holder} holds ${what}; the assistant cannot clear or remove it. Change only its other values.`;
284
+ }
285
+ /**
286
+ * The value at `position` with every writer-locked position taken from `current` by key
287
+ * (EC-738, translate): where `current` holds that position — its value, an absent one
288
+ * included — the current one wins; a position `current` does not hold at all (a new item, a
289
+ * node it lacks) keeps `position`'s own. {@link lockedChanges} then says whether that kept
290
+ * the locked content as it is.
291
+ */
292
+ export function withLockedFrom(position, current, ctx) {
293
+ const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
294
+ const held = lockedPositionsUnder({ ...position, value: current }, positionCtx);
295
+ const rebuild = (pos, key) => {
296
+ if (writerLockOf(pos.field) !== undefined)
297
+ return held.has(key) ? held.get(key).value : pos.value;
298
+ return rebuildFromChildren(pos, positionCtx, (child) => rebuild(child, lockedChildKey(pos, key, child)));
299
+ };
300
+ return rebuild(position, '');
301
+ }
302
+ /**
303
+ * The locked values a write from `position.value` (the CURRENT value) to `next` would lose or
304
+ * create, judged both ways (EC-738, translate): `lost` — a defined locked value `next` does
305
+ * not hold unchanged; `created` — a defined locked value `next` holds where the current value
306
+ * holds none.
307
+ */
308
+ export function lockedChanges(position, next, ctx) {
309
+ const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
310
+ const before = lockedPositionsUnder(position, positionCtx);
311
+ const after = lockedPositionsUnder({ ...position, value: next }, positionCtx);
312
+ return {
313
+ lost: lockedLosses(before, after),
314
+ created: [...after].flatMap(([key, entry]) => isPresent(entry.value) && !isPresent(before.get(key)?.value) ? [entry] : []),
315
+ };
316
+ }
317
+ /**
318
+ * Whether a value written to a locked position leaves it as it is: a clear of an empty
319
+ * position, or the current value echoed back — as stored, in the form the model READ it
320
+ * in ({@link toWriterForm}: rich text as markdown), or in any writer form that converts to
321
+ * exactly the stored value.
322
+ */
323
+ function isEcho(pos, current, ctx, root) {
324
+ const written = pos.value;
325
+ if (written === null)
326
+ return current === undefined || current === null;
327
+ if (current === undefined || current === null)
328
+ return false;
329
+ const json = canonicalJson(written);
330
+ if (json === canonicalJson(current))
331
+ return true;
332
+ if (json === canonicalJson(toWriterForm({ ...pos, value: current }, ctx)))
333
+ return true;
334
+ const scratch = [];
335
+ const stored = convertValue(pos, current, ctx, scratch, root);
336
+ return scratch.length === 0 && canonicalJson(stored) === canonicalJson(current);
337
+ }
338
+ /**
339
+ * A repeater's current value with every item the written list adds seeded the way a
340
+ * manual "add item" seeds it (core's `seedObjectItem`), so a locked sub-field of a NEW item
341
+ * keeps its default rather than staying empty (EC-716, sol-C 22 at item level). Items are
342
+ * matched by index — the matching the converter uses for every repeater.
343
+ */
344
+ function withSeededItems(current, length, fields) {
345
+ const items = Array.isArray(current) ? current : [];
346
+ if (items.length >= length)
347
+ return current;
348
+ return [...items, ...Array.from({ length: length - items.length }, () => seedObjectItem(fields))];
136
349
  }
137
- function convert(pos, current, ctx, problems, root) {
350
+ /** {@link convert} for a position no lock holds: the writer form → the stored form. */
351
+ function convertValue(pos, current, ctx, problems, root) {
138
352
  const { field } = pos;
139
353
  const raw = pos.value === null ? undefined : pos.value;
140
354
  const report = (code, message, leash) => {
@@ -144,8 +358,13 @@ function convert(pos, current, ctx, problems, root) {
144
358
  switch (fieldTypeOf(field).writer) {
145
359
  case 'composition': {
146
360
  // 4c: a composition is never written whole — the value stays exactly what it is
147
- // (re-sending it unchanged is no change); its props change one at a time.
148
- if (canonicalJson(raw) === canonicalJson(current))
361
+ // (re-sending it unchanged is no change); its props change one at a time. EC-741:
362
+ // "unchanged" includes the value as the model READ it ({@link toWriterForm}: excluded
363
+ // props absent, rich-text props as markdown) — the rule `isEcho` reads.
364
+ const json = canonicalJson(raw);
365
+ if (json === canonicalJson(current))
366
+ return current;
367
+ if (current !== undefined && json === canonicalJson(toWriterForm({ ...pos, value: current }, ctx)))
149
368
  return current;
150
369
  return report('invalid-field-value', `Field ${subject(pos, root)} is a page-builder composition: adding, reordering or removing its components is done on the canvas, not by the assistant. Change one component's prop with blockId + prop (the component's id from getDocument).`);
151
370
  }
@@ -246,9 +465,15 @@ function convert(pos, current, ctx, problems, root) {
246
465
  return undefined;
247
466
  return relation.multiple === true ? refs : refs[0];
248
467
  }
249
- case 'object':
468
+ case 'object': {
250
469
  // A value of the wrong shape has no children; the validator names it.
251
- return raw === undefined ? undefined : convertChildren({ ...pos, value: raw }, current, ctx, problems, root);
470
+ if (raw === undefined)
471
+ return undefined;
472
+ const base = field.type === 'object' && field.multiple && Array.isArray(raw)
473
+ ? withSeededItems(current, raw.length, field.fields)
474
+ : current;
475
+ return convertChildren({ ...pos, value: raw }, base, ctx, problems, root);
476
+ }
252
477
  case 'value':
253
478
  return raw;
254
479
  }
@@ -273,16 +498,31 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
273
498
  const root = fieldValuePosition(field, displayed, locale !== undefined ? { locale } : {});
274
499
  const isTarget = (position) => position.kind === 'prop' && position.component?.nodeId === nodeId && position.field.name === prop;
275
500
  let target;
501
+ // EC-716: the lock the target sits under — its own, or the nearest locked ancestor's
502
+ // (a component embedded in a read-only rich-text prop, a prop of a locked object…).
503
+ let targetLock;
276
504
  const declared = [];
277
505
  let componentId;
278
- walkValuePositions(root, positionCtx, (position) => {
279
- if (position.kind !== 'prop' || position.component?.nodeId !== nodeId)
280
- return;
281
- componentId = position.component.componentId;
282
- declared.push(position.field.name);
283
- if (target === undefined && position.field.name === prop)
284
- target = position;
285
- });
506
+ const visit = (position, inherited) => {
507
+ const lock = writerLockOf(position.field) ?? inherited;
508
+ if (position.kind === 'prop' && position.component?.nodeId === nodeId) {
509
+ componentId = position.component.componentId;
510
+ declared.push(position.field.name);
511
+ if (target === undefined && position.field.name === prop) {
512
+ target = position;
513
+ targetLock = lock;
514
+ }
515
+ }
516
+ for (const child of childPositions(position, positionCtx))
517
+ visit(child, lock);
518
+ };
519
+ visit(root, undefined);
520
+ if (target && targetLock !== undefined) {
521
+ return {
522
+ ok: false,
523
+ message: lockedWriteMessage(`Prop "${prop}" of component "${nodeId}" (${componentId})`, targetLock),
524
+ };
525
+ }
286
526
  if (!target) {
287
527
  if (componentId === undefined) {
288
528
  return {
@@ -296,7 +536,9 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
296
536
  };
297
537
  }
298
538
  const found = target;
299
- const { value: converted, problems } = fromWriterForm({ ...found, value: raw }, found.value, ctx);
539
+ // The prop is the write's ROOT (an omitted value clears it); messages locate positions
540
+ // from the top-level field (`body.content[0].props.group.locked`).
541
+ const { value: converted, problems } = convertRoot({ ...found, value: raw }, found.value, ctx, field.name);
300
542
  const rebuild = (position) => rebuildFromChildren(position, positionCtx, (child) => (isTarget(child) ? converted : rebuild(child)));
301
543
  const value = rebuild(root);
302
544
  return found.exact
@@ -309,22 +551,24 @@ export function isTextPosition(position) {
309
551
  const { type } = position.field;
310
552
  return type === 'text' || type === 'richText' || type === 'blocks';
311
553
  }
312
- function isExcludedBelow(position) {
313
- return position.at.length > 0 && position.field.ai?.exclude === true;
554
+ /** EC-716: a writer-locked position — no unit, no write, nothing below it either. */
555
+ function isLocked(position) {
556
+ return writerLockOf(position.field) !== undefined;
314
557
  }
315
558
  /**
316
559
  * Every TEXT position at or below `position` (EC-593, ratified 4e — "translate uses the
317
560
  * same traversal"): a `text` value, and a `richText` / `blocks` sequence (its prose —
318
561
  * the sequence's own text — is the caller's to split) together with every text position
319
562
  * below it: component text props in a composition and in rich-text embeds, object
320
- * sub-fields, repeater items, asset placement sub-fields. An `ai.exclude` position (and
321
- * everything below it) is not text a writer may touch. Depth-first, document order.
563
+ * sub-fields, repeater items, asset placement sub-fields. A writer-locked position
564
+ * (EC-716: `ai.exclude` or `form.readOnly`) and everything below it is not text a writer
565
+ * may touch — not a unit, and so never a skip either. Depth-first, document order.
322
566
  */
323
567
  export function textPositionsOf(position, ctx) {
324
568
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
325
569
  const out = [];
326
570
  const visit = (current) => {
327
- if (isExcludedBelow(current))
571
+ if (isLocked(current))
328
572
  return;
329
573
  if (isTextPosition(current))
330
574
  out.push(current);
@@ -346,7 +590,7 @@ export function textPositionsOf(position, ctx) {
346
590
  export function rebuildTextPositions(position, ctx, replace) {
347
591
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
348
592
  const rebuild = (current) => {
349
- if (isExcludedBelow(current))
593
+ if (isLocked(current))
350
594
  return current.value;
351
595
  if (current.field.type === 'text')
352
596
  return replace(current);
@@ -364,8 +608,20 @@ export function rebuildTextPositions(position, ctx, replace) {
364
608
  * stays, so nothing echoed back can silently lose structure), at EVERY position: a
365
609
  * top-level field, an object sub-field, a repeater item, a component's rich-text prop.
366
610
  * Everything else reads as stored (envelopes are writer forms too).
611
+ *
612
+ * **THE model-read projection** (EC-741): a position core's `writerLockOf` marks
613
+ * `excluded` (`ai: { exclude: true }` — "the model never sees or writes the field") reads
614
+ * as ABSENT, and so does everything below it, at every depth: a sub-field, a repeater
615
+ * item's sub-field, a placement sub-field, a component prop in a composition, a slot or
616
+ * typography content. A `read-only` position stays readable. Every model read crosses this
617
+ * one fold — `getDocument`'s output, the titles the model is shown
618
+ * ({@link modelFacingTitle}) and the echo checks (a value the model read and sent back is
619
+ * no change) — so what the model can see and what it can echo are one declaration. The raw
620
+ * stored document stays local: proposals are always built and validated against it.
367
621
  */
368
622
  export function toWriterForm(position, ctx) {
623
+ if (writerLockOf(position.field) === 'excluded')
624
+ return undefined;
369
625
  const { value } = position;
370
626
  if (value === undefined || value === null)
371
627
  return value;
@@ -402,28 +658,50 @@ export function componentIdsOf(collection, document, lookupManifest) {
402
658
  visitBucket(bucket);
403
659
  return [...ids];
404
660
  }
661
+ /**
662
+ * {@link toWriterForm} over one values bag: each declared field in its read form, an
663
+ * excluded one (and an excluded position inside any field) left out. A key the collection
664
+ * does not declare reads as stored (AD-14: the schema is a lens).
665
+ */
666
+ export function toWriterValues(collection, bag, ctx, locale) {
667
+ let next;
668
+ for (const field of collection.fields) {
669
+ if (!Object.hasOwn(bag, field.name))
670
+ continue;
671
+ const value = bag[field.name];
672
+ const read = toWriterForm(fieldValuePosition(field, value, locale !== undefined ? { locale } : {}), ctx);
673
+ if (read === value)
674
+ continue;
675
+ next ??= { ...bag };
676
+ if (read === undefined)
677
+ delete next[field.name];
678
+ else
679
+ next[field.name] = read;
680
+ }
681
+ return next ?? bag;
682
+ }
405
683
  /** {@link toWriterForm} over every field of every bucket of a document. */
406
684
  export function toWriterDocument(collection, document, ctx) {
407
- const byName = new Map(collection.fields.map((field) => [field.name, field]));
408
- const mapBucket = (bucket, locale) => {
409
- let next = bucket;
410
- for (const [name, value] of Object.entries(bucket)) {
411
- const field = byName.get(name);
412
- if (!field)
413
- continue;
414
- const read = toWriterForm(fieldValuePosition(field, value, locale !== undefined ? { locale } : {}), ctx);
415
- if (read === value)
416
- continue;
417
- if (next === bucket)
418
- next = { ...bucket };
419
- next[name] = read;
420
- }
421
- return next;
422
- };
423
- const values = mapBucket(document.values);
685
+ const values = toWriterValues(collection, document.values, ctx);
424
686
  const localized = document.localized
425
- ? Object.fromEntries(Object.entries(document.localized).map(([locale, bucket]) => [locale, mapBucket(bucket, locale)]))
687
+ ? Object.fromEntries(Object.entries(document.localized).map(([locale, bucket]) => [
688
+ locale,
689
+ toWriterValues(collection, bucket, ctx, locale),
690
+ ]))
426
691
  : undefined;
427
692
  return { ...document, values, ...(localized ? { localized } : {}) };
428
693
  }
694
+ /**
695
+ * The title a MODEL is shown for a document (EC-741) — core's `resolveDocumentTitle` over
696
+ * the projected values ({@link toWriterValues}), so an excluded field never reaches the
697
+ * model as a title: neither as the named or first-field fallback, nor through a function
698
+ * `titleField` that reads it (the function sees the field absent). Every title the model
699
+ * reads goes through here: the `updateFields` / `createDocument` outputs, the
700
+ * `searchDocuments` results (their query is matched against this title too, so an
701
+ * excluded value cannot be probed by search) and the system prompt's "currently open"
702
+ * line. `values` is the bag the caller resolves titles from (a locale's merged values).
703
+ */
704
+ export function modelFacingTitle(collection, values, fallbackId, ctx = {}) {
705
+ return resolveDocumentTitle(collection, toWriterValues(collection, values, ctx), fallbackId);
706
+ }
429
707
  //# sourceMappingURL=writer-form.js.map