@rohal12/spindle 0.51.2 → 0.51.4
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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +1360 -774
- package/dist/pkg/story-variables.js +22 -12
- package/package.json +1 -1
- package/src/class-registry.ts +76 -0
- package/src/components/macros/Button.tsx +4 -1
- package/src/components/macros/Checkbox.tsx +5 -2
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +5 -2
- package/src/components/macros/Include.tsx +9 -19
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +18 -40
- package/src/components/macros/Radiobutton.tsx +22 -0
- package/src/components/macros/SaveManager.tsx +19 -16
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/WidgetInvocation.tsx +14 -94
- package/src/components/macros/arg-utils.ts +120 -0
- package/src/components/macros/option-utils.ts +9 -5
- package/src/define-macro.ts +13 -34
- package/src/execute-mutation.ts +294 -26
- package/src/expression.ts +22 -237
- package/src/js-lexer.ts +326 -0
- package/src/markup/render.tsx +139 -81
- package/src/markup/tokenizer.ts +23 -9
- package/src/saves/save-manager.ts +47 -8
- package/src/saves/storage.ts +8 -0
- package/src/saves/types.ts +18 -5
- package/src/store.ts +124 -48
- package/src/story-api.ts +51 -50
- package/src/triggers.ts +5 -2
- package/src/utils/object-path.ts +109 -0
- package/src/utils/stable-key.ts +77 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { isDraft } from 'immer';
|
|
2
|
+
|
|
3
|
+
/** Traverse dot-path segments on an object and return the nested value. */
|
|
4
|
+
export function getByPath(
|
|
5
|
+
obj: Record<string, unknown>,
|
|
6
|
+
segments: readonly string[],
|
|
7
|
+
): unknown {
|
|
8
|
+
let current: unknown = obj;
|
|
9
|
+
for (const seg of segments) {
|
|
10
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
11
|
+
current = (current as Record<string, unknown>)[seg];
|
|
12
|
+
}
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shallow copy that keeps the prototype, so a registered class instance
|
|
18
|
+
* stays an instance of its class (with the same own keys deepClone copies).
|
|
19
|
+
*/
|
|
20
|
+
function shallowCopy(value: object): Record<string, unknown> {
|
|
21
|
+
let copy: object;
|
|
22
|
+
if (Array.isArray(value)) copy = [];
|
|
23
|
+
else if (value instanceof Map) copy = new Map(value);
|
|
24
|
+
else if (value instanceof Set) copy = new Set(value);
|
|
25
|
+
else if (value instanceof Date) copy = new Date(value.getTime());
|
|
26
|
+
else copy = Object.create(Object.getPrototypeOf(value)) as object;
|
|
27
|
+
return Object.assign(copy, value) as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SetByPathOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Create a plain object for a missing or non-object intermediate instead
|
|
33
|
+
* of throwing.
|
|
34
|
+
*/
|
|
35
|
+
createMissing?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Set a value at dot-path `segments` below `root`.
|
|
40
|
+
*
|
|
41
|
+
* When `root` is an Immer draft, objects Immer does not draft (registered
|
|
42
|
+
* class instances and everything below them) are copied on the way down and
|
|
43
|
+
* the copy is assigned to its parent, so the write produces new references
|
|
44
|
+
* up to the root. Writing into such an object directly would mutate the
|
|
45
|
+
* instance shared with the previous state and with history, and subscribers
|
|
46
|
+
* would see no change. Outside a draft (a private working copy) the path is
|
|
47
|
+
* written in place.
|
|
48
|
+
*/
|
|
49
|
+
export function setByPath(
|
|
50
|
+
root: Record<string, unknown>,
|
|
51
|
+
segments: readonly string[],
|
|
52
|
+
value: unknown,
|
|
53
|
+
options: SetByPathOptions = {},
|
|
54
|
+
): void {
|
|
55
|
+
const parent = walkToParent(root, segments, options.createMissing ?? false);
|
|
56
|
+
parent[segments[segments.length - 1]!] = value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Delete the property at dot-path `segments` below `root`, copying
|
|
61
|
+
* undrafted objects on the way down like setByPath(). Does nothing when an
|
|
62
|
+
* intermediate is missing or not an object, or the property is absent.
|
|
63
|
+
*/
|
|
64
|
+
export function deleteByPath(
|
|
65
|
+
root: Record<string, unknown>,
|
|
66
|
+
segments: readonly string[],
|
|
67
|
+
): void {
|
|
68
|
+
const last = segments[segments.length - 1]!;
|
|
69
|
+
// Check first, so a no-op delete copies nothing.
|
|
70
|
+
const holder = getByPath(root, segments.slice(0, -1));
|
|
71
|
+
if (holder == null || typeof holder !== 'object' || !(last in holder)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const parent = walkToParent(root, segments, false);
|
|
75
|
+
delete parent[last];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walk to the object holding the last of `segments`, copying undrafted
|
|
80
|
+
* objects when `root` is an Immer draft (see setByPath). A missing or
|
|
81
|
+
* non-object intermediate is replaced by a plain object when
|
|
82
|
+
* `createMissing` is set, and throws a TypeError otherwise.
|
|
83
|
+
*/
|
|
84
|
+
function walkToParent(
|
|
85
|
+
root: Record<string, unknown>,
|
|
86
|
+
segments: readonly string[],
|
|
87
|
+
createMissing: boolean,
|
|
88
|
+
): Record<string, unknown> {
|
|
89
|
+
const copyOnWrite = isDraft(root);
|
|
90
|
+
let current: Record<string, unknown> = root;
|
|
91
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
92
|
+
const seg = segments[i]!;
|
|
93
|
+
let next = current[seg];
|
|
94
|
+
if (next == null || typeof next !== 'object') {
|
|
95
|
+
if (!createMissing) {
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? 'null' : typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
next = {};
|
|
101
|
+
current[seg] = next;
|
|
102
|
+
} else if (copyOnWrite && !isDraft(next)) {
|
|
103
|
+
next = shallowCopy(next);
|
|
104
|
+
current[seg] = next;
|
|
105
|
+
}
|
|
106
|
+
current = next as Record<string, unknown>;
|
|
107
|
+
}
|
|
108
|
+
return current;
|
|
109
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getClassName } from '../class-registry';
|
|
2
|
+
|
|
3
|
+
type Constructor = new (...args: any[]) => any;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Content-derived string key for a value, used to remount components when
|
|
7
|
+
* the value's contents change (e.g. {for} iterations, #45).
|
|
8
|
+
*
|
|
9
|
+
* JSON-compatible values (strings, finite numbers, booleans, null, arrays and
|
|
10
|
+
* plain objects of those) produce exactly their `JSON.stringify` output. Other
|
|
11
|
+
* values get distinct, unambiguous markers so their contents are reflected
|
|
12
|
+
* too: Map and Set entries (nested at any depth), Date, RegExp, BigInt,
|
|
13
|
+
* undefined, NaN/±Infinity, symbols, functions and registered class
|
|
14
|
+
* instances. Cyclic references become a back-reference marker instead of
|
|
15
|
+
* throwing, and the function never throws.
|
|
16
|
+
*/
|
|
17
|
+
export function stableKey(value: unknown): string {
|
|
18
|
+
try {
|
|
19
|
+
return keyOf(value, []);
|
|
20
|
+
} catch {
|
|
21
|
+
return '<unkeyable>';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function keyOf(val: unknown, ancestors: object[]): string {
|
|
26
|
+
switch (typeof val) {
|
|
27
|
+
case 'string':
|
|
28
|
+
return JSON.stringify(val);
|
|
29
|
+
case 'number':
|
|
30
|
+
return Number.isFinite(val) ? JSON.stringify(val) : String(val);
|
|
31
|
+
case 'boolean':
|
|
32
|
+
return String(val);
|
|
33
|
+
case 'bigint':
|
|
34
|
+
return `${val}n`;
|
|
35
|
+
case 'undefined':
|
|
36
|
+
return 'undefined';
|
|
37
|
+
case 'symbol':
|
|
38
|
+
return val.toString();
|
|
39
|
+
case 'function':
|
|
40
|
+
return `<function ${JSON.stringify(val.name)}>`;
|
|
41
|
+
}
|
|
42
|
+
if (val === null) return 'null';
|
|
43
|
+
|
|
44
|
+
const obj = val as object;
|
|
45
|
+
const depth = ancestors.indexOf(obj);
|
|
46
|
+
if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
|
|
47
|
+
|
|
48
|
+
if (val instanceof Date) return `Date(${val.getTime()})`;
|
|
49
|
+
if (val instanceof RegExp) return `RegExp(${String(val)})`;
|
|
50
|
+
|
|
51
|
+
ancestors.push(obj);
|
|
52
|
+
try {
|
|
53
|
+
if (Array.isArray(val)) {
|
|
54
|
+
return `[${val.map((v) => keyOf(v, ancestors)).join(',')}]`;
|
|
55
|
+
}
|
|
56
|
+
if (val instanceof Map) {
|
|
57
|
+
const entries = [...val].map(
|
|
58
|
+
([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`,
|
|
59
|
+
);
|
|
60
|
+
return `Map[${entries.join(',')}]`;
|
|
61
|
+
}
|
|
62
|
+
if (val instanceof Set) {
|
|
63
|
+
return `Set[${[...val].map((v) => keyOf(v, ancestors)).join(',')}]`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const record = obj as Record<string, unknown>;
|
|
67
|
+
const body = Object.keys(record)
|
|
68
|
+
.map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`)
|
|
69
|
+
.join(',');
|
|
70
|
+
const className = getClassName(obj.constructor as Constructor);
|
|
71
|
+
return className === undefined
|
|
72
|
+
? `{${body}}`
|
|
73
|
+
: `Class(${JSON.stringify(className)}){${body}}`;
|
|
74
|
+
} finally {
|
|
75
|
+
ancestors.pop();
|
|
76
|
+
}
|
|
77
|
+
}
|