@rohal12/spindle 0.28.4 → 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/components/macros/WidgetInvocation.tsx +17 -6
- package/src/expression.ts +51 -0
- package/src/index.tsx +8 -2
- package/src/markup/markdown.ts +5 -1
- package/src/markup/render.tsx +14 -1
- package/src/saves/types.ts +3 -2
- package/src/store.ts +4 -0
- package/src/story-init.ts +5 -1
- package/src/widgets/ast-scanner.ts +29 -0
- package/src/widgets/widget-registry.ts +12 -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
|
},
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
LocalsValuesContext,
|
|
10
10
|
LocalsUpdateContext,
|
|
11
11
|
NobrContext,
|
|
12
|
+
WidgetChildrenContext,
|
|
12
13
|
renderNodes,
|
|
13
14
|
} from '../../markup/render';
|
|
14
15
|
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
@@ -19,6 +20,7 @@ interface WidgetInvocationProps {
|
|
|
19
20
|
body: ASTNode[];
|
|
20
21
|
params: string[];
|
|
21
22
|
rawArgs?: string;
|
|
23
|
+
invocationChildren?: ASTNode[];
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -110,13 +112,20 @@ export function WidgetInvocation({
|
|
|
110
112
|
body,
|
|
111
113
|
params,
|
|
112
114
|
rawArgs,
|
|
115
|
+
invocationChildren,
|
|
113
116
|
}: WidgetInvocationProps) {
|
|
114
117
|
const parentValues = useContext(LocalsValuesContext);
|
|
115
118
|
const nobr = useContext(NobrContext);
|
|
116
119
|
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
117
120
|
|
|
121
|
+
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
122
|
+
|
|
118
123
|
if (params.length === 0 || !rawArgs) {
|
|
119
|
-
return
|
|
124
|
+
return (
|
|
125
|
+
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
126
|
+
{renderNodes(body, { nobr, locals: parentValues })}
|
|
127
|
+
</WidgetChildrenContext.Provider>
|
|
128
|
+
);
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
const argExprs = splitArgs(rawArgs);
|
|
@@ -137,10 +146,12 @@ export function WidgetInvocation({
|
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
return (
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
150
|
+
<WidgetBody
|
|
151
|
+
body={body}
|
|
152
|
+
parentValues={parentValues}
|
|
153
|
+
ownKeys={ownKeys}
|
|
154
|
+
/>
|
|
155
|
+
</WidgetChildrenContext.Provider>
|
|
145
156
|
);
|
|
146
157
|
}
|
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/index.tsx
CHANGED
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
extractDefaults,
|
|
14
14
|
} from './story-variables';
|
|
15
15
|
import { tokenize } from './markup/tokenizer';
|
|
16
|
-
import { buildAST } from './markup/ast';
|
|
16
|
+
import { buildAST, registerBlockMacro } from './markup/ast';
|
|
17
17
|
import { registerWidget } from './widgets/widget-registry';
|
|
18
|
+
import { astContainsChildren } from './widgets/ast-scanner';
|
|
18
19
|
import type { ASTNode } from './markup/ast';
|
|
19
20
|
import './macros/register-builtins';
|
|
20
21
|
import builtinCSS from './styles.css?inline';
|
|
@@ -122,7 +123,12 @@ function boot() {
|
|
|
122
123
|
(t) =>
|
|
123
124
|
t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
|
|
124
125
|
);
|
|
125
|
-
|
|
126
|
+
const children = node.children as ASTNode[];
|
|
127
|
+
const isBlock = astContainsChildren(children);
|
|
128
|
+
registerWidget(widgetName, children, params, isBlock);
|
|
129
|
+
if (isBlock) {
|
|
130
|
+
registerBlockMacro(widgetName);
|
|
131
|
+
}
|
|
126
132
|
}
|
|
127
133
|
}
|
|
128
134
|
}
|
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
|
@@ -23,6 +23,7 @@ const defaultUpdater: LocalsUpdater = {
|
|
|
23
23
|
export const LocalsValuesContext = createContext<Record<string, unknown>>({});
|
|
24
24
|
export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
|
|
25
25
|
export const NobrContext = createContext(false);
|
|
26
|
+
export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Convert an HTML string (from micromark) to Preact VNodes,
|
|
@@ -97,6 +98,14 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
function ChildrenSlot() {
|
|
102
|
+
const childrenAST = useContext(WidgetChildrenContext);
|
|
103
|
+
const nobr = useContext(NobrContext);
|
|
104
|
+
const locals = useContext(LocalsValuesContext);
|
|
105
|
+
if (!childrenAST || childrenAST.length === 0) return null;
|
|
106
|
+
return <>{renderNodes(childrenAST, { nobr, locals })}</>;
|
|
107
|
+
}
|
|
108
|
+
|
|
100
109
|
function renderMacro(node: MacroNode, key: number) {
|
|
101
110
|
if (isSubMacro(node.name)) return null;
|
|
102
111
|
|
|
@@ -108,6 +117,7 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
108
117
|
body={widget.body}
|
|
109
118
|
params={widget.params}
|
|
110
119
|
rawArgs={node.rawArgs}
|
|
120
|
+
invocationChildren={node.children}
|
|
111
121
|
/>
|
|
112
122
|
);
|
|
113
123
|
}
|
|
@@ -148,6 +158,9 @@ function renderSingleNode(
|
|
|
148
158
|
return node.value;
|
|
149
159
|
|
|
150
160
|
case 'variable':
|
|
161
|
+
if (node.scope === 'local' && node.name === 'children') {
|
|
162
|
+
return <ChildrenSlot key={key} />;
|
|
163
|
+
}
|
|
151
164
|
return (
|
|
152
165
|
<VarDisplay
|
|
153
166
|
key={key}
|
|
@@ -246,7 +259,7 @@ export function renderNodes(
|
|
|
246
259
|
for (let i = 0; i < nodes.length; i++) {
|
|
247
260
|
const node = nodes[i]!;
|
|
248
261
|
if (node.type === 'text') {
|
|
249
|
-
combined += node.value
|
|
262
|
+
combined += node.value;
|
|
250
263
|
} else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
|
|
251
264
|
// Inline variable value to avoid placeholder inside code span
|
|
252
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
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ASTNode } from '../markup/ast';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively scan an AST node array for a VariableNode
|
|
5
|
+
* with scope 'local' and name 'children' (@children).
|
|
6
|
+
*/
|
|
7
|
+
export function astContainsChildren(nodes: ASTNode[]): boolean {
|
|
8
|
+
for (const node of nodes) {
|
|
9
|
+
if (
|
|
10
|
+
node.type === 'variable' &&
|
|
11
|
+
node.scope === 'local' &&
|
|
12
|
+
node.name === 'children'
|
|
13
|
+
) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (node.type === 'html' && astContainsChildren(node.children)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (node.type === 'macro') {
|
|
20
|
+
if (astContainsChildren(node.children)) return true;
|
|
21
|
+
if (node.branches) {
|
|
22
|
+
for (const branch of node.branches) {
|
|
23
|
+
if (astContainsChildren(branch.children)) return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { ASTNode } from '../markup/ast';
|
|
|
3
3
|
interface WidgetEntry {
|
|
4
4
|
body: ASTNode[];
|
|
5
5
|
params: string[];
|
|
6
|
+
isBlock: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
const widgets = new Map<string, WidgetEntry>();
|
|
@@ -11,14 +12,24 @@ export function registerWidget(
|
|
|
11
12
|
name: string,
|
|
12
13
|
bodyAST: ASTNode[],
|
|
13
14
|
params: string[],
|
|
15
|
+
isBlock = false,
|
|
14
16
|
): void {
|
|
15
|
-
|
|
17
|
+
const filteredParams = params.filter((p) => p !== '@children');
|
|
18
|
+
widgets.set(name.toLowerCase(), {
|
|
19
|
+
body: bodyAST,
|
|
20
|
+
params: filteredParams,
|
|
21
|
+
isBlock,
|
|
22
|
+
});
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function getWidget(name: string): WidgetEntry | undefined {
|
|
19
26
|
return widgets.get(name.toLowerCase());
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
export function isBlockWidget(name: string): boolean {
|
|
30
|
+
return widgets.get(name.toLowerCase())?.isBlock ?? false;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
export function clearWidgets(): void {
|
|
23
34
|
widgets.clear();
|
|
24
35
|
}
|