@rohal12/spindle 0.41.0 → 0.43.0
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 +6 -3
- package/src/components/macros/ExprDisplay.tsx +8 -1
- package/src/components/macros/Unset.tsx +3 -1
- package/src/components/macros/VarDisplay.tsx +4 -2
- package/src/components/macros/WidgetInvocation.tsx +11 -3
- 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/src/types-drift-check.ts +15 -0
- package/types/index.d.ts +344 -7
package/package.json
CHANGED
|
@@ -61,11 +61,12 @@ 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,
|
|
65
66
|
): void {
|
|
66
67
|
let newValue: unknown;
|
|
67
68
|
try {
|
|
68
|
-
newValue = evaluate(expr, variables, temporary, locals);
|
|
69
|
+
newValue = evaluate(expr, variables, temporary, locals, transient);
|
|
69
70
|
} catch (err) {
|
|
70
71
|
console.error(
|
|
71
72
|
`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
|
|
@@ -86,7 +87,7 @@ defineMacro({
|
|
|
86
87
|
name: 'computed',
|
|
87
88
|
merged: true,
|
|
88
89
|
render({ rawArgs }, ctx) {
|
|
89
|
-
const [mergedVars, mergedTemps, mergedLocals] = ctx.merged!;
|
|
90
|
+
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
|
|
90
91
|
|
|
91
92
|
let target: string;
|
|
92
93
|
let expr: string;
|
|
@@ -113,6 +114,7 @@ defineMacro({
|
|
|
113
114
|
mergedVars,
|
|
114
115
|
mergedTemps,
|
|
115
116
|
mergedLocals,
|
|
117
|
+
mergedTrans,
|
|
116
118
|
rawArgs,
|
|
117
119
|
);
|
|
118
120
|
}
|
|
@@ -125,9 +127,10 @@ defineMacro({
|
|
|
125
127
|
mergedVars,
|
|
126
128
|
mergedTemps,
|
|
127
129
|
mergedLocals,
|
|
130
|
+
mergedTrans,
|
|
128
131
|
rawArgs,
|
|
129
132
|
);
|
|
130
|
-
}, [mergedVars, mergedTemps, mergedLocals]);
|
|
133
|
+
}, [mergedVars, mergedTemps, mergedLocals, mergedTrans]);
|
|
131
134
|
|
|
132
135
|
return null;
|
|
133
136
|
},
|
|
@@ -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}}`;
|
|
@@ -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)
|
|
@@ -218,7 +219,8 @@ export function WidgetInvocation({
|
|
|
218
219
|
}: WidgetInvocationProps) {
|
|
219
220
|
const parentValues = useContext(LocalsValuesContext);
|
|
220
221
|
const nobr = useContext(NobrContext);
|
|
221
|
-
const [mergedVars, mergedTemps, mergedLocals] =
|
|
222
|
+
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
|
|
223
|
+
useMergedLocals();
|
|
222
224
|
|
|
223
225
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
224
226
|
|
|
@@ -239,7 +241,13 @@ export function WidgetInvocation({
|
|
|
239
241
|
let value: unknown;
|
|
240
242
|
if (expr !== undefined) {
|
|
241
243
|
try {
|
|
242
|
-
value = evaluate(
|
|
244
|
+
value = evaluate(
|
|
245
|
+
expr,
|
|
246
|
+
mergedVars,
|
|
247
|
+
mergedTemps,
|
|
248
|
+
mergedLocals,
|
|
249
|
+
mergedTrans,
|
|
250
|
+
);
|
|
243
251
|
} catch {
|
|
244
252
|
value = undefined;
|
|
245
253
|
}
|
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++) {
|
package/src/markup/tokenizer.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface MacroToken {
|
|
|
29
29
|
export interface VariableToken {
|
|
30
30
|
type: 'variable';
|
|
31
31
|
name: string;
|
|
32
|
-
scope: 'variable' | 'temporary' | 'local';
|
|
32
|
+
scope: 'variable' | 'temporary' | 'local' | 'transient';
|
|
33
33
|
className?: string;
|
|
34
34
|
id?: string;
|
|
35
35
|
start: number;
|
|
@@ -499,6 +499,51 @@ export function tokenize(input: string): Token[] {
|
|
|
499
499
|
continue;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
if (charAfter === '%') {
|
|
503
|
+
// {.class#id %transient.field} or {.class %expr[...]}
|
|
504
|
+
i = afterSelectors + 1;
|
|
505
|
+
const nameStart = i;
|
|
506
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
507
|
+
const name = input.slice(nameStart, i);
|
|
508
|
+
|
|
509
|
+
if (input[i] === '}') {
|
|
510
|
+
i++; // skip }
|
|
511
|
+
const token: VariableToken = {
|
|
512
|
+
type: 'variable',
|
|
513
|
+
name,
|
|
514
|
+
scope: 'transient',
|
|
515
|
+
start,
|
|
516
|
+
end: i,
|
|
517
|
+
};
|
|
518
|
+
if (className) token.className = className;
|
|
519
|
+
if (id) token.id = id;
|
|
520
|
+
tokens.push(token);
|
|
521
|
+
textStart = i;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Complex expression — scan for balanced closing }
|
|
525
|
+
const closeIdx_pct = scanBalancedBrace(input, nameStart);
|
|
526
|
+
if (closeIdx_pct !== -1) {
|
|
527
|
+
const expression = input.slice(afterSelectors, closeIdx_pct);
|
|
528
|
+
i = closeIdx_pct + 1;
|
|
529
|
+
const token: ExpressionToken = {
|
|
530
|
+
type: 'expression',
|
|
531
|
+
expression,
|
|
532
|
+
start,
|
|
533
|
+
end: i,
|
|
534
|
+
};
|
|
535
|
+
if (className) token.className = className;
|
|
536
|
+
if (id) token.id = id;
|
|
537
|
+
tokens.push(token);
|
|
538
|
+
textStart = i;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
// Unbalanced — treat as text
|
|
542
|
+
i = start + 1;
|
|
543
|
+
textStart = start;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
502
547
|
if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
|
|
503
548
|
// {.class#id macroName args}
|
|
504
549
|
i = afterSelectors;
|
|
@@ -663,6 +708,46 @@ export function tokenize(input: string): Token[] {
|
|
|
663
708
|
continue;
|
|
664
709
|
}
|
|
665
710
|
|
|
711
|
+
// {%transient.field} or {%expr[...]}
|
|
712
|
+
if (nextChar === '%') {
|
|
713
|
+
flushText(i);
|
|
714
|
+
i += 2;
|
|
715
|
+
const nameStart = i;
|
|
716
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
717
|
+
const name = input.slice(nameStart, i);
|
|
718
|
+
|
|
719
|
+
if (input[i] === '}') {
|
|
720
|
+
i++; // skip }
|
|
721
|
+
tokens.push({
|
|
722
|
+
type: 'variable',
|
|
723
|
+
name,
|
|
724
|
+
scope: 'transient',
|
|
725
|
+
start,
|
|
726
|
+
end: i,
|
|
727
|
+
});
|
|
728
|
+
textStart = i;
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
// Complex expression — scan for balanced closing }
|
|
732
|
+
const closeIdx = scanBalancedBrace(input, nameStart);
|
|
733
|
+
if (closeIdx !== -1) {
|
|
734
|
+
const expression = input.slice(start + 1, closeIdx);
|
|
735
|
+
i = closeIdx + 1;
|
|
736
|
+
tokens.push({
|
|
737
|
+
type: 'expression',
|
|
738
|
+
expression,
|
|
739
|
+
start,
|
|
740
|
+
end: i,
|
|
741
|
+
});
|
|
742
|
+
textStart = i;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
// Unbalanced — treat as text
|
|
746
|
+
i = start + 1;
|
|
747
|
+
textStart = start;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
|
|
666
751
|
// {macro ...} or {/macro} — but not bare { that's just text
|
|
667
752
|
// Must start with a letter or /
|
|
668
753
|
if (
|
package/src/store.ts
CHANGED
|
@@ -42,6 +42,7 @@ const SPECIAL_PASSAGES = new Set([
|
|
|
42
42
|
'StoryInit',
|
|
43
43
|
'StoryInterface',
|
|
44
44
|
'StoryVariables',
|
|
45
|
+
'StoryTransients',
|
|
45
46
|
'StoryLoading',
|
|
46
47
|
'SaveTitle',
|
|
47
48
|
'PassageReady',
|
|
@@ -237,6 +238,8 @@ export interface StoryState {
|
|
|
237
238
|
currentPassage: string;
|
|
238
239
|
variables: Record<string, unknown>;
|
|
239
240
|
variableDefaults: Record<string, unknown>;
|
|
241
|
+
transient: Record<string, unknown>;
|
|
242
|
+
transientDefaults: Record<string, unknown>;
|
|
240
243
|
temporary: Record<string, unknown>;
|
|
241
244
|
history: HistoryMoment[];
|
|
242
245
|
historyIndex: number;
|
|
@@ -256,6 +259,7 @@ export interface StoryState {
|
|
|
256
259
|
init: (
|
|
257
260
|
storyData: StoryData,
|
|
258
261
|
variableDefaults?: Record<string, unknown>,
|
|
262
|
+
transientDefaults?: Record<string, unknown>,
|
|
259
263
|
) => void;
|
|
260
264
|
navigate: (passageName: string) => void;
|
|
261
265
|
goBack: () => void;
|
|
@@ -264,6 +268,8 @@ export interface StoryState {
|
|
|
264
268
|
setTemporary: (name: string, value: unknown) => void;
|
|
265
269
|
deleteVariable: (name: string) => void;
|
|
266
270
|
deleteTemporary: (name: string) => void;
|
|
271
|
+
setTransient: (name: string, value: unknown) => void;
|
|
272
|
+
deleteTransient: (name: string) => void;
|
|
267
273
|
trackRender: (passageName: string) => void;
|
|
268
274
|
restart: () => void;
|
|
269
275
|
save: (slot?: string, custom?: Record<string, unknown>) => void;
|
|
@@ -291,6 +297,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
291
297
|
currentPassage: '',
|
|
292
298
|
variables: {},
|
|
293
299
|
variableDefaults: {},
|
|
300
|
+
transient: {},
|
|
301
|
+
transientDefaults: {},
|
|
294
302
|
temporary: {},
|
|
295
303
|
history: [],
|
|
296
304
|
historyIndex: -1,
|
|
@@ -315,6 +323,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
315
323
|
init: (
|
|
316
324
|
storyData: StoryData,
|
|
317
325
|
variableDefaults: Record<string, unknown> = {},
|
|
326
|
+
transientDefaults: Record<string, unknown> = {},
|
|
318
327
|
) => {
|
|
319
328
|
const startPassage = storyData.passagesById.get(storyData.startNode);
|
|
320
329
|
if (!startPassage) {
|
|
@@ -331,6 +340,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
331
340
|
state.currentPassage = startPassage.name;
|
|
332
341
|
state.variables = initialVars;
|
|
333
342
|
state.variableDefaults = variableDefaults;
|
|
343
|
+
state.transient = deepClone(transientDefaults);
|
|
344
|
+
state.transientDefaults = transientDefaults;
|
|
334
345
|
state.temporary = {};
|
|
335
346
|
state.history = [
|
|
336
347
|
{
|
|
@@ -516,6 +527,18 @@ export const useStoryStore = create<StoryState>()(
|
|
|
516
527
|
});
|
|
517
528
|
},
|
|
518
529
|
|
|
530
|
+
setTransient: (name: string, value: unknown) => {
|
|
531
|
+
set((state) => {
|
|
532
|
+
state.transient[name] = value;
|
|
533
|
+
});
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
deleteTransient: (name: string) => {
|
|
537
|
+
set((state) => {
|
|
538
|
+
delete state.transient[name];
|
|
539
|
+
});
|
|
540
|
+
},
|
|
541
|
+
|
|
519
542
|
trackRender: (passageName: string) => {
|
|
520
543
|
set((state) => {
|
|
521
544
|
state.renderCounts[passageName] =
|
|
@@ -524,7 +547,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
524
547
|
},
|
|
525
548
|
|
|
526
549
|
restart: () => {
|
|
527
|
-
const { storyData, variableDefaults } = get();
|
|
550
|
+
const { storyData, variableDefaults, transientDefaults } = get();
|
|
528
551
|
if (!storyData) return;
|
|
529
552
|
|
|
530
553
|
const startPassage = storyData.passagesById.get(storyData.startNode);
|
|
@@ -551,6 +574,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
551
574
|
set((state) => {
|
|
552
575
|
state.currentPassage = startPassage.name;
|
|
553
576
|
state.variables = initialVars;
|
|
577
|
+
state.transient = deepClone(transientDefaults);
|
|
554
578
|
state.temporary = {};
|
|
555
579
|
state.history = [
|
|
556
580
|
{
|
|
@@ -804,6 +828,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
804
828
|
state.visitCounts = payload.visitCounts ?? {};
|
|
805
829
|
state.renderCounts = payload.renderCounts ?? {};
|
|
806
830
|
state.temporary = {};
|
|
831
|
+
state.transient = deepClone(get().transientDefaults);
|
|
807
832
|
});
|
|
808
833
|
|
|
809
834
|
lastNavigationVars = get().variables;
|