@rohal12/spindle 0.51.0 → 0.51.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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +411 -134
- package/dist/pkg/story-variables.js +13 -0
- package/package.json +1 -1
- package/src/class-registry.ts +63 -1
- package/src/components/Passage.tsx +9 -3
- package/src/components/macros/Computed.tsx +2 -18
- package/src/components/macros/Include.tsx +57 -2
- package/src/components/macros/MacroLink.tsx +4 -3
- package/src/components/macros/PassageDisplay.tsx +1 -0
- package/src/components/macros/SaveManager.tsx +7 -0
- package/src/components/macros/VarDisplay.tsx +3 -2
- package/src/components/macros/Widget.tsx +7 -1
- package/src/components/macros/WidgetInvocation.tsx +4 -2
- package/src/execute-mutation.ts +8 -54
- package/src/expression.ts +99 -4
- package/src/index.tsx +12 -20
- package/src/interpolation.ts +2 -1
- package/src/markup/ast.ts +9 -1
- package/src/markup/render.tsx +2 -1
- package/src/passage-render-state.ts +9 -8
- package/src/preact-store.ts +50 -0
- package/src/saves/save-manager.ts +109 -55
- package/src/saves/storage.ts +18 -4
- package/src/saves/types.ts +56 -7
- package/src/store.ts +9 -4
- package/src/story-api.ts +13 -7
- package/src/story-init.ts +23 -0
- package/src/story-variables.ts +18 -0
|
@@ -768,6 +768,12 @@ var VALID_VAR_TYPES = new Set([
|
|
|
768
768
|
"string",
|
|
769
769
|
"boolean"
|
|
770
770
|
]);
|
|
771
|
+
/** Boxed sample values whose members a primitive of each type can access. */
|
|
772
|
+
var PRIMITIVE_SAMPLES = {
|
|
773
|
+
string: Object(""),
|
|
774
|
+
number: Object(0),
|
|
775
|
+
boolean: Object(false)
|
|
776
|
+
};
|
|
771
777
|
function inferSchema(value) {
|
|
772
778
|
if (Array.isArray(value)) return { type: "array" };
|
|
773
779
|
if (value !== null && typeof value === "object") {
|
|
@@ -845,6 +851,13 @@ function validateRef(ref, schema, forLocals) {
|
|
|
845
851
|
for (let i = 1; i < parts.length; i++) {
|
|
846
852
|
const part = parts[i];
|
|
847
853
|
if (current.type === "array") return null;
|
|
854
|
+
const sample = PRIMITIVE_SAMPLES[current.type];
|
|
855
|
+
if (sample && part in sample) {
|
|
856
|
+
const memberType = typeof sample[part];
|
|
857
|
+
if (!VALID_VAR_TYPES.has(memberType)) return null;
|
|
858
|
+
current = { type: memberType };
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
848
861
|
if (current.type !== "object" || !current.fields) return `Cannot access field "${part}" on $${parts.slice(0, i).join(".")} (type: ${current.type})`;
|
|
849
862
|
const fieldSchema = current.fields.get(part);
|
|
850
863
|
if (!fieldSchema) return null;
|
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -27,7 +27,16 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
27
27
|
return proto === Object.prototype || proto === null;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export
|
|
30
|
+
export interface DeepCloneOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Return instances of unregistered classes (DOM nodes, promises, other
|
|
33
|
+
* library objects) by reference instead of copying their own keys into a
|
|
34
|
+
* plain object, which would lose their prototype and identity.
|
|
35
|
+
*/
|
|
36
|
+
keepUnregistered?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
31
40
|
const seen = new Map<object, object>();
|
|
32
41
|
|
|
33
42
|
function clone(val: unknown): unknown {
|
|
@@ -83,6 +92,7 @@ export function deepClone<T>(value: T): T {
|
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
// Plain object (or unregistered class — treat as plain)
|
|
95
|
+
if (!isPlainObject(val) && options.keepUnregistered) return val;
|
|
86
96
|
if (isPlainObject(val) || typeof val === 'object') {
|
|
87
97
|
const copy: Record<string, unknown> = {};
|
|
88
98
|
seen.set(obj, copy);
|
|
@@ -98,6 +108,58 @@ export function deepClone<T>(value: T): T {
|
|
|
98
108
|
return clone(value) as T;
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
// --- Deep Equal ---
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Structural equality over the value types deepClone() supports: primitives,
|
|
115
|
+
* arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
|
|
116
|
+
* at any depth). Map and Set entries are compared in insertion order.
|
|
117
|
+
*/
|
|
118
|
+
export function deepEqual(
|
|
119
|
+
a: unknown,
|
|
120
|
+
b: unknown,
|
|
121
|
+
seen: Map<object, object> = new Map(),
|
|
122
|
+
): boolean {
|
|
123
|
+
if (Object.is(a, b)) return true;
|
|
124
|
+
if (
|
|
125
|
+
a === null ||
|
|
126
|
+
b === null ||
|
|
127
|
+
typeof a !== 'object' ||
|
|
128
|
+
typeof b !== 'object'
|
|
129
|
+
) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
133
|
+
if (seen.get(a) === b) return true;
|
|
134
|
+
seen.set(a, b);
|
|
135
|
+
|
|
136
|
+
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
137
|
+
if (a instanceof RegExp) return String(a) === String(b);
|
|
138
|
+
if (a instanceof Map || a instanceof Set) {
|
|
139
|
+
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
140
|
+
if (a.size !== bc.size) return false;
|
|
141
|
+
const ai = a.entries();
|
|
142
|
+
const bi = bc.entries();
|
|
143
|
+
for (
|
|
144
|
+
let x = ai.next(), y = bi.next();
|
|
145
|
+
!x.done;
|
|
146
|
+
x = ai.next(), y = bi.next()
|
|
147
|
+
) {
|
|
148
|
+
if (!deepEqual(x.value, y.value, seen)) return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ao = a as Record<string, unknown>;
|
|
154
|
+
const bo = b as Record<string, unknown>;
|
|
155
|
+
const keys = Object.keys(ao);
|
|
156
|
+
if (keys.length !== Object.keys(bo).length) return false;
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
101
163
|
// --- Serialize ---
|
|
102
164
|
|
|
103
165
|
const CLASS_TAG = '__spindle_class__';
|
|
@@ -42,6 +42,8 @@ export function renderPassageContent(passage: PassageData) {
|
|
|
42
42
|
interface PassageProps {
|
|
43
43
|
passage: PassageData;
|
|
44
44
|
dataTransition?: string;
|
|
45
|
+
/** The navigation this passage displays; omitted for placeholders. */
|
|
46
|
+
navigationId?: number;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
const CODE_PASSAGES = new Set([
|
|
@@ -51,7 +53,11 @@ const CODE_PASSAGES = new Set([
|
|
|
51
53
|
'PassageDone',
|
|
52
54
|
]);
|
|
53
55
|
|
|
54
|
-
export function Passage({
|
|
56
|
+
export function Passage({
|
|
57
|
+
passage,
|
|
58
|
+
dataTransition,
|
|
59
|
+
navigationId,
|
|
60
|
+
}: PassageProps) {
|
|
55
61
|
const storyData = useStoryStore((s) => s.storyData);
|
|
56
62
|
const isCodePassage = CODE_PASSAGES.has(passage.name);
|
|
57
63
|
const [doneReady, setDoneReady] = useState(false);
|
|
@@ -121,11 +127,11 @@ export function Passage({ passage, dataTransition }: PassageProps) {
|
|
|
121
127
|
// Passage so every navigation that shows a passage mounts a new one.
|
|
122
128
|
const elRef = useRef<HTMLDivElement>(null);
|
|
123
129
|
useLayoutEffect(() => {
|
|
124
|
-
markPassageRendered(
|
|
130
|
+
if (navigationId !== undefined) markPassageRendered(navigationId);
|
|
125
131
|
if (elRef.current) {
|
|
126
132
|
emitFromRender('passagerender', passage.name, elRef.current);
|
|
127
133
|
}
|
|
128
|
-
}, [passage.name]);
|
|
134
|
+
}, [passage.name, navigationId]);
|
|
129
135
|
|
|
130
136
|
const nobr = passage.tags.includes('nobr');
|
|
131
137
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { evaluate } from '../../expression';
|
|
3
|
+
import { deepEqual } from '../../class-registry';
|
|
3
4
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
4
5
|
import { defineMacro } from '../../define-macro';
|
|
5
6
|
import { MacroError } from './MacroError';
|
|
@@ -37,23 +38,6 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
41
|
-
if (Object.is(a, b)) return true;
|
|
42
|
-
if (
|
|
43
|
-
typeof a === 'object' &&
|
|
44
|
-
a !== null &&
|
|
45
|
-
typeof b === 'object' &&
|
|
46
|
-
b !== null
|
|
47
|
-
) {
|
|
48
|
-
try {
|
|
49
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
50
|
-
} catch {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
41
|
function computeAndApply(
|
|
58
42
|
expr: string,
|
|
59
43
|
name: string,
|
|
@@ -78,7 +62,7 @@ function computeAndApply(
|
|
|
78
62
|
return;
|
|
79
63
|
}
|
|
80
64
|
|
|
81
|
-
if (!
|
|
65
|
+
if (!deepEqual(prevRef.current, newValue)) {
|
|
82
66
|
prevRef.current = newValue;
|
|
83
67
|
if (isLocal) {
|
|
84
68
|
try {
|
|
@@ -4,6 +4,62 @@ import { buildAST } from '../../markup/ast';
|
|
|
4
4
|
import { NobrContext } from '../../markup/render';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
6
|
|
|
7
|
+
const FLAG = 'inline';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Split a standalone `inline` flag off the start or end of the include
|
|
11
|
+
* arguments. The flag only counts outside quotes, brackets, parentheses and
|
|
12
|
+
* braces, separated by whitespace from the target expression, so passage
|
|
13
|
+
* names and expressions containing the word stay intact (#201).
|
|
14
|
+
*/
|
|
15
|
+
function parseIncludeArgs(rawArgs: string): {
|
|
16
|
+
nameExpr: string;
|
|
17
|
+
inline: boolean;
|
|
18
|
+
} {
|
|
19
|
+
const trimmed = rawArgs.trim();
|
|
20
|
+
|
|
21
|
+
// First and last whitespace runs at depth 0, as [start, end) offsets.
|
|
22
|
+
let first: [number, number] | null = null;
|
|
23
|
+
let last: [number, number] | null = null;
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let inString: string | null = null;
|
|
26
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
27
|
+
const ch = trimmed[i]!;
|
|
28
|
+
if (inString) {
|
|
29
|
+
if (ch === '\\') i++;
|
|
30
|
+
else if (ch === inString) inString = null;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (ch === '"' || ch === "'" || ch === '`') inString = ch;
|
|
34
|
+
else if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
35
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
36
|
+
else if (depth === 0 && /\s/.test(ch)) {
|
|
37
|
+
let end = i + 1;
|
|
38
|
+
while (end < trimmed.length && /\s/.test(trimmed[end]!)) end++;
|
|
39
|
+
last = [i, end];
|
|
40
|
+
first ??= last;
|
|
41
|
+
i = end - 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Next to a binary operator the word is an operand (`"a" + inline`).
|
|
46
|
+
if (
|
|
47
|
+
last &&
|
|
48
|
+
last[1] === trimmed.length - FLAG.length &&
|
|
49
|
+
trimmed.endsWith(FLAG)
|
|
50
|
+
) {
|
|
51
|
+
const rest = trimmed.slice(0, last[0]);
|
|
52
|
+
if (!/[-+*/%&|^!=<>?:,.]$/.test(rest))
|
|
53
|
+
return { nameExpr: rest, inline: true };
|
|
54
|
+
}
|
|
55
|
+
if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
|
|
56
|
+
const rest = trimmed.slice(first[1]);
|
|
57
|
+
if (!/^[-+*/%&|^=<>?:,.]/.test(rest))
|
|
58
|
+
return { nameExpr: rest, inline: true };
|
|
59
|
+
}
|
|
60
|
+
return { nameExpr: trimmed, inline: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
7
63
|
defineMacro({
|
|
8
64
|
name: 'include',
|
|
9
65
|
interpolate: true,
|
|
@@ -11,8 +67,7 @@ defineMacro({
|
|
|
11
67
|
render({ rawArgs }, ctx) {
|
|
12
68
|
const storyData = useStoryStore((s) => s.storyData);
|
|
13
69
|
|
|
14
|
-
const inline =
|
|
15
|
-
const nameExpr = rawArgs.replace(/\binline\b/, '').trim();
|
|
70
|
+
const { nameExpr, inline } = parseIncludeArgs(rawArgs);
|
|
16
71
|
|
|
17
72
|
let passageName: string;
|
|
18
73
|
try {
|
|
@@ -14,12 +14,13 @@ function parseArgs(rawArgs: string): {
|
|
|
14
14
|
display: string;
|
|
15
15
|
passage: string | null;
|
|
16
16
|
} {
|
|
17
|
-
// {link "text" "Passage"} or {link "text"}
|
|
17
|
+
// {link "text" "Passage"} or {link "text"}. A backslash escapes a quote
|
|
18
|
+
// or another backslash inside a quoted argument (#200).
|
|
18
19
|
const parts: string[] = [];
|
|
19
|
-
const re = /(["'])(
|
|
20
|
+
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
20
21
|
let m;
|
|
21
22
|
while ((m = re.exec(rawArgs)) !== null) {
|
|
22
|
-
parts.push(m[2]
|
|
23
|
+
parts.push(m[2]!.replace(/\\(["'\\])/g, '$1'));
|
|
23
24
|
}
|
|
24
25
|
if (parts.length >= 2) {
|
|
25
26
|
return { display: parts[0]!, passage: parts[1]! };
|
|
@@ -292,6 +292,7 @@ defineMacro({
|
|
|
292
292
|
passage={effectivePassage}
|
|
293
293
|
key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
|
|
294
294
|
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
295
|
+
navigationId={renderDeferred ? undefined : displayed.id}
|
|
295
296
|
/>
|
|
296
297
|
)}
|
|
297
298
|
</div>
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
createSave,
|
|
13
13
|
overwriteSave,
|
|
14
14
|
deleteSaveById,
|
|
15
|
+
populateKnownSaves,
|
|
15
16
|
renameSave,
|
|
16
17
|
exportSave,
|
|
17
18
|
importSave,
|
|
@@ -151,6 +152,12 @@ export function SaveManagerContent() {
|
|
|
151
152
|
if (!confirm('Delete this save?')) return;
|
|
152
153
|
try {
|
|
153
154
|
await deleteSaveById(saveId);
|
|
155
|
+
// The save may have been held by a slot; hasSave() and QuickLoad
|
|
156
|
+
// read the store's cache
|
|
157
|
+
const known = await populateKnownSaves(ifid);
|
|
158
|
+
useStoryStore.setState((state) => {
|
|
159
|
+
state.knownSaves = known;
|
|
160
|
+
});
|
|
154
161
|
showStatus('Save deleted');
|
|
155
162
|
await refresh();
|
|
156
163
|
} catch {
|
|
@@ -34,9 +34,10 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
|
|
|
34
34
|
value = storeValue;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// Resolve dot path (e.g. "character.name" → character['name'])
|
|
37
|
+
// Resolve dot path (e.g. "character.name" → character['name']). Primitives
|
|
38
|
+
// box on access, so built-ins like "name.length" resolve too.
|
|
38
39
|
for (let i = 1; i < parts.length; i++) {
|
|
39
|
-
if (value == null
|
|
40
|
+
if (value == null) {
|
|
40
41
|
value = undefined;
|
|
41
42
|
break;
|
|
42
43
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { registerWidget } from '../../widgets/widget-registry';
|
|
2
|
+
import { astContainsChildren } from '../../widgets/ast-scanner';
|
|
3
|
+
import { registerBlockMacro } from '../../markup/ast';
|
|
2
4
|
import { defineMacro } from '../../define-macro';
|
|
3
5
|
|
|
4
6
|
function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
|
|
@@ -18,7 +20,11 @@ defineMacro({
|
|
|
18
20
|
const paramsKey = params.join(',');
|
|
19
21
|
|
|
20
22
|
ctx.hooks.useLayoutEffect(() => {
|
|
21
|
-
|
|
23
|
+
// Widgets whose body renders {@children} take a closing tag; register
|
|
24
|
+
// them as block macros so passages parsed later nest their content.
|
|
25
|
+
const isBlock = astContainsChildren(children);
|
|
26
|
+
registerWidget(name, children, params, isBlock);
|
|
27
|
+
if (isBlock) registerBlockMacro(name);
|
|
22
28
|
}, [name, childrenKey, paramsKey]);
|
|
23
29
|
|
|
24
30
|
return null;
|
|
@@ -230,7 +230,9 @@ export function WidgetInvocation({
|
|
|
230
230
|
|
|
231
231
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
// Parameterized widgets always get their own local scope, even when invoked
|
|
234
|
+
// without arguments: missing parameters shadow outer locals as undefined.
|
|
235
|
+
if (params.length === 0) {
|
|
234
236
|
return (
|
|
235
237
|
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
236
238
|
{renderNodes(body, { nobr, locals: parentValues })}
|
|
@@ -238,7 +240,7 @@ export function WidgetInvocation({
|
|
|
238
240
|
);
|
|
239
241
|
}
|
|
240
242
|
|
|
241
|
-
const argExprs = splitArgs(rawArgs);
|
|
243
|
+
const argExprs = rawArgs ? splitArgs(rawArgs) : [];
|
|
242
244
|
const values: unknown[] = [];
|
|
243
245
|
|
|
244
246
|
for (let i = 0; i < params.length; i++) {
|
package/src/execute-mutation.ts
CHANGED
|
@@ -1,57 +1,6 @@
|
|
|
1
1
|
import { useStoryStore } from './store';
|
|
2
2
|
import { execute } from './expression';
|
|
3
|
-
import { deepClone } from './class-registry';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Structural equality for a deepClone() copy against its source. Lets
|
|
7
|
-
* executeMutation write back only the variables the code actually changed,
|
|
8
|
-
* so untouched clones don't overwrite updates made during execution
|
|
9
|
-
* (e.g. Story.set called from inside {do}).
|
|
10
|
-
*/
|
|
11
|
-
function deepEqual(
|
|
12
|
-
a: unknown,
|
|
13
|
-
b: unknown,
|
|
14
|
-
seen: Map<object, object> = new Map(),
|
|
15
|
-
): boolean {
|
|
16
|
-
if (Object.is(a, b)) return true;
|
|
17
|
-
if (
|
|
18
|
-
a === null ||
|
|
19
|
-
b === null ||
|
|
20
|
-
typeof a !== 'object' ||
|
|
21
|
-
typeof b !== 'object'
|
|
22
|
-
) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
26
|
-
if (seen.get(a) === b) return true;
|
|
27
|
-
seen.set(a, b);
|
|
28
|
-
|
|
29
|
-
if (a instanceof Date) return a.getTime() === (b as Date).getTime();
|
|
30
|
-
if (a instanceof RegExp) return String(a) === String(b);
|
|
31
|
-
if (a instanceof Map || a instanceof Set) {
|
|
32
|
-
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
33
|
-
if (a.size !== bc.size) return false;
|
|
34
|
-
const ai = a.entries();
|
|
35
|
-
const bi = bc.entries();
|
|
36
|
-
for (
|
|
37
|
-
let x = ai.next(), y = bi.next();
|
|
38
|
-
!x.done;
|
|
39
|
-
x = ai.next(), y = bi.next()
|
|
40
|
-
) {
|
|
41
|
-
if (!deepEqual(x.value, y.value, seen)) return false;
|
|
42
|
-
}
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const ao = a as Record<string, unknown>;
|
|
47
|
-
const bo = b as Record<string, unknown>;
|
|
48
|
-
const keys = Object.keys(ao);
|
|
49
|
-
if (keys.length !== Object.keys(bo).length) return false;
|
|
50
|
-
for (const key of keys) {
|
|
51
|
-
if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
|
|
52
|
-
}
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
3
|
+
import { deepClone, deepEqual } from './class-registry';
|
|
55
4
|
|
|
56
5
|
/**
|
|
57
6
|
* Write the keys `code` changed (`clone` vs the pre-execution `before`) into
|
|
@@ -84,7 +33,12 @@ export function executeMutation(
|
|
|
84
33
|
const vars = deepClone(state.variables);
|
|
85
34
|
const temps = deepClone(state.temporary);
|
|
86
35
|
const trans = deepClone(state.transient);
|
|
87
|
-
|
|
36
|
+
// Locals are deep-cloned like the store namespaces: a loop item or widget
|
|
37
|
+
// argument taken from story state is Immer-frozen, and an unfrozen object
|
|
38
|
+
// mutated in place would keep its reference, so a nested assignment
|
|
39
|
+
// (`@item.name = "x"`) would be lost or never reach the scope updater.
|
|
40
|
+
// Unregistered class instances (DOM nodes etc.) stay shared by reference.
|
|
41
|
+
const localsClone = deepClone(mergedLocals, { keepUnregistered: true });
|
|
88
42
|
|
|
89
43
|
execute(code, vars, temps, localsClone, trans);
|
|
90
44
|
|
|
@@ -98,7 +52,7 @@ export function executeMutation(
|
|
|
98
52
|
});
|
|
99
53
|
|
|
100
54
|
for (const key of Object.keys(localsClone)) {
|
|
101
|
-
if (localsClone[key]
|
|
55
|
+
if (!deepEqual(localsClone[key], mergedLocals[key])) {
|
|
102
56
|
scopeUpdate(key, localsClone[key]);
|
|
103
57
|
}
|
|
104
58
|
}
|
package/src/expression.ts
CHANGED
|
@@ -31,21 +31,45 @@ const fnCache = new Map<string, CompiledExpression>();
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
34
|
-
* @var → locals["var"]
|
|
34
|
+
* @var → locals["var"]
|
|
35
35
|
* Only transforms when sigils appear as a word boundary (not inside strings naively,
|
|
36
36
|
* but authors already have full JS access so this is acceptable).
|
|
37
|
+
* `%var` → transient["var"] is handled by `transform` itself, because `%` is
|
|
38
|
+
* also the modulo operator and only an operand position makes it a sigil.
|
|
37
39
|
*/
|
|
38
40
|
const VAR_RE = /\$(\w+)/g;
|
|
39
41
|
const TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
40
42
|
const LOCAL_RE = /@(\w+)/g;
|
|
41
|
-
|
|
43
|
+
/** Transient name after `%`: an identifier, so `%3` is never a reference. */
|
|
44
|
+
const TRANS_NAME_RE = /[A-Za-z_]\w*/y;
|
|
45
|
+
/** Characters of identifiers, numbers and sigil variable references. */
|
|
46
|
+
const WORD_CHAR_RE = /[\w$@]/;
|
|
47
|
+
const SPACE_RE = /\s/;
|
|
48
|
+
/** Keywords followed by an operand rather than an operator. */
|
|
49
|
+
const OPERAND_KEYWORDS = new Set([
|
|
50
|
+
'await',
|
|
51
|
+
'case',
|
|
52
|
+
'delete',
|
|
53
|
+
'do',
|
|
54
|
+
'else',
|
|
55
|
+
'in',
|
|
56
|
+
'instanceof',
|
|
57
|
+
'new',
|
|
58
|
+
'of',
|
|
59
|
+
'return',
|
|
60
|
+
'throw',
|
|
61
|
+
'typeof',
|
|
62
|
+
'void',
|
|
63
|
+
'yield',
|
|
64
|
+
]);
|
|
65
|
+
/** Keywords whose parenthesised header is followed by a statement. */
|
|
66
|
+
const HEADER_KEYWORDS = new Set(['for', 'if', 'while', 'with']);
|
|
42
67
|
|
|
43
68
|
function transformSegment(segment: string): string {
|
|
44
69
|
return segment
|
|
45
70
|
.replace(VAR_RE, 'variables["$1"]')
|
|
46
71
|
.replace(TEMP_RE, 'temporary["$1"]')
|
|
47
|
-
.replace(LOCAL_RE, 'locals["$1"]')
|
|
48
|
-
.replace(TRANS_RE, 'transient["$1"]');
|
|
72
|
+
.replace(LOCAL_RE, 'locals["$1"]');
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
/**
|
|
@@ -53,12 +77,23 @@ function transformSegment(segment: string): string {
|
|
|
53
77
|
* character so that variable sigils ($, _, @, %) inside string literals are
|
|
54
78
|
* left untouched while code — including expressions inside template-literal
|
|
55
79
|
* `${…}` interpolations — is transformed.
|
|
80
|
+
*
|
|
81
|
+
* It also tracks whether the next token is an operand or an operator, so that
|
|
82
|
+
* `%name` in operand position is a transient reference while `%` after an
|
|
83
|
+
* operand — `($n)%3`, `$a[i] %2`, `$a%$b` — stays the modulo operator.
|
|
56
84
|
*/
|
|
57
85
|
function transform(expr: string): string {
|
|
58
86
|
let result = '';
|
|
59
87
|
let code = ''; // accumulates code characters to be transformed
|
|
60
88
|
let i = 0;
|
|
61
89
|
|
|
90
|
+
let operandNext = true; // an operand (not an operator) comes next
|
|
91
|
+
let word = ''; // identifier/number currently being read
|
|
92
|
+
let afterDot = false; // `word` is a property name, never a keyword
|
|
93
|
+
let afterHeaderKeyword = false; // last token was if/while/for/with
|
|
94
|
+
let lastPunct = '';
|
|
95
|
+
const parenIsHeader: boolean[] = []; // per open `(`: closes a header?
|
|
96
|
+
|
|
62
97
|
function flushCode() {
|
|
63
98
|
if (code) {
|
|
64
99
|
result += transformSegment(code);
|
|
@@ -66,6 +101,49 @@ function transform(expr: string): string {
|
|
|
66
101
|
}
|
|
67
102
|
}
|
|
68
103
|
|
|
104
|
+
function endWord() {
|
|
105
|
+
if (!word) return;
|
|
106
|
+
operandNext = !afterDot && OPERAND_KEYWORDS.has(word);
|
|
107
|
+
afterHeaderKeyword = !afterDot && HEADER_KEYWORDS.has(word);
|
|
108
|
+
afterDot = false;
|
|
109
|
+
lastPunct = '';
|
|
110
|
+
word = '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A string or template literal, or a transient reference, just ended. */
|
|
114
|
+
function endOperand() {
|
|
115
|
+
endWord();
|
|
116
|
+
operandNext = false;
|
|
117
|
+
afterHeaderKeyword = false;
|
|
118
|
+
afterDot = false;
|
|
119
|
+
lastPunct = '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function trackCode(c: string) {
|
|
123
|
+
if (WORD_CHAR_RE.test(c)) {
|
|
124
|
+
word += c;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
endWord();
|
|
128
|
+
// A line may start a new statement such as `%x = 1`.
|
|
129
|
+
if (c === '\n') operandNext = true;
|
|
130
|
+
if (SPACE_RE.test(c)) return;
|
|
131
|
+
if (c === '(') parenIsHeader.push(afterHeaderKeyword);
|
|
132
|
+
if (c === ')') {
|
|
133
|
+
// `if (…) %x = 1` vs `($n)%3`
|
|
134
|
+
operandNext = parenIsHeader.pop() ?? false;
|
|
135
|
+
} else if (c === '.') {
|
|
136
|
+
// Property access, unless it is the spread `...`
|
|
137
|
+
operandNext = lastPunct === '.';
|
|
138
|
+
} else {
|
|
139
|
+
// `]` ends an operand; `}` closes a block, so a statement may follow.
|
|
140
|
+
operandNext = c !== ']';
|
|
141
|
+
}
|
|
142
|
+
afterDot = c === '.';
|
|
143
|
+
afterHeaderKeyword = false;
|
|
144
|
+
lastPunct = c;
|
|
145
|
+
}
|
|
146
|
+
|
|
69
147
|
while (i < expr.length) {
|
|
70
148
|
const ch = expr.charAt(i);
|
|
71
149
|
|
|
@@ -90,6 +168,7 @@ function transform(expr: string): string {
|
|
|
90
168
|
}
|
|
91
169
|
}
|
|
92
170
|
result += str;
|
|
171
|
+
endOperand();
|
|
93
172
|
continue;
|
|
94
173
|
}
|
|
95
174
|
|
|
@@ -192,10 +271,26 @@ function transform(expr: string): string {
|
|
|
192
271
|
i++;
|
|
193
272
|
}
|
|
194
273
|
}
|
|
274
|
+
endOperand();
|
|
195
275
|
continue;
|
|
196
276
|
}
|
|
197
277
|
|
|
278
|
+
// Transient reference — only where an operand is expected
|
|
279
|
+
if (ch === '%') {
|
|
280
|
+
endWord();
|
|
281
|
+
TRANS_NAME_RE.lastIndex = i + 1;
|
|
282
|
+
const name = operandNext ? TRANS_NAME_RE.exec(expr)?.[0] : undefined;
|
|
283
|
+
if (name) {
|
|
284
|
+
flushCode();
|
|
285
|
+
result += `transient["${name}"]`;
|
|
286
|
+
i += 1 + name.length;
|
|
287
|
+
endOperand();
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
198
292
|
// Regular code character
|
|
293
|
+
trackCode(ch);
|
|
199
294
|
code += ch;
|
|
200
295
|
i++;
|
|
201
296
|
}
|
package/src/index.tsx
CHANGED
|
@@ -2,14 +2,13 @@ import { render } from 'preact';
|
|
|
2
2
|
import { App } from './components/App';
|
|
3
3
|
import { parseStoryData } from './parser';
|
|
4
4
|
import { useStoryStore, enterRuntimePhase } from './store';
|
|
5
|
-
import { emit } from './event-emitter';
|
|
6
5
|
import {
|
|
7
6
|
installStoryAPI,
|
|
8
7
|
getReadyPromise,
|
|
9
8
|
setDeclaredVariables,
|
|
10
9
|
} from './story-api';
|
|
11
10
|
import { resetIdCounters } from './action-registry';
|
|
12
|
-
import {
|
|
11
|
+
import { initializeStory } from './story-init';
|
|
13
12
|
import { connectTriggersToStore } from './triggers';
|
|
14
13
|
import { loadSession } from './saves/save-manager';
|
|
15
14
|
import {
|
|
@@ -141,26 +140,15 @@ export function boot() {
|
|
|
141
140
|
// Enter runtime phase — handlers registered from here on are cleaned on restart
|
|
142
141
|
enterRuntimePhase();
|
|
143
142
|
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
if (sessionPayload) {
|
|
150
|
-
useStoryStore.getState().loadFromPayload(sessionPayload);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Fire storyinit after all state is settled (defaults + StoryInit + session)
|
|
154
|
-
emit('storyinit');
|
|
155
|
-
|
|
156
|
-
// Pass 1: Pre-scan all widget passages to discover block widgets.
|
|
157
|
-
// Register them as block macros BEFORE any tokenize/buildAST calls,
|
|
158
|
-
// so that widget bodies using other block widgets parse correctly
|
|
159
|
-
// regardless of passage order.
|
|
143
|
+
// Pass 1: Pre-scan StoryInit and all widget passages to discover block
|
|
144
|
+
// widgets. Register them as block macros BEFORE any tokenize/buildAST
|
|
145
|
+
// calls (StoryInit's included), so that passages invoking block widgets
|
|
146
|
+
// and widget bodies using other block widgets parse correctly regardless
|
|
147
|
+
// of passage or definition order.
|
|
160
148
|
const blockWidgetPattern =
|
|
161
149
|
/\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
|
|
162
|
-
for (const [, passage] of storyData.passages) {
|
|
163
|
-
if (passage.tags.includes('widget')) {
|
|
150
|
+
for (const [passageName, passage] of storyData.passages) {
|
|
151
|
+
if (passageName === 'StoryInit' || passage.tags.includes('widget')) {
|
|
164
152
|
let match;
|
|
165
153
|
while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
|
|
166
154
|
const name = match[1]!;
|
|
@@ -173,6 +161,10 @@ export function boot() {
|
|
|
173
161
|
}
|
|
174
162
|
}
|
|
175
163
|
|
|
164
|
+
// Run StoryInit, restore the session if the page was refreshed, and fire
|
|
165
|
+
// storyinit after all state is settled (defaults + StoryInit + session)
|
|
166
|
+
initializeStory(loadSession(storyData.ifid));
|
|
167
|
+
|
|
176
168
|
// Pass 2: Full parse and register widgets from passages tagged "widget"
|
|
177
169
|
for (const [, passage] of storyData.passages) {
|
|
178
170
|
if (passage.tags.includes('widget')) {
|