@reventlessdev/trait-attachments 1.0.0-alpha.1

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.
@@ -0,0 +1,167 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
+
6
+ let empty_attached = [];
7
+
8
+ let empty_altTexts = [];
9
+
10
+ let empty = {
11
+ attached: empty_attached,
12
+ primary: undefined,
13
+ altTexts: empty_altTexts
14
+ };
15
+
16
+ function primaryOf(chosen, attached) {
17
+ if (chosen !== undefined) {
18
+ return chosen;
19
+ } else {
20
+ return attached[0];
21
+ }
22
+ }
23
+
24
+ function effectivePrimary(t) {
25
+ return primaryOf(t.primary, t.attached);
26
+ }
27
+
28
+ function altTextOf(t, ref) {
29
+ return Stdlib_Option.map(t.altTexts.find(param => param[0] === ref), param => param[1]);
30
+ }
31
+
32
+ function evolve(t, fact) {
33
+ switch (fact.TAG) {
34
+ case "Attached" :
35
+ let ref = fact.ref;
36
+ if (t.attached.includes(ref)) {
37
+ return t;
38
+ }
39
+ let altText = fact.altText;
40
+ return {
41
+ attached: t.attached.concat([ref]),
42
+ primary: t.primary,
43
+ altTexts: altText !== undefined ? t.altTexts.concat([[
44
+ ref,
45
+ altText
46
+ ]]) : t.altTexts
47
+ };
48
+ case "Removed" :
49
+ let ref$1 = fact.ref;
50
+ return {
51
+ attached: t.attached.filter(r => r !== ref$1),
52
+ primary: Primitive_object.equal(t.primary, ref$1) ? undefined : t.primary,
53
+ altTexts: t.altTexts.filter(param => param[0] !== ref$1)
54
+ };
55
+ case "PrimarySet" :
56
+ return {
57
+ attached: t.attached,
58
+ primary: fact.ref,
59
+ altTexts: t.altTexts
60
+ };
61
+ case "AltTextSet" :
62
+ let ref$2 = fact.ref;
63
+ return {
64
+ attached: t.attached,
65
+ primary: t.primary,
66
+ altTexts: t.altTexts.filter(param => param[0] !== ref$2).concat([[
67
+ ref$2,
68
+ fact.altText
69
+ ]])
70
+ };
71
+ }
72
+ }
73
+
74
+ function decide(t, op) {
75
+ switch (op.TAG) {
76
+ case "Attach" :
77
+ let ref = op.ref;
78
+ if (t.attached.includes(ref)) {
79
+ return {
80
+ TAG: "Ok",
81
+ _0: undefined
82
+ };
83
+ } else {
84
+ return {
85
+ TAG: "Ok",
86
+ _0: {
87
+ TAG: "Attached",
88
+ ref: ref,
89
+ altText: op.altText
90
+ }
91
+ };
92
+ }
93
+ case "Remove" :
94
+ let ref$1 = op.ref;
95
+ if (t.attached.includes(ref$1)) {
96
+ return {
97
+ TAG: "Ok",
98
+ _0: {
99
+ TAG: "Removed",
100
+ ref: ref$1
101
+ }
102
+ };
103
+ } else {
104
+ return {
105
+ TAG: "Ok",
106
+ _0: undefined
107
+ };
108
+ }
109
+ case "SetPrimary" :
110
+ let ref$2 = op.ref;
111
+ if (t.attached.includes(ref$2)) {
112
+ if (Primitive_object.equal(effectivePrimary(t), ref$2)) {
113
+ return {
114
+ TAG: "Ok",
115
+ _0: undefined
116
+ };
117
+ } else {
118
+ return {
119
+ TAG: "Ok",
120
+ _0: {
121
+ TAG: "PrimarySet",
122
+ ref: ref$2
123
+ }
124
+ };
125
+ }
126
+ } else {
127
+ return {
128
+ TAG: "Error",
129
+ _0: "NotAttached"
130
+ };
131
+ }
132
+ case "SetAltText" :
133
+ let ref$3 = op.ref;
134
+ if (!t.attached.includes(ref$3)) {
135
+ return {
136
+ TAG: "Error",
137
+ _0: "NotAttached"
138
+ };
139
+ }
140
+ let altText = op.altText;
141
+ if (Primitive_object.equal(altTextOf(t, ref$3), altText)) {
142
+ return {
143
+ TAG: "Ok",
144
+ _0: undefined
145
+ };
146
+ } else {
147
+ return {
148
+ TAG: "Ok",
149
+ _0: {
150
+ TAG: "AltTextSet",
151
+ ref: ref$3,
152
+ altText: altText
153
+ }
154
+ };
155
+ }
156
+ }
157
+ }
158
+
159
+ export {
160
+ empty,
161
+ primaryOf,
162
+ effectivePrimary,
163
+ altTextOf,
164
+ evolve,
165
+ decide,
166
+ }
167
+ /* No side effect */
@@ -0,0 +1,454 @@
1
+ /**
2
+ The graft's spec surface, written rather than transcribed.
3
+
4
+ `Attachments_Rules` is the competency, compiled once. What a host still needs is
5
+ the *declarations* the rules act on — its own commands, events and errors, in its
6
+ own vocabulary, in files it owns. Those cannot come from a functor: a host binds
7
+ the source module, so the constructor names are the host's, not the trait's.
8
+
9
+ They do not have to be typed by hand, though, and that is all this module
10
+ changes. It takes the names and hands back real `.res` files. The declarations
11
+ are as host-owned as before; only the transcription is gone.
12
+
13
+ **Why this is code and not a template.** A `.res.tpl` compiles nowhere and is
14
+ checked by nothing: it is transcribed from a working host and drifts from it
15
+ silently. This module compiles with the trait, its config is sury-validated so a
16
+ misspelled key fails at emit rather than at paste-compile, and its output can be
17
+ built and run through the trait's own conformance suite — which is what the pack
18
+ check does.
19
+
20
+ **What it does not do.** It never writes the host's policy. Which states an
21
+ attachment may be changed in, which refusal comes first, what a host does with
22
+ its own events — those differ across every host of this trait (one refuses on a
23
+ three-state shelf, another on a single boolean), and expressing them in a config
24
+ is how a scaffolder acquires a policy language nobody asked for. They are left
25
+ as `TODO(graft)` markers, and the developer writes ReScript, which is better at
26
+ this than any config could be.
27
+ */
28
+
29
+ /**
30
+ The names a graft needs, and nothing else.
31
+
32
+ Everything here is a name or a literal spliced into an annotation. Nothing here
33
+ is control flow — that boundary is what keeps this a scaffolder rather than a
34
+ worse ReScript.
35
+ */
36
+ @schema
37
+ type config = {
38
+ /** The host entity, capitalised: `"Product"`. */
39
+ entity: string,
40
+ /** Its id field: `"productId"`. */
41
+ entityId: string,
42
+ /**
43
+ What this host calls one attachment, capitalised and singular: `"Image"`,
44
+ `"Document"`, `"Attachment"`.
45
+
46
+ Carried rather than assumed, because it is the host's word and it runs through
47
+ every name the graft declares — `AttachProductImage`, `ProductImageAttached`,
48
+ `ProductPrimaryImageSet`. A trait that hard-wired "Image" would be an image
49
+ trait, and this one holds references, not pictures.
50
+ */
51
+ noun: string,
52
+ /** The attachment field, named for the store it draws from: `"productImage"`. */
53
+ file: string,
54
+ /** The host event that brings the entity into existence: `"ProductAdded"`. */
55
+ created: string,
56
+ /** Whether that event carries the entity id. `false` for a payload-less
57
+ creation like `CategoryAdded`, which both shipped hosts differ on. */
58
+ createdCarriesEntityId?: bool,
59
+ /** The view whose lifecycle states `transition` names: `"Products"`. */
60
+ view: string,
61
+ /** The semantic type of the reference. `Reventless.UploadableImage.t` unless
62
+ the host attaches something other than pictures. */
63
+ refType?: string,
64
+ /** Spliced verbatim between the parentheses of `@authorize(…)` on every
65
+ command. Omitted ⇒ no annotation, and the host's default applies. */
66
+ authorize?: string,
67
+ /** The states the four commands are legal in, as the linked view's own
68
+ constructors: `["Products.Listed", "Products.Archived"]`. Emitted into a
69
+ `commandTransition` switch, not an annotation, so the compiler resolves
70
+ them and a typo here is a build error rather than a dead menu entry.
71
+ Omitted ⇒ a `TODO(graft)` marker, which is the honest default: which
72
+ states an attachment may change in is this host's policy. */
73
+ transition?: array<string>,
74
+ /** Two distinct references for the conformance fixtures. Defaulted when absent
75
+ — they are test data, not a decision. */
76
+ refA?: string,
77
+ refB?: string,
78
+ }
79
+
80
+ /** A file the graft owns outright, written to disk. */
81
+ type file = {path: string, contents: string}
82
+
83
+ /** Arms for a file the host already owns. Printed for a human to place, never
84
+ written: inserting into an existing ordered `switch` is an AST operation, and
85
+ a text splice into the wrong arm is a bug the compiler cannot see. */
86
+ type patch = {into: string, at: string, contents: string}
87
+
88
+ type output = {files: array<file>, patches: array<patch>}
89
+
90
+ // ── The vocabulary, derived once ─────────────────────────────────────────────
91
+ //
92
+ // One record so every emitted file spells a name the same way by construction.
93
+ // The rules match the two shipped hosts exactly: `ProductImages` /
94
+ // `AttachProductImage` / `ProductImageAttached` / `ProductPrimaryImageSet`, and
95
+ // the same with `Category`.
96
+
97
+ type names = {
98
+ slice: string,
99
+ attachCmd: string,
100
+ removeCmd: string,
101
+ setPrimaryCmd: string,
102
+ setAltTextCmd: string,
103
+ attached: string,
104
+ removed: string,
105
+ primarySet: string,
106
+ altTextSet: string,
107
+ notFound: string,
108
+ notAttached: string,
109
+ }
110
+
111
+ let namesOf = (c: config): names => {
112
+ let subject = c.entity ++ c.noun
113
+ {
114
+ slice: subject ++ "s",
115
+ attachCmd: "Attach" ++ subject,
116
+ removeCmd: "Remove" ++ subject,
117
+ setPrimaryCmd: "SetPrimary" ++ subject,
118
+ setAltTextCmd: "Set" ++ subject ++ "AltText",
119
+ attached: subject ++ "Attached",
120
+ removed: subject ++ "Removed",
121
+ primarySet: c.entity ++ "Primary" ++ c.noun ++ "Set",
122
+ altTextSet: subject ++ "AltTextSet",
123
+ notFound: c.entity ++ "NotFound",
124
+ notAttached: subject ++ "NotAttached",
125
+ }
126
+ }
127
+
128
+ let refTypeOf = (c: config) => c.refType->Option.getOr("Reventless.UploadableImage.t")
129
+
130
+ // `@authorize` is the host's policy, so an absent one emits nothing at all rather
131
+ // than a permissive default — a graft that silently declared "anyone" would be
132
+ // worse than one that declares nothing.
133
+ //
134
+ // The rule survives being emitted as an annotation because the PPX lowers it
135
+ // into a `switch` whose arms are ordinary expressions: `AllowGroupz` does not
136
+ // compile. `@transition` has no such second chance — it is stripped before the
137
+ // typechecker runs — so the states go out through `commandTransition` below
138
+ // instead, where the compiler resolves them.
139
+ let commandAttributes = (c: config): string =>
140
+ switch c.authorize {
141
+ | Some(a) => ` | @authorize(${a})\n `
142
+ | None => " | "
143
+ }
144
+
145
+ // The four commands' `commandTransition`, emitted whole because this graft's
146
+ // slice is a file the trait writes.
147
+ //
148
+ // The states arrive as config either way; what changes is where they land. In
149
+ // `@transition([Products.Listed])` they are stripped before the typechecker and
150
+ // matched as strings at plugin assembly; in `Guards([Products.Listed])` they are
151
+ // constructor references the compiler resolves, so a config typo is a build
152
+ // error naming it. Same input, and the difference is only who checks it.
153
+ let commandTransitionBinding = (c: config): array<string> => {
154
+ let n = namesOf(c)
155
+ let arms = [n.attachCmd, n.removeCmd, n.setPrimaryCmd, n.setAltTextCmd]
156
+ ->Array.map(cmd => ` | ${cmd}(_)`)
157
+ ->Array.join("\n")
158
+ switch c.transition {
159
+ | Some(states) => [
160
+ `type lifecycleState = ${c.view}.<lifecycle> // TODO(graft): the enum's name`,
161
+ `let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {`,
162
+ ` open Reventless.Transition`,
163
+ ` switch command {`,
164
+ arms ++ ` =>`,
165
+ ` Guards([${states->Array.join(", ")}])`,
166
+ ` }`,
167
+ `}`,
168
+ ``,
169
+ ]
170
+ | None => [
171
+ `// TODO(graft): the states this host allows its attachment set to change in,`,
172
+ `// as constructors of the linked view's lifecycle enum. Delete this binding`,
173
+ `// outright if the answer is "any state" — the framework's default says so.`,
174
+ `//`,
175
+ `// type lifecycleState = ${c.view}.<lifecycle>`,
176
+ `// let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {`,
177
+ `// open Reventless.Transition`,
178
+ `// switch command {`,
179
+ arms->String.replaceAll(" | ", "// | ") ++ ` =>`,
180
+ `// Guards([${c.view}.<State>])`,
181
+ `// }`,
182
+ `// }`,
183
+ ``,
184
+ ]
185
+ }
186
+ }
187
+
188
+ let lines = (ls: array<string>) => ls->Array.join("\n")
189
+
190
+ // ── The slice spec ───────────────────────────────────────────────────────────
191
+
192
+ let sliceSpec = (c: config): string => {
193
+ let n = namesOf(c)
194
+ let ref = refTypeOf(c)
195
+ let attrs = commandAttributes(c)
196
+ let createdArm =
197
+ c.createdCarriesEntityId->Option.getOr(true)
198
+ ? ` | ${c.created}({ ${c.entityId}: string})`
199
+ : ` | ${c.created}`
200
+ lines([
201
+ `// ${n.slice} StateChangeSlice: ${c.entity}'s attachment set — attach, remove,`,
202
+ `// choose the primary, caption. A graft of the Attachments trait; the set's rules`,
203
+ `// are the trait's and are asserted by its conformance suite, bound in the tests.`,
204
+ `//`,
205
+ `// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
206
+ `// ordinary source from here on — edit it freely.`,
207
+ ``,
208
+ `@@reventless.spec`,
209
+ ``,
210
+ `@schema`,
211
+ `type consumedEvent =`,
212
+ createdArm,
213
+ ` | ${n.attached}({ ${c.file}: string})`,
214
+ ` | ${n.removed}({ ${c.file}: string})`,
215
+ ` | ${n.primarySet}({ ${c.file}: string})`,
216
+ ` | ${n.altTextSet}({ ${c.file}: string, altText: string})`,
217
+ ` // TODO(graft): add the events this host's own refusal turns on — whatever`,
218
+ ` // moves it into a state where attachments may not be changed.`,
219
+ ``,
220
+ `@schema`,
221
+ `type command =`,
222
+ `${attrs}${n.attachCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
223
+ `${attrs}${n.removeCmd}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
224
+ `${attrs}${n.setPrimaryCmd}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
225
+ `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
226
+ ``,
227
+ `@schema`,
228
+ `type error =`,
229
+ ` | ${n.notFound}`,
230
+ ` | ${n.notAttached}`,
231
+ ` // TODO(graft): add this host's own refusal.`,
232
+ ``,
233
+ `@schema`,
234
+ `type event =`,
235
+ ` | ${n.attached}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
236
+ ` | ${n.removed}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
237
+ ` | ${n.primarySet}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
238
+ ` | ${n.altTextSet}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
239
+ ``,
240
+ ...commandTransitionBinding(c),
241
+ `// The graft's own record of itself. Nothing else survives into a deployed`,
242
+ `// plugin — the dependency and the rules alias are source-side — so without`,
243
+ `// this a running estate cannot say where this slice came from.`,
244
+ `let traits = [TraitAttachments.Attachments.declaration]`,
245
+ ``,
246
+ ])
247
+ }
248
+
249
+ // ── The slice body ───────────────────────────────────────────────────────────
250
+
251
+ let sliceBehavior = (c: config): string => {
252
+ let n = namesOf(c)
253
+ lines([
254
+ `@@reventless.behavior`,
255
+ ``,
256
+ `// The set's rules are the trait's. What is left here is this host's own refusal`,
257
+ `// and the mapping between its constructors and the trait's ops and facts.`,
258
+ `module Attachments = TraitAttachments.Attachments_Rules`,
259
+ ``,
260
+ `type state = {exists: bool, attachments: Attachments.t}`,
261
+ ``,
262
+ `let initialState = {exists: false, attachments: Attachments.empty}`,
263
+ ``,
264
+ `let evolve = (state, event) => {`,
265
+ ` let fold = fact => {...state, attachments: state.attachments->Attachments.evolve(fact)}`,
266
+ ` switch event {`,
267
+ ` | ${c.created}(_) => {...state, exists: true}`,
268
+ ` | ${n.attached}({${c.file}}) => fold(Attached({ref: ${c.file}, altText: None}))`,
269
+ ` | ${n.removed}({${c.file}}) => fold(Removed({ref: ${c.file}}))`,
270
+ ` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`,
271
+ ` | ${n.altTextSet}({${c.file}, altText}) => fold(AltTextSet({ref: ${c.file}, altText}))`,
272
+ ` // TODO(graft): fold this host's own events into its own state.`,
273
+ ` }`,
274
+ `}`,
275
+ ``,
276
+ `let toOp = command =>`,
277
+ ` switch command {`,
278
+ ` | ${n.attachCmd}({${c.entityId}, ${c.file}, altText: ?altText}) => (`,
279
+ ` ${c.entityId},`,
280
+ ` Attachments.Attach({ref: ${c.file}, altText}),`,
281
+ ` )`,
282
+ ` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
283
+ ` ${c.entityId},`,
284
+ ` Attachments.Remove({ref: ${c.file}}),`,
285
+ ` )`,
286
+ ` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
287
+ ` ${c.entityId},`,
288
+ ` Attachments.SetPrimary({ref: ${c.file}}),`,
289
+ ` )`,
290
+ ` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
291
+ ` ${c.entityId},`,
292
+ ` Attachments.SetAltText({ref: ${c.file}, altText}),`,
293
+ ` )`,
294
+ ` }`,
295
+ ``,
296
+ `let toEvent = (${c.entityId}, fact) =>`,
297
+ ` switch fact {`,
298
+ ` | Attachments.Attached({ref, altText}) =>`,
299
+ ` ${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})`,
300
+ ` | Attachments.Removed({ref}) => ${n.removed}({${c.entityId}, ${c.file}: ref})`,
301
+ ` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`,
302
+ ` | Attachments.AltTextSet({ref, altText}) =>`,
303
+ ` ${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})`,
304
+ ` }`,
305
+ ``,
306
+ `let decide = (state, command) =>`,
307
+ ` if !state.exists {`,
308
+ ` Error(${n.notFound})`,
309
+ ` } else {`,
310
+ ` // TODO(graft): this host's own refusal goes here, ahead of the set's rules —`,
311
+ ` // an \`else if\` returning the error added above. A graft with no extra refusal`,
312
+ ` // is a complete graft, so leaving this is legitimate.`,
313
+ ` let (${c.entityId}, op) = toOp(command)`,
314
+ ` switch state.attachments->Attachments.decide(op) {`,
315
+ ` | Error(#NotAttached) => Error(${n.notAttached})`,
316
+ ` | Ok(None) => Ok([])`,
317
+ ` | Ok(Some(fact)) => Ok([toEvent(${c.entityId}, fact)])`,
318
+ ` }`,
319
+ ` }`,
320
+ ``,
321
+ ])
322
+ }
323
+
324
+ // ── The conformance binding ──────────────────────────────────────────────────
325
+ //
326
+ // Emitted whole and final. It is pure name-mapping — every line of it is
327
+ // already in the config — and it is the file a host would otherwise write twice
328
+ // per attachment host, by hand, with nothing checking the names line up.
329
+
330
+ let conformanceBinding = (c: config): string => {
331
+ let n = namesOf(c)
332
+ let id = "e1"
333
+ let refA = c.refA->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000001/a`)
334
+ let refB = c.refB->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000002/b`)
335
+ let createdValue =
336
+ c.createdCarriesEntityId->Option.getOr(true)
337
+ ? `${c.created}({ ${c.entityId}: "${id}"})`
338
+ : c.created
339
+ lines([
340
+ `// The Attachments trait's conformance suite, bound to \`${n.slice}\`.`,
341
+ `// Emitted whole: every name here is one the graft already declared.`,
342
+ ``,
343
+ `module Binding = {`,
344
+ ` type ref = string`,
345
+ ` let refA = "${refA}"`,
346
+ ` let refB = "${refB}"`,
347
+ ``,
348
+ ` module Spec = ${n.slice}`,
349
+ ` module Behavior = ${n.slice}_Behavior`,
350
+ ``,
351
+ ` // Annotated: the slice consumes and emits same-named constructors.`,
352
+ ` let created: array<${n.slice}.consumedEvent> = [${createdValue}]`,
353
+ ` let attachedC = (ref): ${n.slice}.consumedEvent => ${n.attached}({ ${c.file}: ref})`,
354
+ ` let removedC = (ref): ${n.slice}.consumedEvent => ${n.removed}({ ${c.file}: ref})`,
355
+ ` let primarySetC = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
356
+ ` let altTextSetC = (ref, altText): ${n.slice}.consumedEvent =>`,
357
+ ` ${n.altTextSet}({ ${c.file}: ref, altText})`,
358
+ ``,
359
+ ` let attach = ref => ${n.slice}.${n.attachCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
360
+ ` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
361
+ ` let setPrimary = ref =>`,
362
+ ` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
363
+ ` let setAltText = (ref, altText) =>`,
364
+ ` ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
365
+ ``,
366
+ ` let attached = ref => ${n.slice}.${n.attached}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
367
+ ` let removed = ref => ${n.slice}.${n.removed}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
368
+ ` let primarySet = ref =>`,
369
+ ` ${n.slice}.${n.primarySet}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
370
+ ` let altTextSet = (ref, altText) =>`,
371
+ ` ${n.slice}.${n.altTextSet}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
372
+ ` let notAttached = ${n.slice}.${n.notAttached}`,
373
+ `}`,
374
+ ``,
375
+ `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
376
+ ``,
377
+ `Conformance.register()`,
378
+ ``,
379
+ ])
380
+ }
381
+
382
+ // ── The projection patch ─────────────────────────────────────────────────────
383
+ //
384
+ // Printed, not written: the view already exists and its projection is an ordered
385
+ // `switch` the host wrote. Placing an arm in it is the one part of a graft this
386
+ // module deliberately does not automate.
387
+
388
+ let projectionPatch = (c: config): patch => {
389
+ let n = namesOf(c)
390
+ {
391
+ into: `StateViewSliceStream/${c.view}_Projection.res`,
392
+ at: `the projection's \`switch\`, and two fields on \`${c.view}\`'s state`,
393
+ contents: lines([
394
+ `// On the view's state, two fields — the set, and its primary as one string.`,
395
+ `// The second is not redundancy: a card, a gallery tile and a reference cell`,
396
+ `// each read one image-semantic string per row, so without it every tile is blank.`,
397
+ `//`,
398
+ `// ${c.file}s: array<{${c.file}: string, altText?: string}>,`,
399
+ `// ${c.file}?: string,`,
400
+ ``,
401
+ `| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
402
+ ` Update(${c.entityId}, state => {`,
403
+ ` let ${c.file}s = Array.concat(state.${c.file}s, [{${c.file}: ${c.file}, altText: ?altText}])`,
404
+ ` {...state, ${c.file}s, ${c.file}: ?withPrimary(${c.file}s, state.primaryChosen)}`,
405
+ ` })`,
406
+ `| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
407
+ ` Update(${c.entityId}, state => {`,
408
+ ` let ${c.file}s = state.${c.file}s->Array.filter(m => m.${c.file} != ${c.file})`,
409
+ ` {...state, ${c.file}s, ${c.file}: ?withPrimary(${c.file}s, state.primaryChosen)}`,
410
+ ` })`,
411
+ `| ${n.primarySet}({${c.entityId}, ${c.file}}) =>`,
412
+ ` Update(${c.entityId}, state => {...state, ${c.file}: Some(${c.file})})`,
413
+ `| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
414
+ ` Update(${c.entityId}, state => {`,
415
+ ` ...state,`,
416
+ ` ${c.file}s: state.${c.file}s->Array.map(m =>`,
417
+ ` m.${c.file} == ${c.file} ? {...m, altText} : m`,
418
+ ` ),`,
419
+ ` })`,
420
+ ``,
421
+ `// The primary a reader should show: the one chosen, else the first attached —`,
422
+ `// the same rule the trait applies, over the view's own rows.`,
423
+ `let withPrimary = (members, chosen) =>`,
424
+ ` TraitAttachments.Attachments_Rules.primaryOf(`,
425
+ ` ~chosen,`,
426
+ ` ~attached=members->Array.map(m => m.${c.file}),`,
427
+ ` )`,
428
+ ]),
429
+ }
430
+ }
431
+
432
+ /**
433
+ Emit a graft.
434
+
435
+ Three files written, one patch printed. The files are the host's from the moment
436
+ they land — nothing regenerates them, and nothing compares against them later.
437
+ */
438
+ let emit = (~config: config, ~into: string, ~tests: string): output => {
439
+ let n = namesOf(config)
440
+ {
441
+ files: [
442
+ {path: `${into}/StateChangeSlice/${n.slice}.res`, contents: sliceSpec(config)},
443
+ {
444
+ path: `${into}/StateChangeSlice/${n.slice}_Behavior.res`,
445
+ contents: sliceBehavior(config),
446
+ },
447
+ {
448
+ path: `${tests}/${n.slice}Conformance_GWT.res`,
449
+ contents: conformanceBinding(config),
450
+ },
451
+ ],
452
+ patches: [projectionPatch(config)],
453
+ }
454
+ }