@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.
- package/LICENSE +202 -0
- package/README.md +62 -0
- package/package.json +40 -0
- package/rescript.json +27 -0
- package/src/Attachments.res +66 -0
- package/src/Attachments.res.mjs +19 -0
- package/src/Attachments_Conformance.res +88 -0
- package/src/Attachments_Conformance.res.mjs +49 -0
- package/src/Attachments_Rules.res +101 -0
- package/src/Attachments_Rules.res.mjs +167 -0
- package/src/Attachments_Scaffold.res +454 -0
- package/src/Attachments_Scaffold.res.mjs +354 -0
|
@@ -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
|
+
}
|