@elytracms/studio 0.0.99 → 0.0.101

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 (133) hide show
  1. package/convex/analytics-cost.test.ts +1 -1
  2. package/convex/cms.ts +29 -2
  3. package/convex/records-column-transform.convex.test.ts +5 -3
  4. package/convex/records-one-write.convex.test.ts +218 -0
  5. package/convex/redirect-migration.test.ts +1 -1
  6. package/convex/redirects.ts +2 -2
  7. package/convex/shared-capture.convex.test.ts +165 -0
  8. package/convex/version-kind-stamping.test.ts +118 -0
  9. package/dist/components/ai/ai-chat-panel-lazy.js +7 -2
  10. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  11. package/dist/components/ai/proposal-card.js +2 -1
  12. package/dist/components/ai/proposal-card.js.map +1 -1
  13. package/dist/components/ai/proposal-unfilled.d.ts +14 -0
  14. package/dist/components/ai/proposal-unfilled.js +33 -0
  15. package/dist/components/ai/proposal-unfilled.js.map +1 -0
  16. package/dist/components/ai/translate-document-action.js +8 -6
  17. package/dist/components/ai/translate-document-action.js.map +1 -1
  18. package/dist/components/app-views/studio-config.d.ts +12 -12
  19. package/dist/components/app-views/studio-config.js.map +1 -1
  20. package/dist/components/builder/builder-store.d.ts +37 -1
  21. package/dist/components/builder/builder-store.js +21 -6
  22. package/dist/components/builder/builder-store.js.map +1 -1
  23. package/dist/components/builder/canvas-chrome-css.d.ts +1 -1
  24. package/dist/components/builder/canvas-chrome-css.js +1 -1
  25. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  26. package/dist/components/builder/canvas-chrome.d.ts +1 -1
  27. package/dist/components/builder/canvas-chrome.js +1 -1
  28. package/dist/components/builder/canvas-chrome.js.map +1 -1
  29. package/dist/components/builder/canvas-rebase.d.ts +4 -4
  30. package/dist/components/builder/canvas-rebase.js +3 -2
  31. package/dist/components/builder/canvas-rebase.js.map +1 -1
  32. package/dist/components/builder/canvas-render-context.d.ts +1 -1
  33. package/dist/components/builder/canvas-render-context.js +1 -1
  34. package/dist/components/builder/canvas-render-context.js.map +1 -1
  35. package/dist/components/builder/composition-field-editor-lazy.js +2 -1
  36. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  37. package/dist/components/builder/reuse-context.d.ts +8 -2
  38. package/dist/components/builder/reuse-context.js +62 -7
  39. package/dist/components/builder/reuse-context.js.map +1 -1
  40. package/dist/components/builder/reuse.d.ts +106 -70
  41. package/dist/components/builder/reuse.js +125 -108
  42. package/dist/components/builder/reuse.js.map +1 -1
  43. package/dist/components/cms/cms-store.d.ts +9 -1
  44. package/dist/components/cms/cms-store.js +1 -0
  45. package/dist/components/cms/cms-store.js.map +1 -1
  46. package/dist/components/cms/detail-editor.js +13 -13
  47. package/dist/components/cms/detail-editor.js.map +1 -1
  48. package/dist/components/cms/prop-fields.js +1 -1
  49. package/dist/components/cms/prop-fields.js.map +1 -1
  50. package/dist/components/cms/rich-text/lazy.js +2 -1
  51. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  52. package/dist/components/cms/rich-text/toolbar-state.d.ts +1 -1
  53. package/dist/components/cms/rich-text/toolbar-state.js +1 -1
  54. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  55. package/dist/components/cms/use-field-focus.js +3 -3
  56. package/dist/components/cms/use-field-focus.js.map +1 -1
  57. package/dist/components/cms/version-compare-lazy.js +2 -1
  58. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  59. package/dist/components/components/components-index.js +1 -1
  60. package/dist/components/components/components-index.js.map +1 -1
  61. package/dist/components/deployment-setup-gate.d.ts +1 -1
  62. package/dist/components/deployment-setup-gate.js +1 -1
  63. package/dist/components/deployment-setup-gate.js.map +1 -1
  64. package/dist/components/load-failure.d.ts +62 -0
  65. package/dist/components/load-failure.js +110 -0
  66. package/dist/components/load-failure.js.map +1 -0
  67. package/dist/components/notifications/shell-findings.js +3 -3
  68. package/dist/components/notifications/shell-findings.js.map +1 -1
  69. package/dist/components/records/record-rail.js +16 -5
  70. package/dist/components/records/record-rail.js.map +1 -1
  71. package/dist/customization.d.ts +3 -3
  72. package/dist/customization.js.map +1 -1
  73. package/dist/lib/ai/apply-content.js +99 -28
  74. package/dist/lib/ai/apply-content.js.map +1 -1
  75. package/dist/lib/ai/chat-context.d.ts +73 -7
  76. package/dist/lib/ai/chat-context.js +133 -14
  77. package/dist/lib/ai/chat-context.js.map +1 -1
  78. package/dist/lib/ai/chat-state.d.ts +7 -1
  79. package/dist/lib/ai/chat-state.js.map +1 -1
  80. package/dist/lib/ai/constraints.d.ts +5 -0
  81. package/dist/lib/ai/constraints.js +21 -6
  82. package/dist/lib/ai/constraints.js.map +1 -1
  83. package/dist/lib/ai/fake-provider.d.ts +76 -26
  84. package/dist/lib/ai/fake-provider.js +210 -66
  85. package/dist/lib/ai/fake-provider.js.map +1 -1
  86. package/dist/lib/ai/index.d.ts +1 -0
  87. package/dist/lib/ai/index.js +1 -0
  88. package/dist/lib/ai/index.js.map +1 -1
  89. package/dist/lib/ai/keyless-assistant.d.ts +37 -0
  90. package/dist/lib/ai/keyless-assistant.js +193 -0
  91. package/dist/lib/ai/keyless-assistant.js.map +1 -0
  92. package/dist/lib/ai/shape.d.ts +25 -2
  93. package/dist/lib/ai/shape.js +58 -7
  94. package/dist/lib/ai/shape.js.map +1 -1
  95. package/dist/lib/ai/tools.d.ts +11 -1
  96. package/dist/lib/ai/tools.js +51 -6
  97. package/dist/lib/ai/tools.js.map +1 -1
  98. package/dist/lib/ai/translate-document.js +6 -5
  99. package/dist/lib/ai/translate-document.js.map +1 -1
  100. package/dist/lib/ai/types.d.ts +22 -7
  101. package/dist/lib/ai/types.js.map +1 -1
  102. package/dist/lib/ai/update-fields.js +9 -6
  103. package/dist/lib/ai/update-fields.js.map +1 -1
  104. package/dist/lib/ai/writer-form.d.ts +11 -3
  105. package/dist/lib/ai/writer-form.js +105 -18
  106. package/dist/lib/ai/writer-form.js.map +1 -1
  107. package/dist/lib/auth-errors.d.ts +1 -1
  108. package/dist/lib/auth-errors.js +1 -1
  109. package/dist/lib/auth-errors.js.map +1 -1
  110. package/dist/lib/engine-version.d.ts +1 -1
  111. package/dist/lib/engine-version.js +1 -1
  112. package/dist/lib/engine-version.js.map +1 -1
  113. package/dist/lib/i18n/messages.de.d.ts +1 -1
  114. package/dist/lib/i18n/messages.de.js +24 -7
  115. package/dist/lib/i18n/messages.de.js.map +1 -1
  116. package/dist/lib/i18n/messages.en.d.ts +19 -3
  117. package/dist/lib/i18n/messages.en.js +30 -9
  118. package/dist/lib/i18n/messages.en.js.map +1 -1
  119. package/dist/lib/operations/cms-persistence.d.ts +18 -0
  120. package/dist/lib/operations/cms-persistence.js +56 -15
  121. package/dist/lib/operations/cms-persistence.js.map +1 -1
  122. package/dist/lib/persistence/convex-adapter.d.ts +2 -0
  123. package/dist/lib/persistence/convex-adapter.js +67 -2
  124. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  125. package/dist/lib/workspace/index.d.ts +3 -3
  126. package/dist/lib/workspace/index.js +3 -3
  127. package/dist/lib/workspace/index.js.map +1 -1
  128. package/dist/router/navigate-once.d.ts +1 -1
  129. package/dist/router/navigate-once.js.map +1 -1
  130. package/dist/router/root.d.ts +3 -2
  131. package/dist/router/root.js +25 -4
  132. package/dist/router/root.js.map +1 -1
  133. package/package.json +3 -3
