@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/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;
|
package/src/story-api.ts
CHANGED
|
@@ -76,20 +76,37 @@ function ensureVariableChangedSubscription(): void {
|
|
|
76
76
|
if (variableChangedSubActive) return;
|
|
77
77
|
variableChangedSubActive = true;
|
|
78
78
|
let prevVars = { ...useStoryStore.getState().variables };
|
|
79
|
+
let prevTrans = { ...useStoryStore.getState().transient };
|
|
79
80
|
useStoryStore.subscribe((state) => {
|
|
80
81
|
const changed: Record<string, { from: unknown; to: unknown }> = {};
|
|
81
82
|
let hasChanges = false;
|
|
82
|
-
|
|
83
|
+
|
|
84
|
+
// Check $variables
|
|
85
|
+
const allVarKeys = new Set([
|
|
83
86
|
...Object.keys(prevVars),
|
|
84
87
|
...Object.keys(state.variables),
|
|
85
88
|
]);
|
|
86
|
-
for (const key of
|
|
89
|
+
for (const key of allVarKeys) {
|
|
87
90
|
if (state.variables[key] !== prevVars[key]) {
|
|
88
91
|
changed[key] = { from: prevVars[key], to: state.variables[key] };
|
|
89
92
|
hasChanges = true;
|
|
90
93
|
}
|
|
91
94
|
}
|
|
95
|
+
|
|
96
|
+
// Check %transient
|
|
97
|
+
const allTransKeys = new Set([
|
|
98
|
+
...Object.keys(prevTrans),
|
|
99
|
+
...Object.keys(state.transient),
|
|
100
|
+
]);
|
|
101
|
+
for (const key of allTransKeys) {
|
|
102
|
+
if (state.transient[key] !== prevTrans[key]) {
|
|
103
|
+
changed[`%${key}`] = { from: prevTrans[key], to: state.transient[key] };
|
|
104
|
+
hasChanges = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
prevVars = { ...state.variables };
|
|
109
|
+
prevTrans = { ...state.transient };
|
|
93
110
|
if (hasChanges) {
|
|
94
111
|
emit('variableChanged', changed);
|
|
95
112
|
}
|
|
@@ -178,16 +195,27 @@ export interface StoryAPI {
|
|
|
178
195
|
function createStoryAPI(): StoryAPI {
|
|
179
196
|
return {
|
|
180
197
|
get(name: string): unknown {
|
|
198
|
+
if (name.startsWith('%')) {
|
|
199
|
+
return useStoryStore.getState().transient[name.slice(1)];
|
|
200
|
+
}
|
|
181
201
|
return useStoryStore.getState().variables[name];
|
|
182
202
|
},
|
|
183
203
|
|
|
184
204
|
set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
|
|
185
205
|
const state = useStoryStore.getState();
|
|
186
206
|
if (typeof nameOrVars === 'string') {
|
|
187
|
-
|
|
207
|
+
if (nameOrVars.startsWith('%')) {
|
|
208
|
+
state.setTransient(nameOrVars.slice(1), value);
|
|
209
|
+
} else {
|
|
210
|
+
state.setVariable(nameOrVars, value);
|
|
211
|
+
}
|
|
188
212
|
} else {
|
|
189
213
|
for (const [k, v] of Object.entries(nameOrVars)) {
|
|
190
|
-
|
|
214
|
+
if (k.startsWith('%')) {
|
|
215
|
+
state.setTransient(k.slice(1), v);
|
|
216
|
+
} else {
|
|
217
|
+
state.setVariable(k, v);
|
|
218
|
+
}
|
|
191
219
|
}
|
|
192
220
|
}
|
|
193
221
|
},
|
package/src/story-variables.ts
CHANGED
|
@@ -12,7 +12,10 @@ export interface VariableSchema extends FieldSchema {
|
|
|
12
12
|
default: unknown;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
function declarationRegex(sigil: string): RegExp {
|
|
16
|
+
const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
|
|
18
|
+
}
|
|
16
19
|
const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
|
|
17
20
|
const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
18
21
|
|
|
@@ -32,20 +35,23 @@ function inferSchema(value: unknown): FieldSchema {
|
|
|
32
35
|
const jsType = typeof value;
|
|
33
36
|
if (!VALID_VAR_TYPES.has(jsType)) {
|
|
34
37
|
throw new Error(
|
|
35
|
-
`
|
|
38
|
+
`Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`,
|
|
36
39
|
);
|
|
37
40
|
}
|
|
38
41
|
return { type: jsType as VarType };
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
|
-
* Parse a StoryVariables passage content into a schema map.
|
|
43
|
-
* Each line: `$varName = expression`
|
|
45
|
+
* Parse a StoryVariables or StoryTransients passage content into a schema map.
|
|
46
|
+
* Each line: `$varName = expression` (or `%varName = expression` for transients)
|
|
44
47
|
*/
|
|
45
48
|
export function parseStoryVariables(
|
|
46
49
|
content: string,
|
|
50
|
+
sigil: '$' | '%' = '$',
|
|
47
51
|
): Map<string, VariableSchema> {
|
|
48
52
|
const schema = new Map<string, VariableSchema>();
|
|
53
|
+
const DECLARATION_RE = declarationRegex(sigil);
|
|
54
|
+
const passageName = sigil === '%' ? 'StoryTransients' : 'StoryVariables';
|
|
49
55
|
|
|
50
56
|
for (const rawLine of content.split('\n')) {
|
|
51
57
|
const line = rawLine.trim();
|
|
@@ -54,7 +60,7 @@ export function parseStoryVariables(
|
|
|
54
60
|
const match = line.match(DECLARATION_RE);
|
|
55
61
|
if (!match) {
|
|
56
62
|
throw new Error(
|
|
57
|
-
|
|
63
|
+
`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`,
|
|
58
64
|
);
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -64,11 +70,18 @@ export function parseStoryVariables(
|
|
|
64
70
|
value = new Function('return (' + expr + ')')();
|
|
65
71
|
} catch (err) {
|
|
66
72
|
throw new Error(
|
|
67
|
-
|
|
73
|
+
`${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`,
|
|
68
74
|
);
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
let fieldSchema: FieldSchema;
|
|
78
|
+
try {
|
|
79
|
+
fieldSchema = inferSchema(value);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${passageName}: ${err instanceof Error ? err.message : err}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
72
85
|
schema.set(name, { ...fieldSchema, name, default: value });
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -146,8 +159,8 @@ export function validatePassages(
|
|
|
146
159
|
const errors: string[] = [];
|
|
147
160
|
|
|
148
161
|
for (const [name, passage] of passages) {
|
|
149
|
-
// Don't validate the StoryVariables
|
|
150
|
-
if (name === 'StoryVariables') continue;
|
|
162
|
+
// Don't validate the StoryVariables/StoryTransients passages themselves
|
|
163
|
+
if (name === 'StoryVariables' || name === 'StoryTransients') continue;
|
|
151
164
|
|
|
152
165
|
const forLocals = extractForLocals(passage.content);
|
|
153
166
|
|
package/src/triggers.ts
CHANGED
|
@@ -45,7 +45,13 @@ let dialogHostCallbacks: DialogHostCallbacks | null = null;
|
|
|
45
45
|
function evalCondition(condition: string): boolean {
|
|
46
46
|
const state = useStoryStore.getState();
|
|
47
47
|
try {
|
|
48
|
-
return !!evaluate(
|
|
48
|
+
return !!evaluate(
|
|
49
|
+
condition,
|
|
50
|
+
state.variables,
|
|
51
|
+
state.temporary,
|
|
52
|
+
{},
|
|
53
|
+
state.transient,
|
|
54
|
+
);
|
|
49
55
|
} catch {
|
|
50
56
|
return false;
|
|
51
57
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -294,6 +294,7 @@ export interface MacroContext {
|
|
|
294
294
|
Record<string, unknown>,
|
|
295
295
|
Record<string, unknown>,
|
|
296
296
|
Record<string, unknown>,
|
|
297
|
+
Record<string, unknown>,
|
|
297
298
|
];
|
|
298
299
|
varName?: string;
|
|
299
300
|
value?: unknown;
|
|
@@ -364,12 +365,20 @@ export interface SaveInfo {
|
|
|
364
365
|
* @see {@link ../../src/story-api.ts} for the implementation.
|
|
365
366
|
*/
|
|
366
367
|
export interface StoryAPI {
|
|
367
|
-
/**
|
|
368
|
+
/**
|
|
369
|
+
* Get a variable value. Use '%name' prefix for transient variables.
|
|
370
|
+
* @example Story.get('health') // $health
|
|
371
|
+
* @example Story.get('%npcList') // %npcList (transient)
|
|
372
|
+
*/
|
|
368
373
|
get(name: string): unknown;
|
|
369
374
|
|
|
370
|
-
/**
|
|
375
|
+
/**
|
|
376
|
+
* Set one or more variables. Use '%name' prefix for transient variables.
|
|
377
|
+
* @example Story.set('health', 100)
|
|
378
|
+
* @example Story.set('%npcList', [...])
|
|
379
|
+
* @example Story.set({ health: 100, '%npcList': [...] })
|
|
380
|
+
*/
|
|
371
381
|
set(name: string, value: unknown): void;
|
|
372
|
-
/** Set multiple story variables at once. */
|
|
373
382
|
set(vars: Record<string, unknown>): void;
|
|
374
383
|
|
|
375
384
|
/** Navigate to a passage by name. */
|