@reventlessdev/reventless-ui-slots 3.0.0-alpha.1 → 3.0.0-alpha.2

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/README.md CHANGED
@@ -93,6 +93,28 @@ let register = (arg: ReventlessSlots.registerArg) => {
93
93
  }
94
94
  ```
95
95
 
96
+ ### Helpers
97
+
98
+ The ReScript surface carries a few things every slot module otherwise rewrites,
99
+ and rewrites subtly wrong:
100
+
101
+ | | |
102
+ |---|---|
103
+ | `Row.string` / `text` / `float` / `bool` / `array` | read a field out of the row's JSON. `text` treats `""` as absent, which is what a view writes for "not yet" |
104
+ | `Row.money` + `Format.money` | `amount` is **minor units** at a scale the currency decides — 1000 is €10.00 but ¥1000 and 1.000 TND. `Format.money` asks `Intl` for the scale instead of assuming a hundred |
105
+ | `Row.firstAttachment` | the `altText` and `caption` of a captioned set's first member. Text only: members hold storage **refs**, not URLs, and only `payload.image` is rebased for the deployment's origins |
106
+ | `titleOf` | what the mode would have titled the row, falling back to its `name` |
107
+ | `ensureStyles(~id, css)` | put a stylesheet in the page once. The shell re-imports a module on hot reload, so an unguarded injection appends the same rules on every save |
108
+
109
+ They are ReScript-only, and deliberately: a ReScript module is bundled anyway, so
110
+ importing a helper costs it nothing, while a JavaScript one is served as written
111
+ and anything imported from this package would take away the no-bundler property
112
+ that is that path's whole point. `index.js` stays one identity function.
113
+
114
+ Call `ensureStyles` from inside `register`, not at module scope, so importing the
115
+ module has no side effect — which is also what keeps it loadable by a test on
116
+ Node, where there is no document.
117
+
96
118
  ### Why `h` rather than JSX
97
119
 
98
120
  Because React is the one thing a slot module must **not** bring its own copy of.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-ui-slots",
3
- "version": "3.0.0-alpha.1",
3
+ "version": "3.0.0-alpha.2",
4
4
  "description": "Types and one runtime helper for writing custom renderers into the regions of Reventless AutoUI's standard view modes.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -10,7 +10,9 @@
10
10
  ".": {
11
11
  "types": "./src/index.d.ts",
12
12
  "default": "./src/index.js"
13
- }
13
+ },
14
+ "./src/*": "./src/*",
15
+ "./package.json": "./package.json"
14
16
  },
15
17
  "files": [
16
18
  "src/index.js",
@@ -52,5 +54,5 @@
52
54
  "react-dom": "^18.3.1",
53
55
  "rescript": "^12.3.0"
54
56
  },
55
- "gitHead": "5877fd93c92d7f3d023e12a9f32e24832b639400"
57
+ "gitHead": "6d74b362ba047678919b27c22f84e698dea48741"
56
58
  }
@@ -32,14 +32,159 @@ type slots = {
32
32
 
33
33
  type registerArg = {h: hyperscript, slots: slots}
34
34
 
35
- // `React.createElement(tag, props, ...children)` against the function the shell
36
- // passed in. Written as `%raw` because the callee is a value rather than a
37
- // module member, which `@variadic` cannot express.
38
- let h: (hyperscript, 'tag, 'props, array<React.element>) => React.element = %raw(`
39
- function (h, tag, props, children) {
40
- return h(tag, props, ...children)
35
+ /* `React.createElement(tag, props, ...children)` against the function the shell
36
+ passed in.
37
+
38
+ `@variadic` cannot express this: it binds a module member, and the callee here
39
+ is a value that arrived as an argument. So the call goes through
40
+ `Function.prototype.apply`, which is exactly "call this value with these
41
+ arguments" and is the one thing in JavaScript that takes an argument list as
42
+ data.
43
+
44
+ `apply` rather than handing React the array as a single child, which would
45
+ also compile: React reads an array child as a keyed list and warns about
46
+ missing keys, where positional children carry no such expectation. The
47
+ distinction is invisible until a slot author's console fills up. */
48
+
49
+ // One element of that argument list. Heterogeneous by nature — a tag, then
50
+ // props, then elements — so the three are widened to a common type on the way
51
+ // in. Nothing reads an `arg` back out; it exists only to be passed on.
52
+ //
53
+ // `args` widens the children array in one step rather than per element: mapping
54
+ // `arg` over it would compile to an identity `.map`, allocating a second array
55
+ // on every element built.
56
+ type arg
57
+ external arg: 'a => arg = "%identity"
58
+ external args: array<React.element> => array<arg> = "%identity"
59
+
60
+ @send
61
+ external applyTo: (hyperscript, Nullable.t<unit>, array<arg>) => React.element = "apply"
62
+
63
+ let h = (hyperscript: hyperscript, tag: 'tag, props: 'props, children: array<React.element>) =>
64
+ hyperscript->applyTo(Nullable.null, [arg(tag), arg(props)]->Array.concat(args(children)))
65
+
66
+ // Reading the row's JSON. One right answer per field type, so it lives here
67
+ // rather than being rewritten — subtly differently — per deployment.
68
+ module Row = {
69
+ let field = (row: JSON.t, name: string): option<JSON.t> =>
70
+ switch row {
71
+ | Object(fields) => fields->Dict.get(name)
72
+ | _ => None
73
+ }
74
+
75
+ let string = (row: JSON.t, name: string): option<string> =>
76
+ switch field(row, name) {
77
+ | Some(String(value)) => Some(value)
78
+ | _ => None
79
+ }
80
+
81
+ let text = (row: JSON.t, name: string): option<string> =>
82
+ switch string(row, name) {
83
+ | Some("") | None => None
84
+ | Some(value) => Some(value)
85
+ }
86
+
87
+ let float = (row: JSON.t, name: string): option<float> =>
88
+ switch field(row, name) {
89
+ | Some(Number(value)) => Some(value)
90
+ | _ => None
91
+ }
92
+
93
+ let bool = (row: JSON.t, name: string): option<bool> =>
94
+ switch field(row, name) {
95
+ | Some(Boolean(value)) => Some(value)
96
+ | _ => None
97
+ }
98
+
99
+ let array = (row: JSON.t, name: string): option<array<JSON.t>> =>
100
+ switch field(row, name) {
101
+ | Some(Array(values)) => Some(values)
102
+ | _ => None
103
+ }
104
+
105
+ let money = (row: JSON.t, name: string): option<(float, string)> =>
106
+ switch field(row, name) {
107
+ | Some(Object(value)) =>
108
+ switch (value->Dict.get("amount"), value->Dict.get("currency")) {
109
+ | (Some(Number(amount)), Some(String(currency))) => Some((amount, currency))
110
+ | _ => None
111
+ }
112
+ | _ => None
113
+ }
114
+
115
+ let firstAttachment = (row: JSON.t, name: string): option<(option<string>, option<string>)> => {
116
+ let nonEmpty = (member: Dict.t<JSON.t>, key: string) =>
117
+ switch member->Dict.get(key) {
118
+ | Some(String("")) | None => None
119
+ | Some(String(value)) => Some(value)
120
+ | Some(_) => None
121
+ }
122
+ switch field(row, name) {
123
+ | Some(Array(members)) =>
124
+ switch members->Array.get(0) {
125
+ | Some(JSON.Object(first)) => Some((nonEmpty(first, "altText"), nonEmpty(first, "caption")))
126
+ | _ => None
127
+ }
128
+ | _ => None
129
+ }
130
+ }
131
+ }
132
+
133
+ module Format = {
134
+ let money = ((amount, currency): (float, string)): string => {
135
+ let format = Intl.NumberFormat.make(~options={style: #currency, currency})
136
+ let digits = switch Intl.NumberFormat.resolvedOptions(format).maximumFractionDigits {
137
+ | Some(digits) => (digits :> int)
138
+ | None => 2
139
+ }
140
+ Intl.NumberFormat.format(format, amount /. Math.pow(10.0, ~exp=Int.toFloat(digits)))
141
+ }
142
+
143
+ let day = (iso: string): string => {
144
+ let at = Date.fromString(iso)
145
+ Float.isNaN(Date.getTime(at)) ? "" : Date.toLocaleDateString(at)
146
+ }
147
+ }
148
+
149
+ let titleOf = (payload: rowPayload): string =>
150
+ switch payload.label {
151
+ | Some(label) => label
152
+ | None => Row.string(payload.row, "name")->Option.getOr("")
153
+ }
154
+
155
+ // The DOM calls `ensureStyles` makes. Bound here rather than pulled from a DOM
156
+ // binding package, so this package keeps its single dependency.
157
+ //
158
+ // `document` is reached through `globalThis` rather than as a bare global, and
159
+ // that is the load-bearing detail: referencing an undeclared `document` throws a
160
+ // ReferenceError on Node, while `globalThis.document` is simply `undefined`
161
+ // there. It is what lets a slot module be imported and driven by a test.
162
+ module Dom = {
163
+ type document
164
+ type element
165
+
166
+ @val @scope("globalThis") external document: Nullable.t<document> = "document"
167
+ @send external getElementById: (document, string) => Nullable.t<element> = "getElementById"
168
+ @send external createElement: (document, string) => element = "createElement"
169
+ @get external head: document => element = "head"
170
+ @set external setId: (element, string) => unit = "id"
171
+ @set external setTextContent: (element, string) => unit = "textContent"
172
+ @send external appendChild: (element, element) => unit = "appendChild"
173
+ }
174
+
175
+ let ensureStyles = (~id: string, css: string): unit =>
176
+ switch Nullable.toOption(Dom.document) {
177
+ | None => ()
178
+ | Some(doc) =>
179
+ switch Nullable.toOption(doc->Dom.getElementById(id)) {
180
+ | Some(_) => ()
181
+ | None =>
182
+ let style = doc->Dom.createElement("style")
183
+ style->Dom.setId(id)
184
+ style->Dom.setTextContent(css)
185
+ doc->Dom.head->Dom.appendChild(style)
186
+ }
41
187
  }
42
- `)
43
188
 
44
189
  module RowSlot = {
45
190
  let cardsFace = "cards.face"
@@ -1,9 +1,154 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
4
 
4
- let h = (function (h, tag, props, children) {
5
- return h(tag, props, ...children)
5
+ function h(hyperscript, tag, props, children) {
6
+ return hyperscript.apply(null, [
7
+ tag,
8
+ props
9
+ ].concat(children));
10
+ }
11
+
12
+ function field(row, name) {
13
+ if (typeof row === "object" && row !== null && !Array.isArray(row)) {
14
+ return row[name];
15
+ }
16
+ }
17
+
18
+ function string(row, name) {
19
+ let match = field(row, name);
20
+ if (typeof match === "string") {
21
+ return match;
22
+ }
23
+ }
24
+
25
+ function text(row, name) {
26
+ let value = string(row, name);
27
+ if (value !== undefined && value !== "") {
28
+ return value;
29
+ }
30
+ }
31
+
32
+ function float(row, name) {
33
+ let match = field(row, name);
34
+ if (typeof match === "number") {
35
+ return match;
36
+ }
37
+ }
38
+
39
+ function bool(row, name) {
40
+ let match = field(row, name);
41
+ if (typeof match === "boolean") {
42
+ return match;
43
+ }
44
+ }
45
+
46
+ function array(row, name) {
47
+ let match = field(row, name);
48
+ if (Array.isArray(match)) {
49
+ return match;
50
+ }
51
+ }
52
+
53
+ function money(row, name) {
54
+ let match = field(row, name);
55
+ if (match === undefined) {
56
+ return;
57
+ }
58
+ if (typeof match !== "object" || match === null || Array.isArray(match)) {
59
+ return;
60
+ }
61
+ let match$1 = match["amount"];
62
+ let match$2 = match["currency"];
63
+ if (typeof match$1 === "number" && typeof match$2 === "string") {
64
+ return [
65
+ match$1,
66
+ match$2
67
+ ];
68
+ }
69
+ }
70
+
71
+ function firstAttachment(row, name) {
72
+ let nonEmpty = (member, key) => {
73
+ let match = member[key];
74
+ if (typeof match === "string" && match !== "") {
75
+ return match;
76
+ }
77
+ };
78
+ let match = field(row, name);
79
+ if (match === undefined) {
80
+ return;
81
+ }
82
+ if (!Array.isArray(match)) {
83
+ return;
84
+ }
85
+ let match$1 = match[0];
86
+ if (typeof match$1 === "object" && match$1 !== null && !Array.isArray(match$1)) {
87
+ return [
88
+ nonEmpty(match$1, "altText"),
89
+ nonEmpty(match$1, "caption")
90
+ ];
91
+ }
92
+ }
93
+
94
+ let Row = {
95
+ field: field,
96
+ string: string,
97
+ text: text,
98
+ float: float,
99
+ bool: bool,
100
+ array: array,
101
+ money: money,
102
+ firstAttachment: firstAttachment
103
+ };
104
+
105
+ function money$1(param) {
106
+ let format = new Intl.NumberFormat(undefined, {
107
+ currency: param[1],
108
+ style: "currency"
6
109
  });
110
+ let digits = format.resolvedOptions().maximumFractionDigits;
111
+ let digits$1 = digits !== undefined ? digits : 2;
112
+ return format.format(param[0] / Math.pow(10.0, digits$1));
113
+ }
114
+
115
+ function day(iso) {
116
+ let at = new Date(iso);
117
+ if (Number.isNaN(at.getTime())) {
118
+ return "";
119
+ } else {
120
+ return at.toLocaleDateString();
121
+ }
122
+ }
123
+
124
+ let Format = {
125
+ money: money$1,
126
+ day: day
127
+ };
128
+
129
+ function titleOf(payload) {
130
+ let label = payload.label;
131
+ if (label !== undefined) {
132
+ return label;
133
+ } else {
134
+ return Stdlib_Option.getOr(string(payload.row, "name"), "");
135
+ }
136
+ }
137
+
138
+ function ensureStyles(id, css) {
139
+ let doc = globalThis.document;
140
+ if (doc == null) {
141
+ return;
142
+ }
143
+ let match = doc.getElementById(id);
144
+ if (!(match == null)) {
145
+ return;
146
+ }
147
+ let style = doc.createElement("style");
148
+ style.id = id;
149
+ style.textContent = css;
150
+ doc.head.appendChild(style);
151
+ }
7
152
 
8
153
  let cardsFace = "cards.face";
9
154
 
@@ -58,6 +203,10 @@ let ViewSlot = {
58
203
  export {
59
204
  h,
60
205
  RowSlot,
206
+ Row,
207
+ Format,
208
+ titleOf,
209
+ ensureStyles,
61
210
  ViewSlot,
62
211
  }
63
212
  /* No side effect */
@@ -119,6 +119,97 @@ module RowSlot: {
119
119
  let all: array<string>
120
120
  }
121
121
 
122
+ /* ── Helpers ────────────────────────────────────────────────────────────────
123
+
124
+ Everything below is runtime code, and it is ReScript-only on purpose rather
125
+ than by omission.
126
+
127
+ A ReScript slot module is bundled before it is served, so importing a helper
128
+ costs it nothing. A JavaScript one is served as written — no bundler, no
129
+ dependency, which is that path's whole point — and anything it imported from
130
+ this package would take that away. `index.js` therefore stays what it is: one
131
+ identity function for the type checker, and no runtime to depend on.
132
+
133
+ The JS contract is the same contract; it just reads its own fields. */
134
+
135
+ /** Reading the row you were handed.
136
+
137
+ `row` is the read model's own JSON, so every renderer that shows a field has
138
+ to get it out of a `JSON.t` — and there is one right answer per field type,
139
+ which is why these are here rather than rewritten per deployment. None of
140
+ them reaches past the payload: they read what `rowPayload` already carries. */
141
+ module Row: {
142
+ /** One field, whatever its type. */
143
+ let field: (JSON.t, string) => option<JSON.t>
144
+
145
+ /** A string field. `None` when absent or not a string. */
146
+ let string: (JSON.t, string) => option<string>
147
+
148
+ /** A string field that actually says something.
149
+
150
+ Distinct from `string` because a view writes `""` for "not yet" — an order
151
+ that has not shipped carries `shippedAt: ""` — and a blank is not a value
152
+ to render. Take this one wherever an empty string should read as absent. */
153
+ let text: (JSON.t, string) => option<string>
154
+
155
+ /** A number field. */
156
+ let float: (JSON.t, string) => option<float>
157
+
158
+ /** A boolean field. */
159
+ let bool: (JSON.t, string) => option<bool>
160
+
161
+ /** An array field, as raw elements. */
162
+ let array: (JSON.t, string) => option<array<JSON.t>>
163
+
164
+ /** A `Money` field: whole minor units and the currency they are in.
165
+
166
+ Reventless serialises money as `{amount, currency}` where `amount` is
167
+ **minor units** — 1000 is €10.00, ¥1000 or 1.000 TND depending on the
168
+ currency. Pair it with `Format.money`, which asks `Intl` for the scale
169
+ rather than assuming a hundred. */
170
+ let money: (JSON.t, string) => option<(float, string)>
171
+
172
+ /** The first member of a captioned attachment set, as `(altText, caption)`.
173
+
174
+ Reventless serialises these as `{ref, altText?, caption?}`, where `ref` is
175
+ a **storage ref, not a URL**: only `rowPayload.image` is rebased for this
176
+ deployment's asset origins. So this reads the *text* of a member, never its
177
+ picture — resolving one is the producer's job. */
178
+ let firstAttachment: (JSON.t, string) => option<(option<string>, option<string>)>
179
+ }
180
+
181
+ /** Turning values into the strings a region shows. */
182
+ module Format: {
183
+ /** Money, at the scale its currency actually uses.
184
+
185
+ Takes what `Row.money` returns. The number of minor units per unit comes
186
+ from `Intl` rather than being assumed to be a hundred, which is right for
187
+ most currencies and quietly wrong for JPY and TND. */
188
+ let money: ((float, string)) => string
189
+
190
+ /** An ISO instant as a short local date. `""` when it does not parse. */
191
+ let day: string => string
192
+ }
193
+
194
+ /** What the mode would have titled this row, else the row's own `name`.
195
+
196
+ The precedence is the contract's, not the deployment's: `label` is what the
197
+ producer resolved, and a renderer that preferred the raw field would ignore
198
+ it. */
199
+ let titleOf: rowPayload => string
200
+
201
+ /** Put a stylesheet in the page, once, under an id of your choosing.
202
+
203
+ Slot modules ship their own CSS and there is nowhere else to put it. Doing it
204
+ by hand is easy to get subtly wrong: the shell re-imports a module on a hot
205
+ reload, so an unguarded injection appends the same rules again on every save.
206
+ This no-ops when the id is already present, and no-ops entirely outside a
207
+ browser so a module stays loadable by a test on Node.
208
+
209
+ Call it from `register` rather than at module scope, so importing the module
210
+ has no side effect. */
211
+ let ensureStyles: (~id: string, string) => unit
212
+
122
213
  /** Per-view slots the shipped modes offer. */
123
214
  module ViewSlot: {
124
215
  /** The card grid when the view has no rows. */