@@ -2,6 +2,8 @@ import type { ComponentManifest } from '@elytracms/core/component-registry';
2
2
  import type { CmsDocument, CollectionDef, DocumentRef, LocaleConfig } from '@elytracms/core/cms-core';
3
3
  import type { ComponentNode, PlacementScope, PlacementVerdict } from '@elytracms/core/project-graph';
4
4
  import type { CustomizationIssue } from '@elytracms/core/studio-core';
5
+ import type { DeleteCreatedOutcome } from '../../lib/operations/cms-persistence.js';
6
+ import type { CanvasHistoryEffect } from './builder-store.js';
5
7
  /**
6
8
  * The reuse capability (EC-282): the engine-generic description of how a project
7
9
  * models reusable elements. **AD-a (EC-467): reusable elements are SINGULAR per
@@ -9,40 +11,22 @@ import type { CustomizationIssue } from '@elytracms/core/studio-core';
9
11
  * Multiple reuse collections are deliberately not supported, which is why this is
10
12
  * one value and never a list.
11
13
  *
12
- * The capability is normally DERIVED from config (manifests + collections) — no
13
- * declaration needed, so a consumer that already has a transcluding embed gets the
14
- * in-canvas gestures for free (the EC-296 "works automatically" pattern). The
15
- * derivation predicate for a "reusable embed" component: it contributes no slots of
16
- * its own and has exactly ONE `relation` prop (cardinality `one`) whose target
17
- * collection holds a `blocks` field — i.e. it embeds a single document and renders
18
- * that document's composition by transclusion (EC-254/EC-281). For Konditorei that
19
- * is uniquely `project.Reusable` → `element` → `reusable.content`.
14
+ * **EC-728 (owner ruling 2026-10-31, "Declare, never infer"): the capability exists
15
+ * only when the project DECLARES its embed** — `defineStudio({ reuse: { component } })`
16
+ * ({@link StudioReuseConfig}). The engine never guesses one from a component's shape.
17
+ * The shape test it used to run (no slots, exactly one cardinality-one `relation`
18
+ * prop whose target collection holds a `blocks` field) cannot tell an embed from an
19
+ * ordinary card that links to a page with a canvas: EC-462 refused the guess when two
20
+ * components matched, and EC-728 measured the one-candidate guess crowning a "read
21
+ * next" link card — *Make reusable* then moved a block's content into a new document
22
+ * and left a link card in its place. A guess with a destructive gesture attached is
23
+ * the bug, so there is no guess.
20
24
  *
21
- * EC-462 — the predicate additionally DISQUALIFIES a candidate whose own manifest
22
- * explicitly declares its relation prop to be something other than an embed
23
- * pointer. The shape test alone proved unsound the moment a relation prop's
24
- * target collection gains a `blocks` field of its own (a category/page with a
25
- * canvas — a completely natural content shape): the EC-276 sprint's ProductGrid
26
- * and the demo's OptionBox were both claimed as "the embed", so their ordinary
27
- * relation picks rendered the reusable-element placeholder, grew Detach/Make-
28
- * reusable gestures, and fed embed-worded publish-integrity issues. Two
29
- * explicit-declaration disqualifiers close the observed class:
30
- *
31
- * - a manifest with a `listing` declaration is a QUERY host (EC-255) — its
32
- * relation prop feeds the query (a `{ prop }` operand of `listing.where`),
33
- * never a transclusion pointer;
34
- * - a relation prop with `form.hidden` (predicate or `true`) is conditional/
35
- * auxiliary — EC-394 strips a hidden relation's ref at publish, so it cannot
36
- * coherently be what the component transcludes.
37
- *
38
- * Both read only the CANDIDATE manifest's own declarations (never the target's
39
- * contents) and can only EXCLUDE, so a genuine pure embed (which declares
40
- * neither) is always kept. EC-462's residual — a bare unconditional
41
- * single-relation display component (e.g. a page teaser) is at rest
42
- * indistinguishable from a genuine embed — is closed by EC-467's optional
43
- * {@link StudioReuseConfig} declaration: a project that hits ambiguity declares
44
- * its way out, and the ambiguity is LOUD instead of silently disabling the
45
- * gestures. See {@link resolveReuse}.
25
+ * What the capability drives (and therefore what an undeclared project does not get):
26
+ * the in-canvas gestures (*Make reusable* / *Detach to local copy*), the canvas
27
+ * placeholder for an embed whose target is a draft or missing (EC-300), and the reuse
28
+ * config issues. Transclusion itself does not depend on it — the runtime renderer
29
+ * injects `renderBlocks` for every manifest that consumes relations.
46
30
  */
