@rohal12/spindle 0.42.0 → 0.43.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/dist/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/components/macros/Computed.tsx +13 -5
- package/src/components/macros/ExprDisplay.tsx +8 -1
- package/src/components/macros/For.tsx +32 -23
- package/src/components/macros/Unset.tsx +3 -1
- package/src/components/macros/VarDisplay.tsx +4 -2
- package/src/components/macros/WidgetInvocation.tsx +28 -8
- package/src/define-macro.ts +11 -1
- package/src/execute-mutation.ts +12 -1
- package/src/expression.ts +14 -7
- package/src/hooks/use-interpolate.ts +3 -3
- package/src/hooks/use-merged-locals.ts +6 -4
- package/src/index.tsx +22 -1
- package/src/interpolation.ts +18 -7
- package/src/markup/ast.ts +1 -1
- package/src/markup/render.tsx +1 -0
- package/src/markup/tokenizer.ts +86 -1
- package/src/store.ts +26 -1
- package/src/story-api.ts +32 -4
- package/src/story-variables.ts +22 -9
- package/src/triggers.ts +7 -1
- package/types/index.d.ts +12 -3
package/package.json
CHANGED
|
@@ -61,11 +61,13 @@ function computeAndApply(
|
|
|
61
61
|
variables: Record<string, unknown>,
|
|
62
62
|
temporary: Record<string, unknown>,
|
|
63
63
|
locals: Record<string, unknown>,
|
|
64
|
+
transient: Record<string, unknown>,
|
|
64
65
|
rawArgs: string,
|
|
66
|
+
prevRef: { current: unknown },
|
|
65
67
|
): void {
|
|
66
68
|
let newValue: unknown;
|
|
67
69
|
try {
|
|
68
|
-
newValue = evaluate(expr, variables, temporary, locals);
|
|
70
|
+
newValue = evaluate(expr, variables, temporary, locals, transient);
|
|
69
71
|
} catch (err) {
|
|
70
72
|
console.error(
|
|
71
73
|
`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
|
|
@@ -74,8 +76,8 @@ function computeAndApply(
|
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
if (!valuesEqual(prevRef.current, newValue)) {
|
|
80
|
+
prevRef.current = newValue;
|
|
79
81
|
const state = useStoryStore.getState();
|
|
80
82
|
if (isTemp) state.setTemporary(name, newValue);
|
|
81
83
|
else state.setVariable(name, newValue);
|
|
@@ -86,7 +88,7 @@ defineMacro({
|
|
|
86
88
|
name: 'computed',
|
|
87
89
|
merged: true,
|
|
88
90
|
render({ rawArgs }, ctx) {
|
|
89
|
-
const [mergedVars, mergedTemps, mergedLocals] = ctx.merged!;
|
|
91
|
+
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
|
|
90
92
|
|
|
91
93
|
let target: string;
|
|
92
94
|
let expr: string;
|
|
@@ -103,6 +105,8 @@ defineMacro({
|
|
|
103
105
|
const isTemp = target.startsWith('_');
|
|
104
106
|
const name = target.slice(1);
|
|
105
107
|
|
|
108
|
+
const prevOutput = ctx.hooks.useRef<unknown>(undefined);
|
|
109
|
+
|
|
106
110
|
const ran = ctx.hooks.useRef(false);
|
|
107
111
|
if (!ran.current) {
|
|
108
112
|
ran.current = true;
|
|
@@ -113,7 +117,9 @@ defineMacro({
|
|
|
113
117
|
mergedVars,
|
|
114
118
|
mergedTemps,
|
|
115
119
|
mergedLocals,
|
|
120
|
+
mergedTrans,
|
|
116
121
|
rawArgs,
|
|
122
|
+
prevOutput,
|
|
117
123
|
);
|
|
118
124
|
}
|
|
119
125
|
|
|
@@ -125,9 +131,11 @@ defineMacro({
|
|
|
125
131
|
mergedVars,
|
|
126
132
|
mergedTemps,
|
|
127
133
|
mergedLocals,
|
|
134
|
+
mergedTrans,
|
|
128
135
|
rawArgs,
|
|
136
|
+
prevOutput,
|
|
129
137
|
);
|
|
130
|
-
}, [mergedVars, mergedTemps, mergedLocals]);
|
|
138
|
+
}, [mergedVars, mergedTemps, mergedLocals, mergedTrans]);
|
|
131
139
|
|
|
132
140
|
return null;
|
|
133
141
|
},
|
|
@@ -17,10 +17,17 @@ export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
|
|
|
17
17
|
const localsValues = useContext(LocalsValuesContext);
|
|
18
18
|
const variables = useStoryStore((s) => s.variables);
|
|
19
19
|
const temporary = useStoryStore((s) => s.temporary);
|
|
20
|
+
const transient = useStoryStore((s) => s.transient);
|
|
20
21
|
|
|
21
22
|
let display: string;
|
|
22
23
|
try {
|
|
23
|
-
const value = evaluate(
|
|
24
|
+
const value = evaluate(
|
|
25
|
+
expression,
|
|
26
|
+
variables,
|
|
27
|
+
temporary,
|
|
28
|
+
localsValues,
|
|
29
|
+
transient,
|
|
30
|
+
);
|
|
24
31
|
display = value == null ? '' : String(value);
|
|
25
32
|
} catch {
|
|
26
33
|
display = `{error: ${expression}}`;
|
|
@@ -53,21 +53,35 @@ function parseForArgs(rawArgs: string): {
|
|
|
53
53
|
|
|
54
54
|
function ForIteration({
|
|
55
55
|
parentValues,
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
itemVar,
|
|
57
|
+
itemValue,
|
|
58
|
+
indexVar,
|
|
59
|
+
indexValue,
|
|
58
60
|
children,
|
|
59
61
|
}: {
|
|
60
62
|
parentValues: Record<string, unknown>;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
itemVar: string;
|
|
64
|
+
itemValue: unknown;
|
|
65
|
+
indexVar: string | null;
|
|
66
|
+
indexValue: number;
|
|
63
67
|
children: ASTNode[];
|
|
64
68
|
}) {
|
|
65
69
|
const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
|
|
66
|
-
() => ({
|
|
70
|
+
() => ({}),
|
|
67
71
|
);
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
const ownKeys = useMemo(
|
|
74
|
+
() => ({
|
|
75
|
+
[itemVar]: itemValue,
|
|
76
|
+
...(indexVar ? { [indexVar]: indexValue } : undefined),
|
|
77
|
+
}),
|
|
78
|
+
[itemVar, itemValue, indexVar, indexValue],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const localState = useMemo(
|
|
82
|
+
() => ({ ...parentValues, ...ownKeys, ...localMutations }),
|
|
83
|
+
[parentValues, ownKeys, localMutations],
|
|
84
|
+
);
|
|
71
85
|
|
|
72
86
|
const valuesRef = useRef(localState);
|
|
73
87
|
valuesRef.current = localState;
|
|
@@ -131,22 +145,17 @@ defineMacro({
|
|
|
131
145
|
);
|
|
132
146
|
}
|
|
133
147
|
|
|
134
|
-
const content = list.map((item, i) =>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
initialValues={{}}
|
|
146
|
-
children={children}
|
|
147
|
-
/>
|
|
148
|
-
);
|
|
149
|
-
});
|
|
148
|
+
const content = list.map((item, i) => (
|
|
149
|
+
<ForIteration
|
|
150
|
+
key={`${i}-${JSON.stringify(item)}`}
|
|
151
|
+
parentValues={parentValues}
|
|
152
|
+
itemVar={itemVar}
|
|
153
|
+
itemValue={item}
|
|
154
|
+
indexVar={indexVar}
|
|
155
|
+
indexValue={i}
|
|
156
|
+
children={children}
|
|
157
|
+
/>
|
|
158
|
+
));
|
|
150
159
|
|
|
151
160
|
return ctx.wrap(content);
|
|
152
161
|
},
|
|
@@ -15,11 +15,13 @@ defineMacro({
|
|
|
15
15
|
state.deleteVariable(name.slice(1));
|
|
16
16
|
} else if (name.startsWith('_')) {
|
|
17
17
|
state.deleteTemporary(name.slice(1));
|
|
18
|
+
} else if (name.startsWith('%')) {
|
|
19
|
+
state.deleteTransient(name.slice(1));
|
|
18
20
|
} else if (name.startsWith('@')) {
|
|
19
21
|
ctx.update(name.slice(1), undefined);
|
|
20
22
|
} else {
|
|
21
23
|
console.error(
|
|
22
|
-
`spindle: {unset} expects a variable ($name, _name, or @name), got "${name}"`,
|
|
24
|
+
`spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
|
|
23
25
|
);
|
|
24
26
|
}
|
|
25
27
|
}
|
|
@@ -5,7 +5,7 @@ import { useInterpolate } from '../../hooks/use-interpolate';
|
|
|
5
5
|
|
|
6
6
|
interface VarDisplayProps {
|
|
7
7
|
name: string;
|
|
8
|
-
scope: 'variable' | 'temporary' | 'local';
|
|
8
|
+
scope: 'variable' | 'temporary' | 'local' | 'transient';
|
|
9
9
|
className?: string;
|
|
10
10
|
id?: string;
|
|
11
11
|
}
|
|
@@ -22,7 +22,9 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
|
|
|
22
22
|
? s.variables[root]
|
|
23
23
|
: scope === 'temporary'
|
|
24
24
|
? s.temporary[root]
|
|
25
|
-
:
|
|
25
|
+
: scope === 'transient'
|
|
26
|
+
? s.transient[root]
|
|
27
|
+
: undefined,
|
|
26
28
|
);
|
|
27
29
|
|
|
28
30
|
let value: unknown;
|
|
@@ -32,7 +32,8 @@ function isStandaloneValue(token: string): boolean {
|
|
|
32
32
|
// Quoted string
|
|
33
33
|
if (first === '"' || first === "'" || first === '`') return true;
|
|
34
34
|
// Variable ($var, _var, @var)
|
|
35
|
-
if (first === '$' || first === '_' || first === '@'
|
|
35
|
+
if (first === '$' || first === '_' || first === '@' || first === '%')
|
|
36
|
+
return true;
|
|
36
37
|
// Number literal
|
|
37
38
|
if (/\d/.test(first)) return true;
|
|
38
39
|
// Signed number (-1, +2)
|
|
@@ -189,8 +190,10 @@ function WidgetBody({
|
|
|
189
190
|
{},
|
|
190
191
|
);
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
const localState = useMemo(
|
|
194
|
+
() => ({ ...parentValues, ...ownKeys, ...localMutations }),
|
|
195
|
+
[parentValues, ownKeys, localMutations],
|
|
196
|
+
);
|
|
194
197
|
|
|
195
198
|
const valuesRef = useRef(localState);
|
|
196
199
|
valuesRef.current = localState;
|
|
@@ -218,7 +221,8 @@ export function WidgetInvocation({
|
|
|
218
221
|
}: WidgetInvocationProps) {
|
|
219
222
|
const parentValues = useContext(LocalsValuesContext);
|
|
220
223
|
const nobr = useContext(NobrContext);
|
|
221
|
-
const [mergedVars, mergedTemps, mergedLocals] =
|
|
224
|
+
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
|
|
225
|
+
useMergedLocals();
|
|
222
226
|
|
|
223
227
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
224
228
|
|
|
@@ -231,22 +235,38 @@ export function WidgetInvocation({
|
|
|
231
235
|
}
|
|
232
236
|
|
|
233
237
|
const argExprs = splitArgs(rawArgs);
|
|
234
|
-
const
|
|
238
|
+
const values: unknown[] = [];
|
|
235
239
|
|
|
236
240
|
for (let i = 0; i < params.length; i++) {
|
|
237
|
-
const param = params[i]!;
|
|
238
241
|
const expr = argExprs[i];
|
|
239
242
|
let value: unknown;
|
|
240
243
|
if (expr !== undefined) {
|
|
241
244
|
try {
|
|
242
|
-
value = evaluate(
|
|
245
|
+
value = evaluate(
|
|
246
|
+
expr,
|
|
247
|
+
mergedVars,
|
|
248
|
+
mergedTemps,
|
|
249
|
+
mergedLocals,
|
|
250
|
+
mergedTrans,
|
|
251
|
+
);
|
|
243
252
|
} catch {
|
|
244
253
|
value = undefined;
|
|
245
254
|
}
|
|
246
255
|
}
|
|
247
|
-
|
|
256
|
+
values.push(value);
|
|
248
257
|
}
|
|
249
258
|
|
|
259
|
+
const ownKeys = useMemo(() => {
|
|
260
|
+
const keys: Record<string, unknown> = {};
|
|
261
|
+
for (let i = 0; i < params.length; i++) {
|
|
262
|
+
keys[params[i]!.startsWith('@') ? params[i]!.slice(1) : params[i]!] =
|
|
263
|
+
values[i];
|
|
264
|
+
}
|
|
265
|
+
return keys;
|
|
266
|
+
// params is stable per widget instance; values tracks evaluated args
|
|
267
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
268
|
+
}, values);
|
|
269
|
+
|
|
250
270
|
return (
|
|
251
271
|
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
252
272
|
<WidgetBody
|
package/src/define-macro.ts
CHANGED
|
@@ -52,6 +52,7 @@ export interface MacroContext {
|
|
|
52
52
|
Record<string, unknown>,
|
|
53
53
|
Record<string, unknown>,
|
|
54
54
|
Record<string, unknown>,
|
|
55
|
+
Record<string, unknown>,
|
|
55
56
|
];
|
|
56
57
|
varName?: string;
|
|
57
58
|
value?: unknown;
|
|
@@ -178,12 +179,21 @@ export function defineMacro(
|
|
|
178
179
|
ctx.merged = useMergedLocals();
|
|
179
180
|
const merged = ctx.merged;
|
|
180
181
|
ctx.evaluate = (expr: string) =>
|
|
181
|
-
evaluate(expr, merged[0], merged[1], merged[2]);
|
|
182
|
+
evaluate(expr, merged[0], merged[1], merged[2], merged[3]);
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
if (config.storeVar) {
|
|
185
186
|
const firstToken =
|
|
186
187
|
props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, '') ?? '';
|
|
188
|
+
|
|
189
|
+
if (firstToken.startsWith('%')) {
|
|
190
|
+
return h(
|
|
191
|
+
'span',
|
|
192
|
+
{ class: 'error' },
|
|
193
|
+
`{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
187
197
|
const varExpr = firstToken.replace(/["']/g, '').replace(/^\$/, '');
|
|
188
198
|
const segments = varExpr.split('.');
|
|
189
199
|
ctx.varName = varExpr;
|
package/src/execute-mutation.ts
CHANGED
|
@@ -10,9 +10,10 @@ export function executeMutation(
|
|
|
10
10
|
const state = useStoryStore.getState();
|
|
11
11
|
const vars = deepClone(state.variables);
|
|
12
12
|
const temps = deepClone(state.temporary);
|
|
13
|
+
const trans = deepClone(state.transient);
|
|
13
14
|
const localsClone = { ...mergedLocals };
|
|
14
15
|
|
|
15
|
-
execute(code, vars, temps, localsClone);
|
|
16
|
+
execute(code, vars, temps, localsClone, trans);
|
|
16
17
|
|
|
17
18
|
for (const key of Object.keys(vars)) {
|
|
18
19
|
if (vars[key] !== state.variables[key]) {
|
|
@@ -24,6 +25,11 @@ export function executeMutation(
|
|
|
24
25
|
state.setTemporary(key, temps[key]);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
for (const key of Object.keys(trans)) {
|
|
29
|
+
if (trans[key] !== state.transient[key]) {
|
|
30
|
+
state.setTransient(key, trans[key]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
27
33
|
for (const key of Object.keys(localsClone)) {
|
|
28
34
|
if (localsClone[key] !== mergedLocals[key]) {
|
|
29
35
|
scopeUpdate(key, localsClone[key]);
|
|
@@ -41,6 +47,11 @@ export function executeMutation(
|
|
|
41
47
|
state.deleteTemporary(key);
|
|
42
48
|
}
|
|
43
49
|
}
|
|
50
|
+
for (const key of Object.keys(state.transient)) {
|
|
51
|
+
if (!(key in trans)) {
|
|
52
|
+
state.deleteTransient(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
for (const key of Object.keys(mergedLocals)) {
|
|
45
56
|
if (!(key in localsClone)) {
|
|
46
57
|
scopeUpdate(key, undefined);
|
package/src/expression.ts
CHANGED
|
@@ -23,30 +23,34 @@ type CompiledExpression = (
|
|
|
23
23
|
temporary: Record<string, unknown>,
|
|
24
24
|
locals: Record<string, unknown>,
|
|
25
25
|
__fns: ExpressionFns,
|
|
26
|
+
transient: Record<string, unknown>,
|
|
26
27
|
) => unknown;
|
|
27
28
|
|
|
28
29
|
const FN_CACHE_MAX = 500;
|
|
29
30
|
const fnCache = new Map<string, CompiledExpression>();
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
|
-
* Transform expression: $var → variables["var"], _var → temporary["var"]
|
|
33
|
-
*
|
|
33
|
+
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
34
|
+
* @var → locals["var"], %var → transient["var"]
|
|
35
|
+
* Only transforms when sigils appear as a word boundary (not inside strings naively,
|
|
34
36
|
* but authors already have full JS access so this is acceptable).
|
|
35
37
|
*/
|
|
36
38
|
const VAR_RE = /\$(\w+)/g;
|
|
37
39
|
const TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
38
40
|
const LOCAL_RE = /@(\w+)/g;
|
|
41
|
+
const TRANS_RE = /(?<!\w)%(\w+)/g;
|
|
39
42
|
|
|
40
43
|
function transformSegment(segment: string): string {
|
|
41
44
|
return segment
|
|
42
45
|
.replace(VAR_RE, 'variables["$1"]')
|
|
43
46
|
.replace(TEMP_RE, 'temporary["$1"]')
|
|
44
|
-
.replace(LOCAL_RE, 'locals["$1"]')
|
|
47
|
+
.replace(LOCAL_RE, 'locals["$1"]')
|
|
48
|
+
.replace(TRANS_RE, 'transient["$1"]');
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
52
|
* String-aware expression transformer. Walks the expression character by
|
|
49
|
-
* character so that variable sigils ($, _,
|
|
53
|
+
* character so that variable sigils ($, _, @, %) inside string literals are
|
|
50
54
|
* left untouched while code — including expressions inside template-literal
|
|
51
55
|
* `${…}` interpolations — is transformed.
|
|
52
56
|
*/
|
|
@@ -215,6 +219,7 @@ function getOrCompile(key: string, body: string): CompiledExpression {
|
|
|
215
219
|
'temporary',
|
|
216
220
|
'locals',
|
|
217
221
|
'__fns',
|
|
222
|
+
'transient',
|
|
218
223
|
preamble + body,
|
|
219
224
|
) as CompiledExpression;
|
|
220
225
|
fnCache.set(key, fn);
|
|
@@ -298,11 +303,12 @@ export function evaluate(
|
|
|
298
303
|
variables: Record<string, unknown>,
|
|
299
304
|
temporary: Record<string, unknown>,
|
|
300
305
|
locals: Record<string, unknown> = {},
|
|
306
|
+
transient: Record<string, unknown> = {},
|
|
301
307
|
): unknown {
|
|
302
308
|
const transformed = transform(expr);
|
|
303
309
|
const body = `return (${transformed});`;
|
|
304
310
|
const fn = getOrCompile(body, body);
|
|
305
|
-
return fn(variables, temporary, locals, buildExpressionFns());
|
|
311
|
+
return fn(variables, temporary, locals, buildExpressionFns(), transient);
|
|
306
312
|
}
|
|
307
313
|
|
|
308
314
|
/**
|
|
@@ -314,10 +320,11 @@ export function execute(
|
|
|
314
320
|
variables: Record<string, unknown>,
|
|
315
321
|
temporary: Record<string, unknown>,
|
|
316
322
|
locals: Record<string, unknown> = {},
|
|
323
|
+
transient: Record<string, unknown> = {},
|
|
317
324
|
): void {
|
|
318
325
|
const transformed = transform(code);
|
|
319
326
|
const fn = getOrCompile('exec:' + transformed, transformed);
|
|
320
|
-
fn(variables, temporary, locals, buildExpressionFns());
|
|
327
|
+
fn(variables, temporary, locals, buildExpressionFns(), transient);
|
|
321
328
|
}
|
|
322
329
|
|
|
323
330
|
/**
|
|
@@ -332,5 +339,5 @@ export function clearExpressionCache(): void {
|
|
|
332
339
|
}
|
|
333
340
|
|
|
334
341
|
export function evaluateWithState(expr: string, state: StoryState): unknown {
|
|
335
|
-
return evaluate(expr, state.variables, state.temporary, {});
|
|
342
|
+
return evaluate(expr, state.variables, state.temporary, {}, state.transient);
|
|
336
343
|
}
|
|
@@ -5,13 +5,13 @@ import { hasInterpolation, interpolate } from '../interpolation';
|
|
|
5
5
|
export function useInterpolate(): (
|
|
6
6
|
s: string | undefined,
|
|
7
7
|
) => string | undefined {
|
|
8
|
-
const [variables, temporary, locals] = useMergedLocals();
|
|
8
|
+
const [variables, temporary, locals, transient] = useMergedLocals();
|
|
9
9
|
|
|
10
10
|
return useCallback(
|
|
11
11
|
(s: string | undefined): string | undefined => {
|
|
12
12
|
if (s === undefined || !hasInterpolation(s)) return s;
|
|
13
|
-
return interpolate(s, variables, temporary, locals);
|
|
13
|
+
return interpolate(s, variables, temporary, locals, transient);
|
|
14
14
|
},
|
|
15
|
-
[variables, temporary, locals],
|
|
15
|
+
[variables, temporary, locals, transient],
|
|
16
16
|
);
|
|
17
17
|
}
|
|
@@ -3,19 +3,21 @@ import { useStoryStore } from '../store';
|
|
|
3
3
|
import { LocalsValuesContext } from '../markup/render';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Return store variables, temporary,
|
|
7
|
-
* All
|
|
6
|
+
* Return store variables, temporary, locals from context, and transient.
|
|
7
|
+
* All four dicts use unprefixed keys suitable for evaluate/execute.
|
|
8
8
|
*/
|
|
9
9
|
export function useMergedLocals(): readonly [
|
|
10
10
|
Record<string, unknown>,
|
|
11
11
|
Record<string, unknown>,
|
|
12
12
|
Record<string, unknown>,
|
|
13
|
+
Record<string, unknown>,
|
|
13
14
|
] {
|
|
14
15
|
const variables = useStoryStore((s) => s.variables);
|
|
15
16
|
const temporary = useStoryStore((s) => s.temporary);
|
|
17
|
+
const transient = useStoryStore((s) => s.transient);
|
|
16
18
|
const localsValues = useContext(LocalsValuesContext);
|
|
17
19
|
|
|
18
20
|
return useMemo(() => {
|
|
19
|
-
return [variables, temporary, localsValues] as const;
|
|
20
|
-
}, [variables, temporary, localsValues]);
|
|
21
|
+
return [variables, temporary, localsValues, transient] as const;
|
|
22
|
+
}, [variables, temporary, localsValues, transient]);
|
|
21
23
|
}
|
package/src/index.tsx
CHANGED
|
@@ -88,6 +88,27 @@ function boot() {
|
|
|
88
88
|
const schema = parseStoryVariables(storyVarsPassage.content);
|
|
89
89
|
const errors = validatePassages(storyData.passages, schema);
|
|
90
90
|
|
|
91
|
+
// Parse StoryTransients (optional — no error if missing)
|
|
92
|
+
let transientDefaults: Record<string, unknown> = {};
|
|
93
|
+
const storyTransientsPassage = storyData.passages.get('StoryTransients');
|
|
94
|
+
if (storyTransientsPassage) {
|
|
95
|
+
const transientSchema = parseStoryVariables(
|
|
96
|
+
storyTransientsPassage.content,
|
|
97
|
+
'%',
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Check for cross-scope name collisions
|
|
101
|
+
for (const name of transientSchema.keys()) {
|
|
102
|
+
if (schema.has(name)) {
|
|
103
|
+
errors.push(
|
|
104
|
+
`StoryTransients: Variable "${name}" is already declared in StoryVariables. Names must be unique across scopes.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
transientDefaults = extractDefaults(transientSchema);
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
if (errors.length > 0) {
|
|
92
113
|
const root = document.getElementById('root');
|
|
93
114
|
if (root) renderErrors(root, errors);
|
|
@@ -98,7 +119,7 @@ function boot() {
|
|
|
98
119
|
|
|
99
120
|
defaults = extractDefaults(schema);
|
|
100
121
|
|
|
101
|
-
useStoryStore.getState().init(storyData, defaults);
|
|
122
|
+
useStoryStore.getState().init(storyData, defaults, transientDefaults);
|
|
102
123
|
|
|
103
124
|
// Enter runtime phase — handlers registered from here on are cleaned on restart
|
|
104
125
|
enterRuntimePhase();
|
package/src/interpolation.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { evaluate } from './expression';
|
|
2
2
|
|
|
3
|
-
/** Detects any {…} block that starts with a sigil ($, _,
|
|
4
|
-
const INTERP_TEST = /\{[\$_
|
|
3
|
+
/** Detects any {…} block that starts with a sigil ($, _, @, %). */
|
|
4
|
+
const INTERP_TEST = /\{[\$_@%]\w/;
|
|
5
5
|
|
|
6
6
|
export function hasInterpolation(s: string): boolean {
|
|
7
7
|
return INTERP_TEST.test(s);
|
|
@@ -25,8 +25,9 @@ export function interpolateExpression(
|
|
|
25
25
|
variables: Record<string, unknown>,
|
|
26
26
|
temporary: Record<string, unknown>,
|
|
27
27
|
locals: Record<string, unknown>,
|
|
28
|
+
transient: Record<string, unknown> = {},
|
|
28
29
|
): string {
|
|
29
|
-
const value = evaluate(expr, variables, temporary, locals);
|
|
30
|
+
const value = evaluate(expr, variables, temporary, locals, transient);
|
|
30
31
|
return value == null ? '' : String(value);
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -35,6 +36,7 @@ function resolveSimple(
|
|
|
35
36
|
variables: Record<string, unknown>,
|
|
36
37
|
temporary: Record<string, unknown>,
|
|
37
38
|
locals: Record<string, unknown>,
|
|
39
|
+
transient: Record<string, unknown>,
|
|
38
40
|
): string {
|
|
39
41
|
const prefix = ref[0]!;
|
|
40
42
|
const path = ref.slice(1);
|
|
@@ -46,6 +48,8 @@ function resolveSimple(
|
|
|
46
48
|
value = variables[root];
|
|
47
49
|
} else if (prefix === '_') {
|
|
48
50
|
value = temporary[root];
|
|
51
|
+
} else if (prefix === '%') {
|
|
52
|
+
value = transient[root];
|
|
49
53
|
} else {
|
|
50
54
|
value = locals[root];
|
|
51
55
|
}
|
|
@@ -62,6 +66,7 @@ export function interpolate(
|
|
|
62
66
|
variables: Record<string, unknown>,
|
|
63
67
|
temporary: Record<string, unknown>,
|
|
64
68
|
locals: Record<string, unknown>,
|
|
69
|
+
transient: Record<string, unknown> = {},
|
|
65
70
|
): string {
|
|
66
71
|
// Manual scan: process {…} blocks containing sigils.
|
|
67
72
|
// Simple dot-path refs use the fast resolver; everything else falls back
|
|
@@ -86,7 +91,7 @@ export function interpolate(
|
|
|
86
91
|
i++; // skip {
|
|
87
92
|
|
|
88
93
|
const sigil = template[i];
|
|
89
|
-
if (sigil !== '$' && sigil !== '_' && sigil !== '@') {
|
|
94
|
+
if (sigil !== '$' && sigil !== '_' && sigil !== '@' && sigil !== '%') {
|
|
90
95
|
// Not an interpolation — emit the { as text
|
|
91
96
|
result += '{';
|
|
92
97
|
continue;
|
|
@@ -111,10 +116,16 @@ export function interpolate(
|
|
|
111
116
|
i = j + 1; // skip past closing }
|
|
112
117
|
|
|
113
118
|
// Try simple dot-path match first
|
|
114
|
-
if (/^[\$_
|
|
115
|
-
result += resolveSimple(inner, variables, temporary, locals);
|
|
119
|
+
if (/^[\$_@%][\w.]+$/.test(inner)) {
|
|
120
|
+
result += resolveSimple(inner, variables, temporary, locals, transient);
|
|
116
121
|
} else {
|
|
117
|
-
result += interpolateExpression(
|
|
122
|
+
result += interpolateExpression(
|
|
123
|
+
inner,
|
|
124
|
+
variables,
|
|
125
|
+
temporary,
|
|
126
|
+
locals,
|
|
127
|
+
transient,
|
|
128
|
+
);
|
|
118
129
|
}
|
|
119
130
|
}
|
|
120
131
|
|
package/src/markup/ast.ts
CHANGED
package/src/markup/render.tsx
CHANGED
|
@@ -243,6 +243,7 @@ function getVariableTextValue(
|
|
|
243
243
|
let value: unknown;
|
|
244
244
|
if (node.scope === 'variable') value = state.variables[root];
|
|
245
245
|
else if (node.scope === 'temporary') value = state.temporary[root];
|
|
246
|
+
else if (node.scope === 'transient') value = state.transient[root];
|
|
246
247
|
else value = locals[root];
|
|
247
248
|
|
|
248
249
|
for (let i = 1; i < parts.length; i++) {
|