@rohal12/spindle 0.28.3 → 0.29.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/automation/load-yaml.ts +40 -2
- package/src/components/StoryInterface.tsx +21 -16
- package/src/components/macros/Checkbox.tsx +12 -21
- package/src/components/macros/Cycle.tsx +3 -1
- package/src/components/macros/For.tsx +1 -1
- package/src/components/macros/MenubarButtons.tsx +1 -3
- package/src/components/macros/Radiobutton.tsx +14 -32
- package/src/components/macros/Repeat.tsx +6 -18
- package/src/components/macros/WidgetInvocation.tsx +18 -7
- package/src/define-macro.ts +6 -4
- package/src/execute-mutation.ts +17 -0
- package/src/expression.ts +1 -1
- package/src/index.tsx +8 -2
- package/src/markup/render.tsx +23 -4
- package/src/saves/save-manager.ts +4 -1
- package/src/saves/types.ts +10 -0
- package/src/store.ts +4 -1
- package/src/widgets/ast-scanner.ts +29 -0
- package/src/widgets/widget-registry.ts +12 -1
package/package.json
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import yaml from 'js-yaml';
|
|
2
|
-
import type { AutomationScript } from './types';
|
|
2
|
+
import type { AutomationScript, AutomationStep } from './types';
|
|
3
|
+
|
|
4
|
+
function validateStep(step: unknown, index: number): AutomationStep {
|
|
5
|
+
if (typeof step !== 'object' || step === null) {
|
|
6
|
+
throw new Error(`Invalid automation step ${index + 1}: expected an object`);
|
|
7
|
+
}
|
|
8
|
+
const s = step as Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
const hasAction = 'action' in s;
|
|
11
|
+
const hasAssert = 'assert' in s;
|
|
12
|
+
const hasWait = 'wait' in s;
|
|
13
|
+
const hasSet = 'set' in s;
|
|
14
|
+
|
|
15
|
+
if (!hasAction && !hasAssert && !hasWait && !hasSet) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Invalid automation step ${index + 1}: must have at least one of action, assert, wait, set`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (hasWait && typeof s.wait !== 'number') {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Invalid automation step ${index + 1}: "wait" must be a number`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (hasSet && (typeof s.set !== 'object' || s.set === null)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Invalid automation step ${index + 1}: "set" must be an object`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return s as AutomationStep;
|
|
34
|
+
}
|
|
3
35
|
|
|
4
36
|
export function parseAutomationYaml(yamlContent: string): AutomationScript {
|
|
5
37
|
const doc = yaml.load(yamlContent) as Record<string, unknown>;
|
|
@@ -16,5 +48,11 @@ export function parseAutomationYaml(yamlContent: string): AutomationScript {
|
|
|
16
48
|
throw new Error('Invalid automation script: missing "steps" array');
|
|
17
49
|
}
|
|
18
50
|
|
|
19
|
-
|
|
51
|
+
const steps = doc.steps.map((step, i) => validateStep(step, i));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: doc.name,
|
|
55
|
+
start: typeof doc.start === 'string' ? doc.start : undefined,
|
|
56
|
+
steps,
|
|
57
|
+
};
|
|
20
58
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useMemo } from 'preact/hooks';
|
|
1
2
|
import { useStoryStore } from '../store';
|
|
2
3
|
import { tokenize } from '../markup/tokenizer';
|
|
3
4
|
import { buildAST } from '../markup/ast';
|
|
@@ -14,20 +15,24 @@ export function StoryInterface() {
|
|
|
14
15
|
overridePassage !== undefined ? overridePassage.content : DEFAULT_MARKUP;
|
|
15
16
|
const nobr = overridePassage?.tags.includes('nobr') ?? false;
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
const rendered = useMemo(() => {
|
|
19
|
+
try {
|
|
20
|
+
const tokens = tokenize(markup);
|
|
21
|
+
const ast = buildAST(tokens);
|
|
22
|
+
return <>{renderInlineNodes(ast)}</>;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return (
|
|
25
|
+
<span class="error">
|
|
26
|
+
Error in StoryInterface:{' '}
|
|
27
|
+
{err instanceof Error ? err.message : String(err)}
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}, [markup]);
|
|
32
|
+
|
|
33
|
+
return nobr ? (
|
|
34
|
+
<NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
|
|
35
|
+
) : (
|
|
36
|
+
rendered
|
|
37
|
+
);
|
|
33
38
|
}
|
|
@@ -1,33 +1,24 @@
|
|
|
1
|
-
import { useStoryStore } from '../../store';
|
|
2
1
|
import { defineMacro } from '../../define-macro';
|
|
3
2
|
|
|
4
|
-
function
|
|
5
|
-
const match = rawArgs.match(/^\s*
|
|
6
|
-
|
|
7
|
-
return { varName: rawArgs.trim(), label: '' };
|
|
8
|
-
}
|
|
9
|
-
const varName = match[1]!.replace(/["']/g, '');
|
|
10
|
-
const label = match[2]!;
|
|
11
|
-
return { varName, label };
|
|
3
|
+
function parseLabel(rawArgs: string): string {
|
|
4
|
+
const match = rawArgs.match(/^\s*["']?\$?\w+["']?\s+["']?(.+?)["']?\s*$/);
|
|
5
|
+
return match?.[1] ?? '';
|
|
12
6
|
}
|
|
13
7
|
|
|
14
8
|
defineMacro({
|
|
15
9
|
name: 'checkbox',
|
|
10
|
+
storeVar: true,
|
|
16
11
|
render({ rawArgs }, ctx) {
|
|
17
|
-
const
|
|
18
|
-
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
19
|
-
|
|
20
|
-
const value = useStoryStore((s) => s.variables[name]);
|
|
21
|
-
const setVariable = useStoryStore((s) => s.setVariable);
|
|
12
|
+
const label = parseLabel(rawArgs);
|
|
22
13
|
|
|
23
14
|
ctx.useAction({
|
|
24
15
|
type: 'checkbox',
|
|
25
|
-
key: `$${
|
|
16
|
+
key: `$${ctx.varName}`,
|
|
26
17
|
authorId: ctx.id,
|
|
27
|
-
label: label ||
|
|
28
|
-
variable:
|
|
29
|
-
value: !!value,
|
|
30
|
-
perform: (v) =>
|
|
18
|
+
label: label || ctx.varName || '',
|
|
19
|
+
variable: ctx.varName,
|
|
20
|
+
value: !!ctx.value,
|
|
21
|
+
perform: (v) => ctx.setValue!(v !== undefined ? !!v : !ctx.value),
|
|
31
22
|
});
|
|
32
23
|
|
|
33
24
|
return (
|
|
@@ -37,8 +28,8 @@ defineMacro({
|
|
|
37
28
|
>
|
|
38
29
|
<input
|
|
39
30
|
type="checkbox"
|
|
40
|
-
checked={!!value}
|
|
41
|
-
onChange={() =>
|
|
31
|
+
checked={!!ctx.value}
|
|
32
|
+
onChange={() => ctx.setValue!(!ctx.value)}
|
|
42
33
|
/>
|
|
43
34
|
{label ? ` ${label}` : null}
|
|
44
35
|
</label>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
1
2
|
import { defineMacro } from '../../define-macro';
|
|
2
3
|
|
|
3
4
|
defineMacro({
|
|
@@ -8,7 +9,8 @@ defineMacro({
|
|
|
8
9
|
|
|
9
10
|
const handleClick = () => {
|
|
10
11
|
if (options.length === 0) return;
|
|
11
|
-
const
|
|
12
|
+
const current = useStoryStore.getState().variables[ctx.varName!];
|
|
13
|
+
const currentIndex = options.indexOf(String(current));
|
|
12
14
|
const nextIndex = (currentIndex + 1) % options.length;
|
|
13
15
|
ctx.setValue!(options[nextIndex]);
|
|
14
16
|
};
|
|
@@ -82,7 +82,7 @@ function ForIteration({
|
|
|
82
82
|
return (
|
|
83
83
|
<LocalsUpdateContext.Provider value={updater}>
|
|
84
84
|
<LocalsValuesContext.Provider value={localState}>
|
|
85
|
-
{renderNodes(children,
|
|
85
|
+
{renderNodes(children, { nobr, locals: localState })}
|
|
86
86
|
</LocalsValuesContext.Provider>
|
|
87
87
|
</LocalsUpdateContext.Provider>
|
|
88
88
|
);
|
|
@@ -28,9 +28,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
|
|
|
28
28
|
render(_, ctx) {
|
|
29
29
|
if (config.hidden?.()) return null;
|
|
30
30
|
|
|
31
|
-
const [dialogOpen, setDialogOpen] =
|
|
32
|
-
? useState(false)
|
|
33
|
-
: [false, undefined as never];
|
|
31
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
34
32
|
|
|
35
33
|
const setup = config.setup?.() ?? { perform: () => {} };
|
|
36
34
|
const { disabled } = setup;
|
|
@@ -1,48 +1,30 @@
|
|
|
1
|
-
import { useStoryStore } from '../../store';
|
|
2
1
|
import { defineMacro } from '../../define-macro';
|
|
3
2
|
|
|
4
|
-
function
|
|
5
|
-
varName: string;
|
|
6
|
-
value: string;
|
|
7
|
-
label: string;
|
|
8
|
-
} {
|
|
9
|
-
// {radiobutton "$var" "value" "Label text"}
|
|
3
|
+
function parseRadioArgs(rawArgs: string): { value: string; label: string } {
|
|
10
4
|
const match = rawArgs.match(
|
|
11
|
-
/^\s*
|
|
5
|
+
/^\s*["']?\$?\w+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
|
|
12
6
|
);
|
|
13
7
|
if (!match) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return {
|
|
17
|
-
varName: (parts[0] ?? '').replace(/["']/g, ''),
|
|
18
|
-
value: parts[1] ?? '',
|
|
19
|
-
label: parts.slice(2).join(' '),
|
|
20
|
-
};
|
|
8
|
+
const parts = rawArgs.trim().split(/\s+/).slice(1);
|
|
9
|
+
return { value: parts[0] ?? '', label: parts.slice(1).join(' ') };
|
|
21
10
|
}
|
|
22
|
-
return {
|
|
23
|
-
varName: match[1]!.replace(/["']/g, ''),
|
|
24
|
-
value: match[2]!,
|
|
25
|
-
label: match[3]!,
|
|
26
|
-
};
|
|
11
|
+
return { value: match[1]!, label: match[2]! };
|
|
27
12
|
}
|
|
28
13
|
|
|
29
14
|
defineMacro({
|
|
30
15
|
name: 'radiobutton',
|
|
16
|
+
storeVar: true,
|
|
31
17
|
render({ rawArgs }, ctx) {
|
|
32
|
-
const {
|
|
33
|
-
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
34
|
-
|
|
35
|
-
const currentValue = useStoryStore((s) => s.variables[name]);
|
|
36
|
-
const setVariable = useStoryStore((s) => s.setVariable);
|
|
18
|
+
const { value: radioValue, label } = parseRadioArgs(rawArgs);
|
|
37
19
|
|
|
38
20
|
ctx.useAction({
|
|
39
21
|
type: 'radiobutton',
|
|
40
|
-
key: `$${
|
|
22
|
+
key: `$${ctx.varName}:${radioValue}`,
|
|
41
23
|
authorId: ctx.id,
|
|
42
24
|
label: label || radioValue,
|
|
43
|
-
variable:
|
|
44
|
-
value:
|
|
45
|
-
perform: () =>
|
|
25
|
+
variable: ctx.varName,
|
|
26
|
+
value: ctx.value,
|
|
27
|
+
perform: () => ctx.setValue!(radioValue),
|
|
46
28
|
});
|
|
47
29
|
|
|
48
30
|
return (
|
|
@@ -52,9 +34,9 @@ defineMacro({
|
|
|
52
34
|
>
|
|
53
35
|
<input
|
|
54
36
|
type="radio"
|
|
55
|
-
name={`radio-${
|
|
56
|
-
checked={
|
|
57
|
-
onChange={() =>
|
|
37
|
+
name={`radio-${ctx.varName}`}
|
|
38
|
+
checked={ctx.value === radioValue}
|
|
39
|
+
onChange={() => ctx.setValue!(radioValue)}
|
|
58
40
|
/>
|
|
59
41
|
{label ? ` ${label}` : null}
|
|
60
42
|
</label>
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { parseDelay } from '../../utils/parse-delay';
|
|
3
|
-
import { hasInterpolation, interpolate } from '../../interpolation';
|
|
4
|
-
import { useStoryStore } from '../../store';
|
|
5
3
|
import { defineMacro } from '../../define-macro';
|
|
6
4
|
|
|
7
5
|
export const RepeatContext = createContext<{ stop: () => void }>({
|
|
@@ -10,17 +8,9 @@ export const RepeatContext = createContext<{ stop: () => void }>({
|
|
|
10
8
|
|
|
11
9
|
defineMacro({
|
|
12
10
|
name: 'repeat',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const [className, id] = useMemo(() => {
|
|
17
|
-
const resolveOnce = (s: string | undefined) => {
|
|
18
|
-
if (!s || !hasInterpolation(s)) return s;
|
|
19
|
-
const st = useStoryStore.getState();
|
|
20
|
-
return interpolate(s, st.variables, st.temporary, {});
|
|
21
|
-
};
|
|
22
|
-
return [resolveOnce(rawClassName), resolveOnce(rawId)];
|
|
23
|
-
}, [rawClassName, rawId]);
|
|
11
|
+
interpolate: true,
|
|
12
|
+
render({ rawArgs, children = [] }, ctx) {
|
|
13
|
+
const { useState, useEffect, useCallback } = ctx.hooks;
|
|
24
14
|
|
|
25
15
|
const delay = parseDelay(rawArgs);
|
|
26
16
|
const [count, setCount] = useState(0);
|
|
@@ -38,19 +28,17 @@ defineMacro({
|
|
|
38
28
|
|
|
39
29
|
if (count === 0 && !stopped) return null;
|
|
40
30
|
|
|
41
|
-
const cls = className ? `macro-repeat ${className}` : undefined;
|
|
42
|
-
|
|
43
31
|
const content = (
|
|
44
32
|
<RepeatContext.Provider value={{ stop }}>
|
|
45
33
|
<span key={count}>{ctx.renderNodes(children)}</span>
|
|
46
34
|
</RepeatContext.Provider>
|
|
47
35
|
);
|
|
48
36
|
|
|
49
|
-
if (
|
|
37
|
+
if (ctx.className || ctx.id)
|
|
50
38
|
return (
|
|
51
39
|
<span
|
|
52
|
-
id={id}
|
|
53
|
-
class={cls}
|
|
40
|
+
id={ctx.id}
|
|
41
|
+
class={ctx.cls}
|
|
54
42
|
>
|
|
55
43
|
{content}
|
|
56
44
|
</span>
|
|
@@ -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
|
/**
|
|
@@ -100,7 +102,7 @@ function WidgetBody({
|
|
|
100
102
|
return (
|
|
101
103
|
<LocalsUpdateContext.Provider value={updater}>
|
|
102
104
|
<LocalsValuesContext.Provider value={localState}>
|
|
103
|
-
{renderNodes(body,
|
|
105
|
+
{renderNodes(body, { nobr, locals: localState })}
|
|
104
106
|
</LocalsValuesContext.Provider>
|
|
105
107
|
</LocalsUpdateContext.Provider>
|
|
106
108
|
);
|
|
@@ -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/define-macro.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { useInterpolate } from './hooks/use-interpolate';
|
|
|
13
13
|
import { useMergedLocals } from './hooks/use-merged-locals';
|
|
14
14
|
import {
|
|
15
15
|
LocalsUpdateContext,
|
|
16
|
+
LocalsValuesContext,
|
|
16
17
|
NobrContext,
|
|
17
18
|
renderNodes as _renderNodes,
|
|
18
19
|
renderInlineNodes,
|
|
@@ -107,10 +108,11 @@ export function defineMacro(config: MacroDefinition): void {
|
|
|
107
108
|
// Always-on: cssClass + mutation
|
|
108
109
|
const { update, getValues } = useContext(LocalsUpdateContext);
|
|
109
110
|
const nobr = useContext(NobrContext);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
const localsValues = useContext(LocalsValuesContext);
|
|
112
|
+
const renderNodes = (
|
|
113
|
+
nodes: ASTNode[],
|
|
114
|
+
options?: { nobr?: boolean; locals?: Record<string, unknown> },
|
|
115
|
+
) => _renderNodes(nodes, { nobr, locals: localsValues, ...options });
|
|
114
116
|
const ctx: MacroContext = {
|
|
115
117
|
collectText,
|
|
116
118
|
sourceLocation: currentSourceLocation,
|
package/src/execute-mutation.ts
CHANGED
|
@@ -29,4 +29,21 @@ export function executeMutation(
|
|
|
29
29
|
scopeUpdate(key, localsClone[key]);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
// Detect deleted keys
|
|
34
|
+
for (const key of Object.keys(state.variables)) {
|
|
35
|
+
if (!(key in vars)) {
|
|
36
|
+
state.deleteVariable(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const key of Object.keys(state.temporary)) {
|
|
40
|
+
if (!(key in temps)) {
|
|
41
|
+
state.deleteTemporary(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const key of Object.keys(mergedLocals)) {
|
|
45
|
+
if (!(key in localsClone)) {
|
|
46
|
+
scopeUpdate(key, undefined);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
32
49
|
}
|
package/src/expression.ts
CHANGED
|
@@ -34,7 +34,7 @@ const fnCache = new Map<string, CompiledExpression>();
|
|
|
34
34
|
* but authors already have full JS access so this is acceptable).
|
|
35
35
|
*/
|
|
36
36
|
const VAR_RE = /\$(\w+)/g;
|
|
37
|
-
const TEMP_RE =
|
|
37
|
+
const TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
38
38
|
const LOCAL_RE = /@(\w+)/g;
|
|
39
39
|
|
|
40
40
|
function transformSegment(segment: string): string {
|
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/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,
|
|
@@ -83,6 +84,7 @@ function convertDomNode(
|
|
|
83
84
|
function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
84
85
|
const resolve = useInterpolate();
|
|
85
86
|
const nobr = useContext(NobrContext);
|
|
87
|
+
const locals = useContext(LocalsValuesContext);
|
|
86
88
|
const attrs: Record<string, unknown> = {};
|
|
87
89
|
for (const [k, v] of Object.entries(node.attributes)) {
|
|
88
90
|
attrs[k] = resolve(v) ?? v;
|
|
@@ -91,11 +93,19 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
91
93
|
node.tag,
|
|
92
94
|
attrs,
|
|
93
95
|
node.children.length > 0
|
|
94
|
-
? renderNodes(node.children,
|
|
96
|
+
? renderNodes(node.children, { nobr, locals })
|
|
95
97
|
: undefined,
|
|
96
98
|
);
|
|
97
99
|
}
|
|
98
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
|
+
|
|
99
109
|
function renderMacro(node: MacroNode, key: number) {
|
|
100
110
|
if (isSubMacro(node.name)) return null;
|
|
101
111
|
|
|
@@ -107,6 +117,7 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
107
117
|
body={widget.body}
|
|
108
118
|
params={widget.params}
|
|
109
119
|
rawArgs={node.rawArgs}
|
|
120
|
+
invocationChildren={node.children}
|
|
110
121
|
/>
|
|
111
122
|
);
|
|
112
123
|
}
|
|
@@ -147,6 +158,9 @@ function renderSingleNode(
|
|
|
147
158
|
return node.value;
|
|
148
159
|
|
|
149
160
|
case 'variable':
|
|
161
|
+
if (node.scope === 'local' && node.name === 'children') {
|
|
162
|
+
return <ChildrenSlot key={key} />;
|
|
163
|
+
}
|
|
150
164
|
return (
|
|
151
165
|
<VarDisplay
|
|
152
166
|
key={key}
|
|
@@ -193,7 +207,10 @@ function hasUnclosedBacktick(s: string): boolean {
|
|
|
193
207
|
return count % 2 === 1;
|
|
194
208
|
}
|
|
195
209
|
|
|
196
|
-
function getVariableTextValue(
|
|
210
|
+
function getVariableTextValue(
|
|
211
|
+
node: VariableNode,
|
|
212
|
+
locals: Record<string, unknown>,
|
|
213
|
+
): string {
|
|
197
214
|
const state = useStoryStore.getState();
|
|
198
215
|
const parts = node.name.split('.');
|
|
199
216
|
const root = parts[0]!;
|
|
@@ -201,6 +218,7 @@ function getVariableTextValue(node: VariableNode): string {
|
|
|
201
218
|
let value: unknown;
|
|
202
219
|
if (node.scope === 'variable') value = state.variables[root];
|
|
203
220
|
else if (node.scope === 'temporary') value = state.temporary[root];
|
|
221
|
+
else value = locals[root];
|
|
204
222
|
|
|
205
223
|
for (let i = 1; i < parts.length; i++) {
|
|
206
224
|
if (value == null || typeof value !== 'object') return '';
|
|
@@ -223,7 +241,7 @@ function getVariableTextValue(node: VariableNode): string {
|
|
|
223
241
|
*/
|
|
224
242
|
export function renderNodes(
|
|
225
243
|
nodes: ASTNode[],
|
|
226
|
-
options?: { nobr?: boolean },
|
|
244
|
+
options?: { nobr?: boolean; locals?: Record<string, unknown> },
|
|
227
245
|
): preact.ComponentChildren {
|
|
228
246
|
if (nodes.length === 0) return null;
|
|
229
247
|
|
|
@@ -236,6 +254,7 @@ export function renderNodes(
|
|
|
236
254
|
// Build combined markdown string with placeholders for non-text nodes
|
|
237
255
|
const components: preact.ComponentChildren[] = [];
|
|
238
256
|
let combined = '';
|
|
257
|
+
const locals = options?.locals ?? {};
|
|
239
258
|
|
|
240
259
|
for (let i = 0; i < nodes.length; i++) {
|
|
241
260
|
const node = nodes[i]!;
|
|
@@ -243,7 +262,7 @@ export function renderNodes(
|
|
|
243
262
|
combined += node.value.replace(/^[ \t]{4,}/gm, ' ');
|
|
244
263
|
} else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
|
|
245
264
|
// Inline variable value to avoid placeholder inside code span
|
|
246
|
-
combined += getVariableTextValue(node);
|
|
265
|
+
combined += getVariableTextValue(node, locals);
|
|
247
266
|
} else {
|
|
248
267
|
const phIdx = components.length;
|
|
249
268
|
components.push(renderSingleNode(node, i));
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
PlaythroughRecord,
|
|
6
6
|
SaveExport,
|
|
7
7
|
} from './types';
|
|
8
|
+
import { isSavePayload } from './types';
|
|
8
9
|
import {
|
|
9
10
|
putSave,
|
|
10
11
|
getSave,
|
|
@@ -322,7 +323,9 @@ export function loadSession(ifid: string): SavePayload | undefined {
|
|
|
322
323
|
try {
|
|
323
324
|
const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${ifid}`);
|
|
324
325
|
if (!raw) return undefined;
|
|
325
|
-
const
|
|
326
|
+
const parsed: unknown = JSON.parse(raw);
|
|
327
|
+
if (!isSavePayload(parsed)) return undefined;
|
|
328
|
+
const payload = parsed;
|
|
326
329
|
payload.variables = deserialize(payload.variables);
|
|
327
330
|
payload.history = payload.history.map((m) => ({
|
|
328
331
|
...m,
|
package/src/saves/types.ts
CHANGED
|
@@ -76,3 +76,13 @@ export function isSaveExport(value: unknown): value is SaveExport {
|
|
|
76
76
|
|
|
77
77
|
return true;
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
export function isSavePayload(value: unknown): value is SavePayload {
|
|
81
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
82
|
+
const obj = value as Record<string, unknown>;
|
|
83
|
+
if (typeof obj.passage !== 'string') return false;
|
|
84
|
+
if (typeof obj.variables !== 'object' || obj.variables === null) return false;
|
|
85
|
+
if (!Array.isArray(obj.history)) return false;
|
|
86
|
+
if (typeof obj.historyIndex !== 'number') return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -608,7 +608,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
608
608
|
timestamp: m.timestamp,
|
|
609
609
|
prng: m.prng,
|
|
610
610
|
}));
|
|
611
|
-
state.historyIndex =
|
|
611
|
+
state.historyIndex = Math.max(
|
|
612
|
+
0,
|
|
613
|
+
Math.min(payload.historyIndex, state.history.length - 1),
|
|
614
|
+
);
|
|
612
615
|
state.visitCounts = payload.visitCounts ?? {};
|
|
613
616
|
state.renderCounts = payload.renderCounts ?? {};
|
|
614
617
|
state.temporary = {};
|
|
@@ -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
|
+
}
|