@sigrun/shared 0.1.0 → 0.1.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.
@@ -0,0 +1,294 @@
1
+ /**
2
+ * `applyOps` and `resolvePointer` (UI-blocks spec §4.2/§4.3): the single
3
+ * applier every surface's data updates go through, and the pointer
4
+ * reader bindings resolve with.
5
+ *
6
+ * Purity contract:
7
+ *
8
+ * - `applyOps` NEVER mutates its input — it clones along the touched
9
+ * path only (copy-on-write), so a batch of ops over a large document
10
+ * shares every untouched branch by reference with the input. The
11
+ * property suite pins this (frozen inputs, snapshot equality).
12
+ * - Pointers are RFC 6901 with `~0` (`~`) and `~1` (`/`) escaping, plus
13
+ * the one extension (spec §4.2): a pointer starting `./` resolves
14
+ * against the passed `item` (a collection's item template), which
15
+ * only `resolvePointer` takes — the DATA side never sees `./`.
16
+ * - A failing op throws `DataOpError` carrying the op's INDEX, so the
17
+ * caller (U3/U5) can treat it as the full-refetch signal (spec §16).
18
+ */
19
+ /** An op that could not be applied; `opIndex` names which one failed. */
20
+ export class DataOpError extends Error {
21
+ opIndex;
22
+ constructor(opIndex, message) {
23
+ super(message);
24
+ this.name = "DataOpError";
25
+ this.opIndex = opIndex;
26
+ }
27
+ }
28
+ function isContainer(value) {
29
+ // `typeof null === "object"` — a JSON null is NOT a container; a path
30
+ // through it is a missing path (undefined), never a walk into it.
31
+ return Array.isArray(value) || (typeof value === "object" && value !== null);
32
+ }
33
+ /** Split an RFC 6901 pointer into unescaped reference tokens. */
34
+ function pointerTokens(pointer) {
35
+ if (pointer === "")
36
+ return [];
37
+ if (!pointer.startsWith("/")) {
38
+ throw new Error(`invalid pointer: ${pointer}`);
39
+ }
40
+ return pointer
41
+ .slice(1)
42
+ .split("/")
43
+ .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~"));
44
+ }
45
+ /** Read `token` from a container; `undefined` when absent. */
46
+ function readToken(container, token) {
47
+ if (Array.isArray(container)) {
48
+ if (!/^\d+$/.test(token))
49
+ return undefined;
50
+ const index = Number(token);
51
+ return index < container.length ? container[index] : undefined;
52
+ }
53
+ return Object.prototype.hasOwnProperty.call(container, token)
54
+ ? container[token]
55
+ : undefined;
56
+ }
57
+ /** Parse `key` as an array index valid INSIDE `array` (not for append). */
58
+ function arrayIndex(array, key, pointer) {
59
+ if (!/^\d+$/.test(key)) {
60
+ throw new Error(`not an array index: ${pointer}`);
61
+ }
62
+ const index = Number(key);
63
+ if (index >= array.length) {
64
+ throw new Error(`index out of bounds: ${pointer}`);
65
+ }
66
+ return index;
67
+ }
68
+ /** Parse `key` as an array index valid for INSERTION (== length allowed). */
69
+ function insertIndex(array, key, pointer) {
70
+ if (key === "-")
71
+ return array.length;
72
+ if (!/^\d+$/.test(key)) {
73
+ throw new Error(`not an array index: ${pointer}`);
74
+ }
75
+ const index = Number(key);
76
+ if (index > array.length) {
77
+ throw new Error(`index out of bounds: ${pointer}`);
78
+ }
79
+ return index;
80
+ }
81
+ /**
82
+ * READ-ONLY walk: resolve the tokens before the last and return the
83
+ * parent container plus the final token. Throws when any step is
84
+ * missing or not a container.
85
+ */
86
+ function resolveParent(root, tokens, opIndex, pointer) {
87
+ let node = root;
88
+ for (let i = 0; i < tokens.length - 1; i++) {
89
+ if (!isContainer(node)) {
90
+ throw new DataOpError(opIndex, `path not found: ${pointer}`);
91
+ }
92
+ const next = readToken(node, tokens[i]);
93
+ if (next === undefined) {
94
+ throw new DataOpError(opIndex, `path not found: ${pointer}`);
95
+ }
96
+ node = next;
97
+ }
98
+ if (!isContainer(node)) {
99
+ throw new DataOpError(opIndex, `path not found: ${pointer}`);
100
+ }
101
+ return { parent: node, key: tokens[tokens.length - 1] };
102
+ }
103
+ /**
104
+ * Clone-on-write rewrite: copies every container along `tokens` from
105
+ * `data` down, hands the (fresh, cloned) final container to `mutate`,
106
+ * and returns the new root. The input is never touched; branches off
107
+ * the path are shared by reference with it.
108
+ */
109
+ function withWrite(data, tokens, mutate) {
110
+ const rootClone = { ...data };
111
+ let node = rootClone;
112
+ for (let i = 0; i < tokens.length - 1; i++) {
113
+ const child = readToken(node, tokens[i]);
114
+ if (child === undefined || !isContainer(child)) {
115
+ throw new Error(`path not found: /${tokens.join("/")}`);
116
+ }
117
+ const childClone = Array.isArray(child)
118
+ ? [...child]
119
+ : { ...child };
120
+ if (Array.isArray(node)) {
121
+ node[Number(tokens[i])] = childClone;
122
+ }
123
+ else {
124
+ node[tokens[i]] = childClone;
125
+ }
126
+ node = childClone;
127
+ }
128
+ mutate(node, tokens[tokens.length - 1]);
129
+ return rootClone;
130
+ }
131
+ /**
132
+ * Apply a batch of `DataOp`s (spec §4.3) to `data`, returning a NEW
133
+ * object. Pure: untouched branches are shared by reference with the
134
+ * input; the input is never mutated. Ops apply sequentially — an
135
+ * earlier op's write is visible to the next.
136
+ *
137
+ * Throws `DataOpError` (naming the op's index) on any op whose path
138
+ * does not exist, or whose target is not an array where one is required.
139
+ */
140
+ export function applyOps(data, ops) {
141
+ let current = data;
142
+ for (let opIndex = 0; opIndex < ops.length; opIndex++) {
143
+ const op = ops[opIndex];
144
+ try {
145
+ current = applyOne(current, op, opIndex);
146
+ }
147
+ catch (error) {
148
+ if (error instanceof DataOpError)
149
+ throw error;
150
+ throw new DataOpError(opIndex, `${op.op} ${"path" in op ? op.path : ""}: ${error instanceof Error ? error.message : String(error)}`);
151
+ }
152
+ }
153
+ return current;
154
+ }
155
+ function applyOne(data, op, opIndex) {
156
+ switch (op.op) {
157
+ case "set":
158
+ return opSet(data, op.path, op.value, opIndex);
159
+ case "remove":
160
+ return opRemove(data, op.path, opIndex);
161
+ case "insert":
162
+ return opInsert(data, op.path, op.value, opIndex);
163
+ case "move":
164
+ return opMove(data, op.from, op.path, opIndex);
165
+ }
166
+ }
167
+ /** `set`: assign at `path` (an existing array slot, or a created object member). */
168
+ function opSet(data, path, value, opIndex) {
169
+ const tokens = pointerTokens(path);
170
+ if (tokens.length === 0) {
171
+ throw new DataOpError(opIndex, "set at the root is not allowed");
172
+ }
173
+ const { parent, key } = resolveParent(data, tokens, opIndex, path);
174
+ if (Array.isArray(parent)) {
175
+ arrayIndex(parent, key, path); // validate before cloning
176
+ }
177
+ return withWrite(data, tokens, (container, finalKey) => {
178
+ if (Array.isArray(container)) {
179
+ container[arrayIndex(container, finalKey, path)] = value;
180
+ }
181
+ else {
182
+ container[finalKey] = value;
183
+ }
184
+ });
185
+ }
186
+ /** `remove`: delete the member at `path` (shifting array elements). */
187
+ function opRemove(data, path, opIndex) {
188
+ const tokens = pointerTokens(path);
189
+ if (tokens.length === 0) {
190
+ throw new DataOpError(opIndex, "remove at the root is not allowed");
191
+ }
192
+ const { parent, key } = resolveParent(data, tokens, opIndex, path);
193
+ if (Array.isArray(parent)) {
194
+ arrayIndex(parent, key, path); // throws (→ opIndex) when out of bounds
195
+ }
196
+ else if (!Object.prototype.hasOwnProperty.call(parent, key)) {
197
+ throw new DataOpError(opIndex, `path not found: ${path}`);
198
+ }
199
+ return withWrite(data, tokens, (container, finalKey) => {
200
+ if (Array.isArray(container)) {
201
+ container.splice(arrayIndex(container, finalKey, path), 1);
202
+ }
203
+ else {
204
+ delete container[finalKey];
205
+ }
206
+ });
207
+ }
208
+ /** `insert`: insert `value` into the array at `path` (`-` appends). */
209
+ function opInsert(data, path, value, opIndex) {
210
+ const tokens = pointerTokens(path);
211
+ if (tokens.length === 0) {
212
+ throw new DataOpError(opIndex, `not an array: ${path}`);
213
+ }
214
+ const { parent } = resolveParent(data, tokens, opIndex, path);
215
+ if (!Array.isArray(parent)) {
216
+ throw new DataOpError(opIndex, `not an array: ${path}`);
217
+ }
218
+ return withWrite(data, tokens, (container, finalKey) => {
219
+ if (Array.isArray(container)) {
220
+ container.splice(insertIndex(container, finalKey, path), 0, value);
221
+ }
222
+ });
223
+ }
224
+ /**
225
+ * `move`: remove the item at `from`, then insert it at `path` — a
226
+ * reorder within one array or a move across arrays (spec §4.3: it
227
+ * exists so reorders don't re-send the moved item). Both ends must be
228
+ * array slots; the destination index is resolved against the
229
+ * post-removal array.
230
+ */
231
+ function opMove(data, from, path, opIndex) {
232
+ const fromTokens = pointerTokens(from);
233
+ const toTokens = pointerTokens(path);
234
+ if (fromTokens.length === 0 || toTokens.length === 0) {
235
+ throw new DataOpError(opIndex, "move at the root is not allowed");
236
+ }
237
+ const source = resolveParent(data, fromTokens, opIndex, from);
238
+ if (!Array.isArray(source.parent)) {
239
+ throw new DataOpError(opIndex, `not an array: ${from}`);
240
+ }
241
+ const value = readToken(source.parent, source.key);
242
+ if (value === undefined) {
243
+ throw new DataOpError(opIndex, `path not found: ${from}`);
244
+ }
245
+ // Remove first: a move into the same array sees post-removal indices.
246
+ const removed = withWrite(data, fromTokens, (container, finalKey) => {
247
+ if (Array.isArray(container)) {
248
+ container.splice(arrayIndex(container, finalKey, from), 1);
249
+ }
250
+ });
251
+ const to = resolveParent(removed, toTokens, opIndex, path);
252
+ if (!Array.isArray(to.parent)) {
253
+ throw new DataOpError(opIndex, `not an array: ${path}`);
254
+ }
255
+ return withWrite(removed, toTokens, (container, finalKey) => {
256
+ if (Array.isArray(container)) {
257
+ container.splice(insertIndex(container, finalKey, path), 0, value);
258
+ }
259
+ });
260
+ }
261
+ /**
262
+ * Resolve an RFC 6901 `pointer` against `data`; a pointer starting
263
+ * `./` resolves against `item` instead (spec §4.2's one extension,
264
+ * used inside collection item templates). A missing path — including a
265
+ * path THROUGH a JSON null, and a malformed pointer (no `/` or `./`
266
+ * prefix) — is `undefined`, never an error: "a binding to a missing
267
+ * path renders as empty, never as an error".
268
+ */
269
+ export function resolvePointer(data, pointer, item) {
270
+ const base = pointer.startsWith("./") ? item : data;
271
+ if (base === undefined)
272
+ return undefined;
273
+ const rest = pointer.startsWith("./") ? pointer.slice(1) : pointer;
274
+ let tokens;
275
+ try {
276
+ tokens = pointerTokens(rest);
277
+ }
278
+ catch {
279
+ // A malformed binding renders empty — the render contract is
280
+ // undefined-never-throws (only `applyOps`, the server-side applier,
281
+ // reports bad pointers, as `DataOpError`).
282
+ return undefined;
283
+ }
284
+ let node = base;
285
+ for (const token of tokens) {
286
+ if (!isContainer(node))
287
+ return undefined;
288
+ const next = readToken(node, token);
289
+ if (next === undefined)
290
+ return undefined;
291
+ node = next;
292
+ }
293
+ return node;
294
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `toText` (UI-blocks spec §14.3): the deterministic plain-text
3
+ * projection of a surface — headings, aligned tables, lists with
4
+ * checkboxes and numbers, lanes as sections, charts as one summary
5
+ * line per series. Used for the model's view of its own `show_blocks`
6
+ * call, the default `fallback` text, extension unit tests, logs and
7
+ * notifications.
8
+ *
9
+ * Determinism (spec §14.3): the same input, width and locale give the
10
+ * same bytes, so model context stays cacheable. Three rules make that
11
+ * hold:
12
+ *
13
+ * - object keys are visited in SCHEMA order, never insertion order —
14
+ * card slots, log lines, tree nodes and table cells are read through
15
+ * their named props, and anything rendered as JSON goes through
16
+ * `formatValue`'s sorted-key serializer;
17
+ * - no clock reads in the render path: `TextOptions.timeZone` defaults
18
+ * to UTC (a datetime projection is stable across hosts; pass a zone
19
+ * to localize), and only the `relative` format consults `Date.now()`;
20
+ * - unknown blocks render their `fallback`, else
21
+ * `[unsupported block: <type>]` (§4.4) — never a throw.
22
+ */
23
+ import type { Tree } from "./types.js";
24
+ import type { JsonObject } from "../json.js";
25
+ /** Options for `toText`: the width to wrap at, and the `Intl` locale. */
26
+ export interface TextOptions {
27
+ /** Wrap target in columns (the §14.3 snapshots use 80 and 160). */
28
+ width: number;
29
+ /** BCP-47 locale for every `Intl` format. */
30
+ locale: string;
31
+ /**
32
+ * Time zone for datetime renders. Defaults to UTC inside `toText`
33
+ * (byte-stable projections across hosts); pass one to localize.
34
+ */
35
+ timeZone?: string;
36
+ }
37
+ /**
38
+ * Render `tree` against `data` as plain text (spec §14.3).
39
+ * Deterministic per (input, width, locale): the same arguments give the
40
+ * same bytes. Unknown blocks render their `fallback`, else
41
+ * `[unsupported block: <type>]`.
42
+ */
43
+ export declare function toText(tree: Tree, data: JsonObject, opts: TextOptions): string;