@rohal12/spindle 0.28.2 → 0.28.4
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 +2 -2
- package/src/define-macro.ts +6 -4
- package/src/execute-mutation.ts +17 -0
- package/src/expression.ts +1 -1
- package/src/markup/render.tsx +10 -4
- package/src/parser.ts +18 -3
- package/src/saves/save-manager.ts +4 -1
- package/src/saves/types.ts +10 -0
- package/src/store.ts +4 -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>
|
|
@@ -100,7 +100,7 @@ function WidgetBody({
|
|
|
100
100
|
return (
|
|
101
101
|
<LocalsUpdateContext.Provider value={updater}>
|
|
102
102
|
<LocalsValuesContext.Provider value={localState}>
|
|
103
|
-
{renderNodes(body,
|
|
103
|
+
{renderNodes(body, { nobr, locals: localState })}
|
|
104
104
|
</LocalsValuesContext.Provider>
|
|
105
105
|
</LocalsUpdateContext.Provider>
|
|
106
106
|
);
|
|
@@ -116,7 +116,7 @@ export function WidgetInvocation({
|
|
|
116
116
|
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
117
117
|
|
|
118
118
|
if (params.length === 0 || !rawArgs) {
|
|
119
|
-
return <>{renderNodes(body,
|
|
119
|
+
return <>{renderNodes(body, { nobr, locals: parentValues })}</>;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
const argExprs = splitArgs(rawArgs);
|
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/markup/render.tsx
CHANGED
|
@@ -83,6 +83,7 @@ function convertDomNode(
|
|
|
83
83
|
function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
84
84
|
const resolve = useInterpolate();
|
|
85
85
|
const nobr = useContext(NobrContext);
|
|
86
|
+
const locals = useContext(LocalsValuesContext);
|
|
86
87
|
const attrs: Record<string, unknown> = {};
|
|
87
88
|
for (const [k, v] of Object.entries(node.attributes)) {
|
|
88
89
|
attrs[k] = resolve(v) ?? v;
|
|
@@ -91,7 +92,7 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
91
92
|
node.tag,
|
|
92
93
|
attrs,
|
|
93
94
|
node.children.length > 0
|
|
94
|
-
? renderNodes(node.children,
|
|
95
|
+
? renderNodes(node.children, { nobr, locals })
|
|
95
96
|
: undefined,
|
|
96
97
|
);
|
|
97
98
|
}
|
|
@@ -193,7 +194,10 @@ function hasUnclosedBacktick(s: string): boolean {
|
|
|
193
194
|
return count % 2 === 1;
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
function getVariableTextValue(
|
|
197
|
+
function getVariableTextValue(
|
|
198
|
+
node: VariableNode,
|
|
199
|
+
locals: Record<string, unknown>,
|
|
200
|
+
): string {
|
|
197
201
|
const state = useStoryStore.getState();
|
|
198
202
|
const parts = node.name.split('.');
|
|
199
203
|
const root = parts[0]!;
|
|
@@ -201,6 +205,7 @@ function getVariableTextValue(node: VariableNode): string {
|
|
|
201
205
|
let value: unknown;
|
|
202
206
|
if (node.scope === 'variable') value = state.variables[root];
|
|
203
207
|
else if (node.scope === 'temporary') value = state.temporary[root];
|
|
208
|
+
else value = locals[root];
|
|
204
209
|
|
|
205
210
|
for (let i = 1; i < parts.length; i++) {
|
|
206
211
|
if (value == null || typeof value !== 'object') return '';
|
|
@@ -223,7 +228,7 @@ function getVariableTextValue(node: VariableNode): string {
|
|
|
223
228
|
*/
|
|
224
229
|
export function renderNodes(
|
|
225
230
|
nodes: ASTNode[],
|
|
226
|
-
options?: { nobr?: boolean },
|
|
231
|
+
options?: { nobr?: boolean; locals?: Record<string, unknown> },
|
|
227
232
|
): preact.ComponentChildren {
|
|
228
233
|
if (nodes.length === 0) return null;
|
|
229
234
|
|
|
@@ -236,6 +241,7 @@ export function renderNodes(
|
|
|
236
241
|
// Build combined markdown string with placeholders for non-text nodes
|
|
237
242
|
const components: preact.ComponentChildren[] = [];
|
|
238
243
|
let combined = '';
|
|
244
|
+
const locals = options?.locals ?? {};
|
|
239
245
|
|
|
240
246
|
for (let i = 0; i < nodes.length; i++) {
|
|
241
247
|
const node = nodes[i]!;
|
|
@@ -243,7 +249,7 @@ export function renderNodes(
|
|
|
243
249
|
combined += node.value.replace(/^[ \t]{4,}/gm, ' ');
|
|
244
250
|
} else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
|
|
245
251
|
// Inline variable value to avoid placeholder inside code span
|
|
246
|
-
combined += getVariableTextValue(node);
|
|
252
|
+
combined += getVariableTextValue(node, locals);
|
|
247
253
|
} else {
|
|
248
254
|
const phIdx = components.length;
|
|
249
255
|
components.push(renderSingleNode(node, i));
|
package/src/parser.ts
CHANGED
|
@@ -18,10 +18,25 @@ export interface StoryData {
|
|
|
18
18
|
userScript: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Decode the HTML entities that browsers use when serializing innerHTML.
|
|
23
|
+
* & is decoded last to avoid double-decoding (e.g. &lt; → <, not <).
|
|
24
|
+
*/
|
|
25
|
+
function decodeHtmlEntities(html: string): string {
|
|
26
|
+
return html
|
|
27
|
+
.replace(/</g, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"')
|
|
30
|
+
.replace(/'/g, "'")
|
|
31
|
+
.replace(/&/g, '&');
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
/**
|
|
22
35
|
* Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
36
|
+
*
|
|
37
|
+
* Uses innerHTML + entity decoding instead of textContent so that HTML
|
|
38
|
+
* tags inside passage markup (e.g. <div> inside {button}) are preserved
|
|
39
|
+
* regardless of whether the compiler HTML-encoded them.
|
|
25
40
|
*/
|
|
26
41
|
export function parseStoryData(): StoryData {
|
|
27
42
|
const storyEl = document.querySelector('tw-storydata');
|
|
@@ -52,7 +67,7 @@ export function parseStoryData(): StoryData {
|
|
|
52
67
|
const tags = (el.getAttribute('tags') || '')
|
|
53
68
|
.split(/\s+/)
|
|
54
69
|
.filter((t) => t.length > 0);
|
|
55
|
-
const content = el.
|
|
70
|
+
const content = decodeHtmlEntities(el.innerHTML);
|
|
56
71
|
|
|
57
72
|
const metadata: Record<string, string> = {};
|
|
58
73
|
const skipAttrs = new Set(['pid', 'name', 'tags']);
|
|
@@ -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 = {};
|