@rohal12/spindle 0.29.0 → 0.29.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/class-registry.ts +17 -2
- package/src/components/Passage.tsx +2 -1
- package/src/components/PassageDialog.tsx +5 -1
- package/src/components/macros/Button.tsx +12 -5
- package/src/components/macros/Computed.tsx +4 -5
- package/src/components/macros/For.tsx +7 -6
- package/src/components/macros/MacroLink.tsx +15 -7
- package/src/components/macros/SaveManager.tsx +4 -0
- package/src/components/macros/Type.tsx +3 -1
- package/src/components/macros/Widget.tsx +4 -1
- package/src/expression.ts +51 -0
- package/src/markup/markdown.ts +5 -1
- package/src/markup/render.tsx +1 -1
- package/src/saves/types.ts +3 -2
- package/src/store.ts +4 -0
- package/src/story-init.ts +5 -1
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -99,12 +99,18 @@ export function serialize<T>(value: T): T {
|
|
|
99
99
|
|
|
100
100
|
if (val instanceof Date) {
|
|
101
101
|
seen.delete(obj);
|
|
102
|
-
return
|
|
102
|
+
return {
|
|
103
|
+
[CLASS_TAG]: '__Date__',
|
|
104
|
+
[DATA_TAG]: { iso: val.toISOString() },
|
|
105
|
+
};
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
if (val instanceof RegExp) {
|
|
106
109
|
seen.delete(obj);
|
|
107
|
-
return
|
|
110
|
+
return {
|
|
111
|
+
[CLASS_TAG]: '__RegExp__',
|
|
112
|
+
[DATA_TAG]: { source: val.source, flags: val.flags },
|
|
113
|
+
};
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
if (Array.isArray(val)) {
|
|
@@ -153,6 +159,15 @@ export function deserialize<T>(value: T): T {
|
|
|
153
159
|
if (CLASS_TAG in obj && DATA_TAG in obj) {
|
|
154
160
|
const name = obj[CLASS_TAG] as string;
|
|
155
161
|
const data = obj[DATA_TAG] as Record<string, unknown>;
|
|
162
|
+
|
|
163
|
+
// Built-in types
|
|
164
|
+
if (name === '__Date__') {
|
|
165
|
+
return new Date(data.iso as string);
|
|
166
|
+
}
|
|
167
|
+
if (name === '__RegExp__') {
|
|
168
|
+
return new RegExp(data.source as string, data.flags as string);
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
const ctor = registry.get(name);
|
|
157
172
|
if (!ctor) {
|
|
158
173
|
console.warn(
|
|
@@ -37,7 +37,8 @@ export function Passage({ passage, dataTransition }: PassageProps) {
|
|
|
37
37
|
return (
|
|
38
38
|
<div class="error">
|
|
39
39
|
Error parsing passage “{passage.name}”
|
|
40
|
-
{sourceLocationOf(passage)}:
|
|
40
|
+
{sourceLocationOf(passage)}:{' '}
|
|
41
|
+
{err instanceof Error ? err.message : String(err)}
|
|
41
42
|
</div>
|
|
42
43
|
);
|
|
43
44
|
}
|
|
@@ -38,7 +38,11 @@ export function PassageDialog({
|
|
|
38
38
|
const ast = buildAST(tokens);
|
|
39
39
|
return renderNodes(ast);
|
|
40
40
|
} catch (err) {
|
|
41
|
-
return
|
|
41
|
+
return (
|
|
42
|
+
<div class="error">
|
|
43
|
+
Error in dialog: {err instanceof Error ? err.message : String(err)}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
42
46
|
}
|
|
43
47
|
}, [markup]);
|
|
44
48
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
2
3
|
import { defineMacro } from '../../define-macro';
|
|
3
4
|
import {
|
|
4
5
|
renderNodes,
|
|
5
6
|
LocalsUpdateContext,
|
|
6
7
|
LocalsValuesContext,
|
|
8
|
+
NobrContext,
|
|
7
9
|
} from '../../markup/render';
|
|
8
10
|
|
|
9
11
|
defineMacro({
|
|
@@ -11,6 +13,7 @@ defineMacro({
|
|
|
11
13
|
interpolate: true,
|
|
12
14
|
render({ rawArgs, children = [] }, ctx) {
|
|
13
15
|
const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
|
|
16
|
+
const nobr = useContext(NobrContext);
|
|
14
17
|
|
|
15
18
|
const handleClick = () => {
|
|
16
19
|
// Render children into a detached DOM node — all macro side effects
|
|
@@ -18,12 +21,16 @@ defineMacro({
|
|
|
18
21
|
// Wrap with locals context so @local variables from for-loops are available.
|
|
19
22
|
const container = document.createElement('div');
|
|
20
23
|
const vnode = h(
|
|
21
|
-
|
|
22
|
-
{ value:
|
|
24
|
+
NobrContext.Provider,
|
|
25
|
+
{ value: nobr },
|
|
23
26
|
h(
|
|
24
|
-
|
|
25
|
-
{ value: ctx.getValues
|
|
26
|
-
|
|
27
|
+
LocalsUpdateContext.Provider,
|
|
28
|
+
{ value: { update: ctx.update, getValues: ctx.getValues } },
|
|
29
|
+
h(
|
|
30
|
+
LocalsValuesContext.Provider,
|
|
31
|
+
{ value: ctx.getValues() },
|
|
32
|
+
renderNodes(children),
|
|
33
|
+
),
|
|
27
34
|
),
|
|
28
35
|
);
|
|
29
36
|
render(vnode, container);
|
|
@@ -63,7 +63,6 @@ function computeAndApply(
|
|
|
63
63
|
locals: Record<string, unknown>,
|
|
64
64
|
rawArgs: string,
|
|
65
65
|
): void {
|
|
66
|
-
const state = useStoryStore.getState();
|
|
67
66
|
let newValue: unknown;
|
|
68
67
|
try {
|
|
69
68
|
newValue = evaluate(expr, variables, temporary, locals);
|
|
@@ -75,8 +74,9 @@ function computeAndApply(
|
|
|
75
74
|
return;
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
const current = isTemp ?
|
|
77
|
+
const current = isTemp ? temporary[name] : variables[name];
|
|
79
78
|
if (!valuesEqual(current, newValue)) {
|
|
79
|
+
const state = useStoryStore.getState();
|
|
80
80
|
if (isTemp) state.setTemporary(name, newValue);
|
|
81
81
|
else state.setVariable(name, newValue);
|
|
82
82
|
}
|
|
@@ -106,13 +106,12 @@ defineMacro({
|
|
|
106
106
|
const ran = ctx.hooks.useRef(false);
|
|
107
107
|
if (!ran.current) {
|
|
108
108
|
ran.current = true;
|
|
109
|
-
const state = useStoryStore.getState();
|
|
110
109
|
computeAndApply(
|
|
111
110
|
expr,
|
|
112
111
|
name,
|
|
113
112
|
isTemp,
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
mergedVars,
|
|
114
|
+
mergedTemps,
|
|
116
115
|
mergedLocals,
|
|
117
116
|
rawArgs,
|
|
118
117
|
);
|
|
@@ -62,18 +62,19 @@ function ForIteration({
|
|
|
62
62
|
initialValues: Record<string, unknown>;
|
|
63
63
|
children: ASTNode[];
|
|
64
64
|
}) {
|
|
65
|
-
const [
|
|
66
|
-
...
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
|
|
66
|
+
() => ({ ...initialValues }),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Recomputed every render — picks up new parentValues/ownKeys from parent
|
|
70
|
+
const localState = { ...parentValues, ...ownKeys, ...localMutations };
|
|
70
71
|
|
|
71
72
|
const valuesRef = useRef(localState);
|
|
72
73
|
valuesRef.current = localState;
|
|
73
74
|
|
|
74
75
|
const getValues = useCallback(() => valuesRef.current, []);
|
|
75
76
|
const update = useCallback((key: string, value: unknown) => {
|
|
76
|
-
|
|
77
|
+
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
77
78
|
}, []);
|
|
78
79
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
79
80
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
2
3
|
import { useStoryStore } from '../../store';
|
|
3
4
|
import {
|
|
4
5
|
renderNodes,
|
|
5
6
|
LocalsUpdateContext,
|
|
6
7
|
LocalsValuesContext,
|
|
8
|
+
NobrContext,
|
|
7
9
|
} from '../../markup/render';
|
|
8
10
|
import { defineMacro } from '../../define-macro';
|
|
9
11
|
|
|
@@ -32,15 +34,20 @@ function renderChildrenDetached(
|
|
|
32
34
|
children: import('../../markup/ast').ASTNode[],
|
|
33
35
|
getValues: () => Record<string, unknown>,
|
|
34
36
|
update: (key: string, value: unknown) => void,
|
|
37
|
+
nobr: boolean,
|
|
35
38
|
) {
|
|
36
39
|
const container = document.createElement('div');
|
|
37
40
|
const vnode = h(
|
|
38
|
-
|
|
39
|
-
{ value:
|
|
41
|
+
NobrContext.Provider,
|
|
42
|
+
{ value: nobr },
|
|
40
43
|
h(
|
|
41
|
-
|
|
42
|
-
{ value: getValues
|
|
43
|
-
|
|
44
|
+
LocalsUpdateContext.Provider,
|
|
45
|
+
{ value: { update, getValues } },
|
|
46
|
+
h(
|
|
47
|
+
LocalsValuesContext.Provider,
|
|
48
|
+
{ value: getValues() },
|
|
49
|
+
renderNodes(children),
|
|
50
|
+
),
|
|
44
51
|
),
|
|
45
52
|
);
|
|
46
53
|
render(vnode, container);
|
|
@@ -52,10 +59,11 @@ defineMacro({
|
|
|
52
59
|
interpolate: true,
|
|
53
60
|
render({ rawArgs, children = [] }, ctx) {
|
|
54
61
|
const { display, passage } = parseArgs(rawArgs);
|
|
62
|
+
const nobr = useContext(NobrContext);
|
|
55
63
|
|
|
56
64
|
const handleClick = (e: Event) => {
|
|
57
65
|
e.preventDefault();
|
|
58
|
-
renderChildrenDetached(children, ctx.getValues, ctx.update);
|
|
66
|
+
renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
|
|
59
67
|
if (passage) {
|
|
60
68
|
useStoryStore.getState().navigate(passage);
|
|
61
69
|
}
|
|
@@ -68,7 +76,7 @@ defineMacro({
|
|
|
68
76
|
label: display,
|
|
69
77
|
target: passage ?? undefined,
|
|
70
78
|
perform: () => {
|
|
71
|
-
renderChildrenDetached(children, ctx.getValues, ctx.update);
|
|
79
|
+
renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
|
|
72
80
|
if (passage) {
|
|
73
81
|
useStoryStore.getState().navigate(passage);
|
|
74
82
|
}
|
|
@@ -88,6 +88,10 @@ export function SaveManagerContent() {
|
|
|
88
88
|
statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
return () => clearTimeout(statusTimer.current);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
91
95
|
const toggleCollapse = (id: string) => {
|
|
92
96
|
setCollapsed((prev) => {
|
|
93
97
|
const next = new Set(prev);
|
|
@@ -10,6 +10,8 @@ defineMacro({
|
|
|
10
10
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
11
11
|
const [totalChars, setTotalChars] = useState(0);
|
|
12
12
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
13
|
+
const visibleCharsRef = useRef(0);
|
|
14
|
+
visibleCharsRef.current = visibleChars;
|
|
13
15
|
|
|
14
16
|
useEffect(() => {
|
|
15
17
|
if (containerRef.current) {
|
|
@@ -21,7 +23,7 @@ defineMacro({
|
|
|
21
23
|
// Typewriter interval
|
|
22
24
|
useEffect(() => {
|
|
23
25
|
if (totalChars === 0) return;
|
|
24
|
-
if (
|
|
26
|
+
if (visibleCharsRef.current >= totalChars) return;
|
|
25
27
|
|
|
26
28
|
const timer = setInterval(() => {
|
|
27
29
|
setVisibleChars((c) => {
|
|
@@ -13,9 +13,12 @@ defineMacro({
|
|
|
13
13
|
render({ rawArgs, children = [] }, ctx) {
|
|
14
14
|
const { name, params } = parseWidgetDef(rawArgs);
|
|
15
15
|
|
|
16
|
+
const childrenKey = JSON.stringify(children);
|
|
17
|
+
const paramsKey = params.join(',');
|
|
18
|
+
|
|
16
19
|
ctx.hooks.useLayoutEffect(() => {
|
|
17
20
|
registerWidget(name, children, params);
|
|
18
|
-
}, []);
|
|
21
|
+
}, [name, childrenKey, paramsKey]);
|
|
19
22
|
|
|
20
23
|
return null;
|
|
21
24
|
},
|
package/src/expression.ts
CHANGED
|
@@ -118,6 +118,57 @@ function transform(expr: string): string {
|
|
|
118
118
|
} else if (ic === '\\' && i + 1 < expr.length) {
|
|
119
119
|
inner += ic + expr.charAt(i + 1);
|
|
120
120
|
i++;
|
|
121
|
+
} else if (ic === '"' || ic === "'") {
|
|
122
|
+
// Skip quoted strings — braces inside are not depth-relevant
|
|
123
|
+
inner += ic;
|
|
124
|
+
i++;
|
|
125
|
+
while (i < expr.length) {
|
|
126
|
+
const sc = expr.charAt(i);
|
|
127
|
+
if (sc === '\\' && i + 1 < expr.length) {
|
|
128
|
+
inner += sc + expr.charAt(i + 1);
|
|
129
|
+
i += 2;
|
|
130
|
+
} else if (sc === ic) {
|
|
131
|
+
inner += sc;
|
|
132
|
+
i++;
|
|
133
|
+
break;
|
|
134
|
+
} else {
|
|
135
|
+
inner += sc;
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
continue; // skip the i++ at the end
|
|
140
|
+
} else if (ic === '`') {
|
|
141
|
+
// Nested template literal — consume entirely
|
|
142
|
+
inner += ic;
|
|
143
|
+
i++;
|
|
144
|
+
while (i < expr.length) {
|
|
145
|
+
const tc = expr.charAt(i);
|
|
146
|
+
if (tc === '\\' && i + 1 < expr.length) {
|
|
147
|
+
inner += tc + expr.charAt(i + 1);
|
|
148
|
+
i += 2;
|
|
149
|
+
} else if (tc === '$' && expr.charAt(i + 1) === '{') {
|
|
150
|
+
// Nested interpolation — track brace depth
|
|
151
|
+
inner += '${';
|
|
152
|
+
i += 2;
|
|
153
|
+
let nestedDepth = 1;
|
|
154
|
+
while (i < expr.length && nestedDepth > 0) {
|
|
155
|
+
const nc = expr.charAt(i);
|
|
156
|
+
if (nc === '{') nestedDepth++;
|
|
157
|
+
else if (nc === '}') nestedDepth--;
|
|
158
|
+
if (nestedDepth > 0) inner += nc;
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
inner += '}';
|
|
162
|
+
} else if (tc === '`') {
|
|
163
|
+
inner += tc;
|
|
164
|
+
i++;
|
|
165
|
+
break;
|
|
166
|
+
} else {
|
|
167
|
+
inner += tc;
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
continue; // skip the i++ at the end
|
|
121
172
|
} else {
|
|
122
173
|
inner += ic;
|
|
123
174
|
}
|
package/src/markup/markdown.ts
CHANGED
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
export function markdownToHtml(text: string): string {
|
|
13
13
|
return micromark(text, {
|
|
14
14
|
allowDangerousHtml: true,
|
|
15
|
-
extensions: [
|
|
15
|
+
extensions: [
|
|
16
|
+
gfmTable(),
|
|
17
|
+
gfmStrikethrough(),
|
|
18
|
+
{ disable: { null: ['codeIndented'] } },
|
|
19
|
+
],
|
|
16
20
|
htmlExtensions: [gfmTableHtml(), gfmStrikethroughHtml()],
|
|
17
21
|
});
|
|
18
22
|
}
|
package/src/markup/render.tsx
CHANGED
|
@@ -259,7 +259,7 @@ export function renderNodes(
|
|
|
259
259
|
for (let i = 0; i < nodes.length; i++) {
|
|
260
260
|
const node = nodes[i]!;
|
|
261
261
|
if (node.type === 'text') {
|
|
262
|
-
combined += node.value
|
|
262
|
+
combined += node.value;
|
|
263
263
|
} else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
|
|
264
264
|
// Inline variable value to avoid placeholder inside code span
|
|
265
265
|
combined += getVariableTextValue(node, locals);
|
package/src/saves/types.ts
CHANGED
|
@@ -69,7 +69,8 @@ export function isSaveExport(value: unknown): value is SaveExport {
|
|
|
69
69
|
|
|
70
70
|
const payload = save.payload as Record<string, unknown>;
|
|
71
71
|
if (typeof payload.passage !== 'string') return false;
|
|
72
|
-
if (!Array.isArray(payload.history)
|
|
72
|
+
if (!Array.isArray(payload.history) || payload.history.length === 0)
|
|
73
|
+
return false;
|
|
73
74
|
if (typeof payload.historyIndex !== 'number') return false;
|
|
74
75
|
if (typeof payload.variables !== 'object' || payload.variables === null)
|
|
75
76
|
return false;
|
|
@@ -82,7 +83,7 @@ export function isSavePayload(value: unknown): value is SavePayload {
|
|
|
82
83
|
const obj = value as Record<string, unknown>;
|
|
83
84
|
if (typeof obj.passage !== 'string') return false;
|
|
84
85
|
if (typeof obj.variables !== 'object' || obj.variables === null) return false;
|
|
85
|
-
if (!Array.isArray(obj.history)) return false;
|
|
86
|
+
if (!Array.isArray(obj.history) || obj.history.length === 0) return false;
|
|
86
87
|
if (typeof obj.historyIndex !== 'number') return false;
|
|
87
88
|
return true;
|
|
88
89
|
}
|
package/src/store.ts
CHANGED
|
@@ -576,6 +576,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
576
576
|
},
|
|
577
577
|
|
|
578
578
|
loadFromPayload: (payload: SavePayload) => {
|
|
579
|
+
if (payload.history.length === 0) {
|
|
580
|
+
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
579
583
|
// Convert full snapshots to patch entries
|
|
580
584
|
const base = deserialize(payload.history[0]?.variables ?? {}) as Record<
|
|
581
585
|
string,
|
package/src/story-init.ts
CHANGED
|
@@ -19,12 +19,16 @@ export function executeStoryInit() {
|
|
|
19
19
|
const tokens = tokenize(storyInit.content);
|
|
20
20
|
const ast = buildAST(tokens);
|
|
21
21
|
|
|
22
|
+
// Mount into a persistent hidden container. We intentionally do NOT
|
|
23
|
+
// unmount — this lets async effects (useEffect, setTimeout, etc.)
|
|
24
|
+
// inside StoryInit macros fire through the normal Preact pipeline.
|
|
22
25
|
const container = document.createElement('div');
|
|
26
|
+
container.style.display = 'none';
|
|
27
|
+
document.body.appendChild(container);
|
|
23
28
|
render(
|
|
24
29
|
h(() => renderNodes(ast) as any, null),
|
|
25
30
|
container,
|
|
26
31
|
);
|
|
27
|
-
render(null, container);
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
// Register SaveTitle passage if it exists
|