47
31
  export interface ReuseCapability {
48
32
  /** The embed component, e.g. `project.Reusable`. */
@@ -57,8 +41,8 @@ export interface ReuseCapability {
57
41
  contentCardinality: 'one' | 'many';
58
42
  }
59
43
  /**
60
- * EC-467 (AD-b) — the OPTIONAL studio-config declaration of the reuse embed:
61
- * `defineStudio({ reuse: { component: 'project.Reusable' } })`. See
44
+ * EC-467 (AD-b) → EC-728 — THE declaration of the reuse embed (absent: the project has
45
+ * no reusable elements): `defineStudio({ reuse: { component: 'project.Reusable' } })`. See
62
46
  * {@link import('../app-views/studio-config').StudioConfig.reuse} for the
63
47
  * consumer-facing contract; this is the shape {@link resolveReuse} consumes.
64
48
  *
@@ -78,16 +62,17 @@ export interface StudioReuseConfig {
78
62
  }
79
63
  /**
80
64
  * EC-467 — the resolution of the project's reuse capability: the capability (or
81
- * `null`), plus the config issues a developer must SEE.
65
+ * `null`), plus the config issues a developer must SEE (a declaration that cannot
66
+ * resolve).
82
67
  *
83
68
  * The issues ride the EC-236 {@link CustomizationIssue} surface (`point: 'reuse'`),
84
- * exactly like the sidebar's and the field renderers' — so they render at the
85
- * studio rail foot on every surface, which is the studio's home for
69
+ * exactly like the sidebar's and the field renderers' — so they render in the header
70
+ * Issues popover on every surface (EC-690's one issues tray), the studio's home for
86
71
  * project-CONFIGURATION problems (the document-scoped Issues pane is the wrong
87
72
  * home: this is not a content problem in one document).
88
73
  *
89
74
  * EC-478: each issue carries a `messageRef` (catalog key + identifier values)
90
- * alongside its prebuilt English, so the rail foot renders it in the active UI
75
+ * alongside its prebuilt English, so the tray renders it in the active UI
91
76
  * locale. This module stays pure — it names a key, it never calls `translate`;
92
77
  * `lib/persona/labels.ts` resolves it at the render site. That is what makes the
93
78
  * whole list localize together, which is the mixed-language outcome EC-467
@@ -95,41 +80,30 @@ export interface StudioReuseConfig {
95
80
  */
96
81
  export interface ReuseResolution {
97
82
  capability: ReuseCapability | null;
98
- /** Empty whenever the capability is unambiguous — including "this project has none". */
83
+ /** Empty whenever the declaration resolves — and when there is none ("this project has none"). */
99
84
  issues: CustomizationIssue[];
100
85
  }
101
86
  /**
102
- * The components whose SHAPE makes them plausible reuse embeds (the zero-config
103
- * heuristic + EC-462's disqualifiers), in manifest order. Exported for the tests
104
- * and for issue copy; the resolution rule lives in {@link resolveReuse}.
105
- */
106
- export declare function inferredEmbedCandidates(manifests: readonly ComponentManifest[], collections: readonly CollectionDef[]): ReuseCapability[];
107
- /**
108
- * Resolve the project's ONE reuse capability (AD-a) plus any config issues —
109
- * EC-467's full matrix (AD-c):
110
- *
111
- * | Declaration | Candidates | Result |
112
- * | --- | --- | --- |
113
- * | present, resolves | any | the declared capability, no issue |
114
- * | present, does not resolve | any | `null` + an ERROR issue (misconfiguration) |
115
- * | absent | exactly 1 | that candidate, no issue (the zero-config default) |
116
- * | absent | 2 or more | `null` + a WARNING issue naming every candidate |
117
- * | absent | 0 | `null`, **no issue** — a project need not use reusable elements |
87
+ * Resolve the project's ONE reuse capability (AD-a) plus any config issues — EC-728's
88
+ * matrix (declare, never infer):
118
89
  *
119
- * Severity is the honest split: a declaration that cannot resolve is broken
120
- * config the developer asked for (error); inference ambiguity is nothing broken —
121
- * the engine declines to guess and asks for one line (warning). Both disable the
122
- * gestures, and both render identically in the rail's issue list.
90
+ * | Declaration | Result |
91
+ * | --- | --- |
92
+ * | present, resolves | the declared capability, no issue |
93
+ * | present, does not resolve | `null` + an ERROR issue (misconfiguration) |
94
+ * | absent | `null`, **no issue** — the project has no reusable elements |
123
95
  *
124
- * The zero-candidate row is deliberately silent: crying wolf at every project
125
- * that has no reusable elements would train developers to ignore the surface.
96
+ * The absent row is deliberately silent: a project need not use reusable elements,
97
+ * and naming a component that "looks like an embed" would be the guess again, as
98
+ * advice (EC-728). An unresolvable declaration disables the gestures and is reported
99
+ * once, in the header Issues popover.
126
100
  */
127
101
  export declare function resolveReuse(manifests: readonly ComponentManifest[], collections: readonly CollectionDef[], declaration?: StudioReuseConfig): ReuseResolution;
128
102
  /**
129
- * The project's reuse capability alone, or `null` when it is absent, ambiguous, or
103
+ * The project's reuse capability alone, or `null` when it is undeclared or
130
104
  * misdeclared — for callers that act on the capability and do not surface config
131
- * issues themselves (the publish-integrity walk). The issues are surfaced ONCE, by
132
- * the shell rail; see {@link resolveReuse}.
105
+ * issues themselves (the gestures, the canvas placeholder). The issues are surfaced
106
+ * ONCE, by the shell; see {@link resolveReuse}.
133
107
  */
134
108
  export declare function resolveReuseCapability(manifests: readonly ComponentManifest[], collections: readonly CollectionDef[], declaration?: StudioReuseConfig): ReuseCapability | null;
135
109
  /** A slug for `name`, suffixed `-2`/`-3`/… until it is free among `taken`. */
@@ -137,7 +111,7 @@ export declare function uniqueSlug(name: string, taken: ReadonlySet<string>): st
137
111
  /**
138
112
  * The values for a new reusable document promoted from `node` (EC-282): seeded
139
113
  * defaults, the given `name` on the title field, the node on the content field,
140
- * and a collision-safe slug on any UNIQUE text field (e.g. Konditorei's
114
+ * and a collision-safe slug on any UNIQUE text field (e.g. a production site's
141
115
  * `internalId`) so the created document is publish-valid without hardcoding that
142
116
  * field's name. Other required fields, if any, are completed by the editor when
143
117
  * they open the new reusable.
@@ -155,7 +129,16 @@ export declare function buildReusableValues(collection: CollectionDef, capabilit
155
129
  * whose target doesn't resolve (→ placeholder) without reaching into prop internals.
156
130
  */
157
131
  export declare function reuseEmbedRef(node: ComponentNode, capability: ReuseCapability): DocumentRef | null;
158
- /** The `Reusable` embed node referencing the new document (id minted by the action). */
132
+ /**
133
+ * The `Reusable` embed node referencing the new document (id minted by the action).
134
+ *
135
+ * EC-728 — CANONICAL from the first write: the prop is core's `staticProp` (bare `{ value }`,
136
+ * EC-312), never `{ kind: 'static', value }`. The canvas commits its node patches raw, while
137
+ * the draft takes the same write through the self-typing lens, which strips a redundant
138
+ * `kind`; a non-canonical node therefore made the two views of the canvas field disagree, the
139
+ * draft reported the disagreement as a change made underneath the canvas, and the rebase
140
+ * dropped *Make reusable*'s own undo step (`reuse-undo.test.ts`).
141
+ */
159
142
  export declare function makeEmbedNode(capability: ReuseCapability, documentId: string): ComponentNode;
160
143
  /**
161
144
  * Why a reuse gesture is refused before it does anything: the one core placement
@@ -205,7 +188,11 @@ export declare function detachRefusal(args: {
205
188
  export interface PromoteEffects {
206
189
  newDocumentId(): string;
207
190
  insertDocument(doc: CmsDocument): void;
208
- replaceInCanvas(nodeId: string, embed: ComponentNode): void;
191
+ /**
192
+ * The in-place swap — ONE history entry carrying `effect` (EC-728: the created document, so
193
+ * Undo can remove it when that is safe and Redo can re-insert it).
194
+ */
195
+ replaceInCanvas(nodeId: string, embed: ComponentNode, effect: CanvasHistoryEffect): void;
209
196
  /** Publish the new reusable; resolves `true` only when the document IS published afterwards. */
210
197
  publish(ref: DocumentRef): Promise<boolean>;
211
198
  refused(refusal: ReuseRefusal): void;
@@ -216,8 +203,9 @@ export interface PromoteEffects {
216
203
  /**
217
204
  * Run "Make reusable" (EC-282) — the preflight first (EC-584: a refusal creates,
218
205
  * publishes and replaces NOTHING), then ONE insert-if-absent create of the reusable
219
- * document (EC-595 A2), the in-place swap for the embed, and the chosen publish,
220
- * whose success notice is shown only when the document is actually published.
206
+ * document (EC-595 A2), the in-place swap for the embed — one canvas history entry that
207
+ * records the created document (EC-728) — and the chosen publish, whose success notice is
208
+ * shown only when the document is actually published.
221
209
  */
222
210
  export declare function runPromote(args: {
223
211
  root: ComponentNode;
@@ -235,3 +223,51 @@ export declare function runPromote(args: {
235
223
  canvasFieldLabel: string;
236
224
  effects: PromoteEffects;
237
225
  }): Promise<'refused' | 'draft' | 'published' | 'publish-failed'>;
226
+ /**
227
+ * What Undo did with the document *Make reusable* created: `deleted`, or already `gone`, or
228
+ * KEPT — because it is `published`, was `edited` since it was made, is `referenced` elsewhere,
229
+ * or the removal `failed`. The block is restored in every case (the canvas entry is a snapshot).
230
+ */
231
+ export type PromoteUndoOutcome = 'deleted' | 'gone' | 'published' | 'edited' | 'referenced' | 'failed';
232
+ /** The authoritative reads and the one write Undo needs — injected, so every branch is testable. */
233
+ export interface PromoteUndoDeps {
234
+ /** Await every queued write: the page's restored value lands before anything is judged. */
235
+ flush(): Promise<void>;
236
+ /** The document's record now (`undefined`: it no longer exists). */
237
+ read(ref: DocumentRef): Promise<{
238
+ published: boolean;
239
+ } | undefined>;
240
+ /** How many reference-index entries (draft or published, any document) point at it now. */
241
+ referrers(ref: DocumentRef): Promise<number>;
242
+ /** The conditional delete — at the revision this session's create landed at (`CmsPersister.deleteCreated`). */
243
+ deleteCreated(ref: DocumentRef): Promise<DeleteCreatedOutcome>;
244
+ }
245
+ /**
246
+ * EC-728 — Undo of *Make reusable*, the document side. The created document is deleted ONLY
247
+ * when every authoritative fact at Undo time says nobody else has it: never published (the
248
+ * record), referenced nowhere — after the page's own restore has landed — (the reference
249
+ * index, both variants), and unchanged since its create (the delete's revision precondition,
250
+ * checked by the store in its own step, which also refuses a strict referrer). Anything else
251
+ * keeps it, with the reason. A read that throws keeps it too: an unproven fact never deletes.
252
+ */
253
+ export declare function undoPromote(effect: CanvasHistoryEffect, deps: PromoteUndoDeps): Promise<PromoteUndoOutcome>;
254
+ /** The catalog keys of the kept-document notices (EN + DE in the studio catalogs). */
255
+ export type ReuseUndoNoticeKey = 'builder.reuse.undoKeptPublished' | 'builder.reuse.undoKeptEdited' | 'builder.reuse.undoKeptReferenced' | 'builder.reuse.undoKeptFailed';
256
+ /** The notice for a KEPT document (EC-728 — it names the document and why); none when it went. */
257
+ export declare function undoPromoteNotice(outcome: PromoteUndoOutcome): ReuseUndoNoticeKey | undefined;
258
+ /**
259
+ * EC-728 — the document side of every Undo/Redo that crosses a *Make reusable* step, for one
260
+ * canvas. Crossings run strictly in order (a Redo waits for the Undo before it), so a fast
261
+ * Undo → Redo never re-inserts a document whose removal is still in flight. Redo re-inserts
262
+ * (the same id, insert-if-absent) exactly the documents ITS Undo removed or found gone — never
263
+ * one Undo kept (it is still there) — and the canvas entry re-swaps the embed.
264
+ */
265
+ export declare function createPromoteHistory(deps: PromoteUndoDeps & {
266
+ insert(document: CmsDocument): void;
267
+ notify(key: ReuseUndoNoticeKey, name: string): void;
268
+ }): {
269
+ crossed(step: {
270
+ direction: 'undo' | 'redo';
271
+ effect: CanvasHistoryEffect;
272
+ }): Promise<void>;
273
+ };
@@ -1,6 +1,6 @@
1
- import { manifestPropFields, manifestSlots } from '@elytracms/core/component-registry';
2
- import { fieldAllowList, itemLimit, readDocumentRef, referenceNode, seedDefaults, titleFieldOf, writeField, } from '@elytracms/core/cms-core';
3
- import { editBlockRoots, isStaticProp, replacePlacementRefusal, valuePlacementRefusal } from '@elytracms/core/project-graph';
1
+ import { manifestPropFields } from '@elytracms/core/component-registry';
2
+ import { documentKey, fieldAllowList, itemLimit, readDocumentRef, referenceNode, seedDefaults, titleFieldOf, writeField, } from '@elytracms/core/cms-core';
3
+ import { editBlockRoots, isStaticProp, replacePlacementRefusal, staticProp, valuePlacementRefusal, } from '@elytracms/core/project-graph';
4
4
  import { slugify } from '../../lib/utils.js';
5
5
  import { fieldLabel } from '../cms/cms-labels.js';
6
6
  /** The customization point name stamped onto every reuse issue (EC-236 locator). */
@@ -11,13 +11,7 @@ function relationPropsOf(manifest) {
11
11
  for (const field of manifestPropFields(manifest)) {
12
12
  if (field.type !== 'relation')
13
13
  continue;
14
- const hidden = field.form?.hidden;
15
- props.push({
16
- name: field.name,
17
- target: field.target,
18
- multiple: field.multiple === true,
19
- conditional: hidden !== undefined && hidden !== false,
20
- });
14
+ props.push({ name: field.name, target: field.target, multiple: field.multiple === true });
21
15
  }
22
16
  return props;
23
17
  }
@@ -47,7 +41,6 @@ const REUSE_KEYS = {
47
41
  ambiguousRelationProp: 'builder.reuse.config.ambiguousRelationProp',
48
42
  unknownCollection: 'builder.reuse.config.unknownCollection',
49
43
  noBlocksField: 'builder.reuse.config.noBlocksField',
50
- ambiguousInference: 'builder.reuse.config.ambiguousInference',
51
44
  };
52
45
  function reuseIssue(code, severity, message, messageRef, id) {
53
46
  return {
@@ -64,54 +57,11 @@ function quotedList(names) {
64
57
  return names.map((name) => `"${name}"`).join(', ');
65
58
  }
66
59
  /**
67
- * The components whose SHAPE makes them plausible reuse embeds (the zero-config
68
- * heuristic + EC-462's disqualifiers), in manifest order. Exported for the tests
69
- * and for issue copy; the resolution rule lives in {@link resolveReuse}.
70
- */
71
- export function inferredEmbedCandidates(manifests, collections) {
72
- const matches = [];
73
- for (const manifest of manifests) {
74
- // EC-591 (COMPOSITION #15): the DERIVED slots — a `blocks` prop is a child
75
- // region (`manifestSlots`), so a manifest with `slots: []` and a blocks prop is
76
- // NOT slot-less; reading raw `manifest.slots` inferred it as a reuse embed.
77
- if (manifestSlots(manifest).length > 0)
78
- continue;
79
- // EC-462: a `listing` manifest is a query host by its own declaration — its
80
- // relation prop is the filter source, never an embed pointer.
81
- if (manifest.listing)
82
- continue;
83
- // The heuristic requires the relation to be UNAMBIGUOUS within the manifest:
84
- // exactly ONE relation prop of any kind, which must then be targeted,
85
- // cardinality-one and unconditional. (Counting all relation props — not just
86
- // the embeddable ones — is deliberate: a component with a second, list-valued
87
- // relation is not the single-purpose shape the heuristic is allowed to claim.)
88
- const relations = relationPropsOf(manifest);
89
- const relation = relations.length === 1 ? relations[0] : undefined;
90
- if (!relation || relation.target === undefined || relation.multiple || relation.conditional)
91
- continue;
92
- const collection = collections.find((c) => c.id === relation.target);
93
- if (!collection)
94
- continue;
95
- const blocksField = blocksFieldOf(collection);
96
- if (!blocksField)
97
- continue;
98
- matches.push({
99
- embedComponentId: manifest.id,
100
- relationProp: relation.name,
101
- collectionId: collection.id,
102
- contentField: blocksField.name,
103
- contentCardinality: itemLimit(blocksField) === 1 ? 'one' : 'many',
104
- });
105
- }
106
- return matches;
107
- }
108
- /**
109
- * Resolve the DECLARED embed (EC-467, AD-b). An explicit declaration is the
110
- * developer's authority, so the inference disqualifiers are NOT applied here: a
111
- * declared component may have slots, may declare `listing`, and its relation prop
112
- * may be `form.hidden` — those exist to stop the engine GUESSING, not to overrule
113
- * a stated intent. Only structural impossibilities are issues (see AD-c row 2):
114
- * an unknown component id, no usable relation prop (or an ambiguous set with no
60
+ * Resolve the DECLARED embed (EC-467, AD-b). The declaration is the developer's
61
+ * authority: a declared component may have slots, may declare `listing`, and its
62
+ * relation prop may be `form.hidden` — the engine does not second-guess a stated
63
+ * intent. Only structural impossibilities are issues (see AD-c row 2): an unknown
64
+ * component id, no usable relation prop (or an ambiguous set with no
115
65
  * `relationProp`), an undefined target collection, or a target with no `blocks`
116
66
  * field — each of which would leave nothing coherent to transclude.
117
67
  */
@@ -126,7 +76,7 @@ function resolveDeclaredReuse(declaration, manifests, collections) {
126
76
  return {
127
77
  capability: null,
128
78
  issues: [
129
- reuseIssue('unknown-override-target', 'error', `Reusable elements: studio config declares reuse.component "${component}", which is not a registered component. Fix the id, or remove the reuse block to let the studio infer the embed.`, { key: REUSE_KEYS.unknownComponent, vars: { component } }, component),
79
+ reuseIssue('unknown-override-target', 'error', `Reusable elements: studio config declares reuse.component "${component}", which is not a registered component. Fix the id to name your embed component — until then the reuse gestures are off.`, { key: REUSE_KEYS.unknownComponent, vars: { component } }, component),
130
80
  ],
131
81
  };
132
82
  }
@@ -181,51 +131,30 @@ function resolveDeclaredReuse(declaration, manifests, collections) {
181
131
  };
182
132
  }
183
133
  /**
184
- * Resolve the project's ONE reuse capability (AD-a) plus any config issues —
185
- * EC-467's full matrix (AD-c):
186
- *
187
- * | Declaration | Candidates | Result |
188
- * | --- | --- | --- |
189
- * | present, resolves | any | the declared capability, no issue |
190
- * | present, does not resolve | any | `null` + an ERROR issue (misconfiguration) |
191
- * | absent | exactly 1 | that candidate, no issue (the zero-config default) |
192
- * | absent | 2 or more | `null` + a WARNING issue naming every candidate |
193
- * | absent | 0 | `null`, **no issue** — a project need not use reusable elements |
134
+ * Resolve the project's ONE reuse capability (AD-a) plus any config issues — EC-728's
135
+ * matrix (declare, never infer):
194
136
  *
195
- * Severity is the honest split: a declaration that cannot resolve is broken
196
- * config the developer asked for (error); inference ambiguity is nothing broken —
197
- * the engine declines to guess and asks for one line (warning). Both disable the
198
- * gestures, and both render identically in the rail's issue list.
137
+ * | Declaration | Result |
138
+ * | --- | --- |
139
+ * | present, resolves | the declared capability, no issue |
140
+ * | present, does not resolve | `null` + an ERROR issue (misconfiguration) |
141
+ * | absent | `null`, **no issue** — the project has no reusable elements |
199
142
  *
200
- * The zero-candidate row is deliberately silent: crying wolf at every project
201
- * that has no reusable elements would train developers to ignore the surface.
143
+ * The absent row is deliberately silent: a project need not use reusable elements,
144
+ * and naming a component that "looks like an embed" would be the guess again, as
145
+ * advice (EC-728). An unresolvable declaration disables the gestures and is reported
146
+ * once, in the header Issues popover.
202
147
  */
203
148
  export function resolveReuse(manifests, collections, declaration) {
204
- if (declaration)
205
- return resolveDeclaredReuse(declaration, manifests, collections);
206
- const candidates = inferredEmbedCandidates(manifests, collections);
207
- if (candidates.length === 1)
208
- return { capability: candidates[0], issues: [] };
209
- if (candidates.length === 0)
149
+ if (!declaration)
210
150
  return { capability: null, issues: [] };
211
- return {
212
- capability: null,
213
- issues: [
214
- reuseIssue('ambiguous-inference', 'warning', `Reusable elements: ${candidates.length} components have the reusable-embed shape (${quotedList(candidates.map((c) => c.embedComponentId))}). A project has exactly one reusable-element embed, so the studio will not guess — declare it in studio.config as reuse: { component: '<id>' }. Until then the reuse gestures are off.`, {
215
- key: REUSE_KEYS.ambiguousInference,
216
- vars: {
217
- count: candidates.length,
218
- components: quotedList(candidates.map((c) => c.embedComponentId)),
219
- },
220
- }),
221
- ],
222
- };
151
+ return resolveDeclaredReuse(declaration, manifests, collections);
223
152
  }
224
153
  /**
225
- * The project's reuse capability alone, or `null` when it is absent, ambiguous, or
154
+ * The project's reuse capability alone, or `null` when it is undeclared or
226
155
  * misdeclared — for callers that act on the capability and do not surface config
227
- * issues themselves (the publish-integrity walk). The issues are surfaced ONCE, by
228
- * the shell rail; see {@link resolveReuse}.
156
+ * issues themselves (the gestures, the canvas placeholder). The issues are surfaced
157
+ * ONCE, by the shell; see {@link resolveReuse}.
229
158
  */
230
159
  export function resolveReuseCapability(manifests, collections, declaration) {
231
160
  return resolveReuse(manifests, collections, declaration).capability;
@@ -241,7 +170,7 @@ export function uniqueSlug(name, taken) {
241
170
  /**
242
171
  * The values for a new reusable document promoted from `node` (EC-282): seeded
243
172
  * defaults, the given `name` on the title field, the node on the content field,
244
- * and a collision-safe slug on any UNIQUE text field (e.g. Konditorei's
173
+ * and a collision-safe slug on any UNIQUE text field (e.g. a production site's
245
174
  * `internalId`) so the created document is publish-valid without hardcoding that
246
175
  * field's name. Other required fields, if any, are completed by the editor when
247
176
  * they open the new reusable.
@@ -285,17 +214,23 @@ export function reuseEmbedRef(node, capability) {
285
214
  const ref = isStaticProp(prop) ? readDocumentRef(prop.value) : undefined;
286
215
  return ref ?? null;
287
216
  }
288
- /** The `Reusable` embed node referencing the new document (id minted by the action). */
217
+ /**
218
+ * The `Reusable` embed node referencing the new document (id minted by the action).
219
+ *
220
+ * EC-728 — CANONICAL from the first write: the prop is core's `staticProp` (bare `{ value }`,
221
+ * EC-312), never `{ kind: 'static', value }`. The canvas commits its node patches raw, while
222
+ * the draft takes the same write through the self-typing lens, which strips a redundant
223
+ * `kind`; a non-canonical node therefore made the two views of the canvas field disagree, the
224
+ * draft reported the disagreement as a change made underneath the canvas, and the rebase
225
+ * dropped *Make reusable*'s own undo step (`reuse-undo.test.ts`).
226
+ */
289
227
  export function makeEmbedNode(capability, documentId) {
290
228
  return {
291
229
  id: 'reuse-embed',
292
230
  componentId: capability.embedComponentId,
293
231
  props: {
294
- [capability.relationProp]: {
295
- kind: 'static',
296
- // EC-589 / EC-586: a reference is a typed envelope from the first write.
297
- value: referenceNode({ collection: capability.collectionId, id: documentId }),
298
- },
232
+ // EC-589 / EC-586: a reference is a typed envelope from the first write.
233
+ [capability.relationProp]: staticProp(referenceNode({ collection: capability.collectionId, id: documentId })),
299
234
  },
300
235
  };
301
236
  }
@@ -339,8 +274,9 @@ export function detachRefusal(args) {
339
274
  /**
340
275
  * Run "Make reusable" (EC-282) — the preflight first (EC-584: a refusal creates,
341
276
  * publishes and replaces NOTHING), then ONE insert-if-absent create of the reusable
342
- * document (EC-595 A2), the in-place swap for the embed, and the chosen publish,
343
- * whose success notice is shown only when the document is actually published.
277
+ * document (EC-595 A2), the in-place swap for the embed — one canvas history entry that
278
+ * records the created document (EC-728) — and the chosen publish, whose success notice is
279
+ * shown only when the document is actually published.
344
280
  */
345
281
  export async function runPromote(args) {
346
282
  const { root, nodeId, node, name, capability, collection, placement, canvasFieldLabel, effects } = args;
@@ -351,8 +287,9 @@ export async function runPromote(args) {
351
287
  }
352
288
  const ref = { collection: capability.collectionId, id: effects.newDocumentId() };
353
289
  const content = buildReusableValues(collection, capability, name, node, args.existing, args.locales);
354
- effects.insertDocument({ ...ref, ...content });
355
- effects.replaceInCanvas(nodeId, makeEmbedNode(capability, ref.id));
290
+ const document = { ...ref, ...content };
291
+ effects.insertDocument(document);
292
+ effects.replaceInCanvas(nodeId, makeEmbedNode(capability, ref.id), { kind: 'reusable-created', ref, name, document });
356
293
  if (!args.publish) {
357
294
  effects.madeDraft(name);
358
295
  return 'draft';
@@ -365,4 +302,84 @@ export async function runPromote(args) {
365
302
  effects.publishFailed(name);
366
303
  return 'publish-failed';
367
304
  }
305
+ /**
306
+ * EC-728 — Undo of *Make reusable*, the document side. The created document is deleted ONLY
307
+ * when every authoritative fact at Undo time says nobody else has it: never published (the
308
+ * record), referenced nowhere — after the page's own restore has landed — (the reference
309
+ * index, both variants), and unchanged since its create (the delete's revision precondition,
310
+ * checked by the store in its own step, which also refuses a strict referrer). Anything else
311
+ * keeps it, with the reason. A read that throws keeps it too: an unproven fact never deletes.
312
+ */
313
+ export async function undoPromote(effect, deps) {
314
+ try {
315
+ await deps.flush();
316
+ const record = await deps.read(effect.ref);
317
+ if (!record)
318
+ return 'gone';
319
+ if (record.published)
320
+ return 'published';
321
+ if ((await deps.referrers(effect.ref)) > 0)
322
+ return 'referenced';
323
+ switch (await deps.deleteCreated(effect.ref)) {
324
+ case 'deleted':
325
+ return 'deleted';
326
+ case 'gone':
327
+ return 'gone';
328
+ case 'changed':
329
+ return 'edited';
330
+ case 'referenced':
331
+ return 'referenced';
332
+ default:
333
+ return 'failed';
334
+ }
335
+ }
336
+ catch {
337
+ return 'failed';
338
+ }
339
+ }
340
+ /** The notice for a KEPT document (EC-728 — it names the document and why); none when it went. */
341
+ export function undoPromoteNotice(outcome) {
342
+ switch (outcome) {
343
+ case 'published':
344
+ return 'builder.reuse.undoKeptPublished';
345
+ case 'edited':
346
+ return 'builder.reuse.undoKeptEdited';
347
+ case 'referenced':
348
+ return 'builder.reuse.undoKeptReferenced';
349
+ case 'failed':
350
+ return 'builder.reuse.undoKeptFailed';
351
+ default:
352
+ return undefined;
353
+ }
354
+ }
355
+ /**
356
+ * EC-728 — the document side of every Undo/Redo that crosses a *Make reusable* step, for one
357
+ * canvas. Crossings run strictly in order (a Redo waits for the Undo before it), so a fast
358
+ * Undo → Redo never re-inserts a document whose removal is still in flight. Redo re-inserts
359
+ * (the same id, insert-if-absent) exactly the documents ITS Undo removed or found gone — never
360
+ * one Undo kept (it is still there) — and the canvas entry re-swaps the embed.
361
+ */
362
+ export function createPromoteHistory(deps) {
363
+ const removed = new Set();
364
+ let chain = Promise.resolve();
365
+ return {
366
+ crossed(step) {
367
+ chain = chain.then(async () => {
368
+ const key = documentKey(step.effect.ref);
369
+ if (step.direction === 'redo') {
370
+ if (removed.delete(key))
371
+ deps.insert(step.effect.document);
372
+ return;
373
+ }
374
+ const outcome = await undoPromote(step.effect, deps);
375
+ if (outcome === 'deleted' || outcome === 'gone')
376
+ removed.add(key);
377
+ const notice = undoPromoteNotice(outcome);
378
+ if (notice)
379
+ deps.notify(notice, step.effect.name);
380
+ });
381
+ return chain;
382
+ },
383
+ };
384
+ }
368
385
  //# sourceMappingURL=reuse.js.map