@rohal12/spindle 0.51.4 → 0.52.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/dist/pkg/headless.js +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +99 -14
- package/src/utils/stable-key.ts +14 -9
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
|
@@ -47,4 +47,17 @@ defineMacro({
|
|
|
47
47
|
|
|
48
48
|
return null;
|
|
49
49
|
},
|
|
50
|
+
text({ rawArgs, branches = [] }, ctx) {
|
|
51
|
+
const switchValue = ctx.evaluate(rawArgs);
|
|
52
|
+
let defaultBranch: (typeof branches)[number] | null = null;
|
|
53
|
+
for (let i = 1; i < branches.length; i++) {
|
|
54
|
+
const branch = branches[i]!;
|
|
55
|
+
if (branch.rawArgs === '') {
|
|
56
|
+
defaultBranch = branch;
|
|
57
|
+
} else if (switchValue === ctx.evaluate(branch.rawArgs)) {
|
|
58
|
+
return ctx.renderText(branch.children);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return defaultBranch ? ctx.renderText(defaultBranch.children) : '';
|
|
62
|
+
},
|
|
50
63
|
});
|
|
@@ -1,31 +1,52 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
+
import { MacroError } from './MacroError';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
5
6
|
name: 'unset',
|
|
6
7
|
render({ rawArgs }, ctx) {
|
|
7
8
|
const ran = ctx.hooks.useRef(false);
|
|
9
|
+
// Boxed: anything can be thrown, including null and other falsy values
|
|
10
|
+
const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
|
|
8
11
|
|
|
9
12
|
if (!ran.current) {
|
|
10
13
|
ran.current = true;
|
|
11
14
|
const state = useStoryStore.getState();
|
|
12
15
|
const name = rawArgs.trim();
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
try {
|
|
18
|
+
if (name.startsWith('$')) {
|
|
19
|
+
state.deleteVariable(name.slice(1));
|
|
20
|
+
} else if (name.startsWith('_')) {
|
|
21
|
+
state.deleteTemporary(name.slice(1));
|
|
22
|
+
} else if (name.startsWith('%')) {
|
|
23
|
+
state.deleteTransient(name.slice(1));
|
|
24
|
+
} else if (name.startsWith('@')) {
|
|
25
|
+
// Throws outside a locals scope ({for}, widget, {link}, {button})
|
|
26
|
+
ctx.update(name.slice(1), undefined);
|
|
27
|
+
} else {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
failure.current = { error: err };
|
|
23
34
|
console.error(
|
|
24
|
-
`spindle: {unset
|
|
35
|
+
`spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
|
|
36
|
+
err,
|
|
25
37
|
);
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
|
|
41
|
+
if (failure.current) {
|
|
42
|
+
return (
|
|
43
|
+
<MacroError
|
|
44
|
+
macro="unset"
|
|
45
|
+
error={failure.current.error}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
29
50
|
return null;
|
|
30
51
|
},
|
|
31
52
|
});
|
|
@@ -2,6 +2,14 @@ import { useStoryStore } from '../../store';
|
|
|
2
2
|
import { useContext } from 'preact/hooks';
|
|
3
3
|
import { LocalsValuesContext } from '../../markup/render';
|
|
4
4
|
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
5
|
+
import { RESERVED_NAME, ownValue } from '../../utils/namespace';
|
|
6
|
+
|
|
7
|
+
const SIGILS = {
|
|
8
|
+
variable: '$',
|
|
9
|
+
temporary: '_',
|
|
10
|
+
local: '@',
|
|
11
|
+
transient: '%',
|
|
12
|
+
} as const;
|
|
5
13
|
|
|
6
14
|
interface VarDisplayProps {
|
|
7
15
|
name: string;
|
|
@@ -17,19 +25,28 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
|
|
|
17
25
|
const localsValues = useContext(LocalsValuesContext);
|
|
18
26
|
const parts = name.split('.');
|
|
19
27
|
const root = parts[0]!;
|
|
28
|
+
// Own entries only, as the expression engine reads them
|
|
20
29
|
const storeValue = useStoryStore((s) =>
|
|
21
30
|
scope === 'variable'
|
|
22
|
-
? s.variables
|
|
31
|
+
? ownValue(s.variables, root)
|
|
23
32
|
: scope === 'temporary'
|
|
24
|
-
? s.temporary
|
|
33
|
+
? ownValue(s.temporary, root)
|
|
25
34
|
: scope === 'transient'
|
|
26
|
-
? s.transient
|
|
35
|
+
? ownValue(s.transient, root)
|
|
27
36
|
: undefined,
|
|
28
37
|
);
|
|
29
38
|
|
|
39
|
+
if (root === RESERVED_NAME) {
|
|
40
|
+
return (
|
|
41
|
+
<span class="error">
|
|
42
|
+
{`{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`}
|
|
43
|
+
</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
let value: unknown;
|
|
31
48
|
if (scope === 'local') {
|
|
32
|
-
value =
|
|
49
|
+
value = ownValue(localsValues, root);
|
|
33
50
|
} else {
|
|
34
51
|
value = storeValue;
|
|
35
52
|
}
|
|
@@ -2,11 +2,14 @@ import { registerWidget } from '../../widgets/widget-registry';
|
|
|
2
2
|
import { astContainsChildren } from '../../widgets/ast-scanner';
|
|
3
3
|
import { registerBlockMacro } from '../../markup/ast';
|
|
4
4
|
import { defineMacro } from '../../define-macro';
|
|
5
|
+
import { checkVariableName } from '../../utils/namespace';
|
|
6
|
+
import { MacroError } from './MacroError';
|
|
5
7
|
|
|
6
8
|
function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
|
|
7
9
|
const tokens = rawArgs.trim().split(/\s+/);
|
|
8
10
|
const name = tokens[0]!.replace(/["']/g, '');
|
|
9
11
|
const params = tokens.slice(1).filter((t) => t.startsWith('@'));
|
|
12
|
+
for (const param of params) checkVariableName(param.slice(1), param);
|
|
10
13
|
return { name, params };
|
|
11
14
|
}
|
|
12
15
|
|
|
@@ -14,12 +17,20 @@ defineMacro({
|
|
|
14
17
|
name: 'widget',
|
|
15
18
|
block: true,
|
|
16
19
|
render({ rawArgs, children = [] }, ctx) {
|
|
17
|
-
|
|
20
|
+
let parsed: ReturnType<typeof parseWidgetDef> | undefined;
|
|
21
|
+
let error: unknown;
|
|
22
|
+
try {
|
|
23
|
+
parsed = parseWidgetDef(rawArgs);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
error = err;
|
|
26
|
+
}
|
|
27
|
+
const { name, params } = parsed ?? { name: '', params: [] };
|
|
18
28
|
|
|
19
29
|
const childrenKey = JSON.stringify(children);
|
|
20
30
|
const paramsKey = params.join(',');
|
|
21
31
|
|
|
22
32
|
ctx.hooks.useLayoutEffect(() => {
|
|
33
|
+
if (!parsed) return;
|
|
23
34
|
// Widgets whose body renders {@children} take a closing tag; register
|
|
24
35
|
// them as block macros so passages parsed later nest their content.
|
|
25
36
|
const isBlock = astContainsChildren(children);
|
|
@@ -27,6 +38,14 @@ defineMacro({
|
|
|
27
38
|
if (isBlock) registerBlockMacro(name);
|
|
28
39
|
}, [name, childrenKey, paramsKey]);
|
|
29
40
|
|
|
41
|
+
if (error) {
|
|
42
|
+
return (
|
|
43
|
+
<MacroError
|
|
44
|
+
macro="widget"
|
|
45
|
+
error={error}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
30
49
|
return null;
|
|
31
50
|
},
|
|
32
51
|
});
|
|
@@ -11,12 +11,20 @@ import {
|
|
|
11
11
|
NobrContext,
|
|
12
12
|
InlineContext,
|
|
13
13
|
WidgetChildrenContext,
|
|
14
|
+
RawTextContext,
|
|
14
15
|
renderNodes,
|
|
15
16
|
} from '../../markup/render';
|
|
16
17
|
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
17
18
|
import { evaluate } from '../../expression';
|
|
18
19
|
import type { ASTNode } from '../../markup/ast';
|
|
19
|
-
import {
|
|
20
|
+
import { splitArgs } from './arg-utils';
|
|
21
|
+
import {
|
|
22
|
+
checkVariableName,
|
|
23
|
+
createNamespace,
|
|
24
|
+
withEntry,
|
|
25
|
+
} from '../../utils/namespace';
|
|
26
|
+
|
|
27
|
+
export { splitArgs };
|
|
20
28
|
|
|
21
29
|
interface WidgetInvocationProps {
|
|
22
30
|
body: ASTNode[];
|
|
@@ -25,75 +33,6 @@ interface WidgetInvocationProps {
|
|
|
25
33
|
invocationChildren?: ASTNode[];
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
/**
|
|
29
|
-
* Check whether a whitespace-delimited token looks like a standalone value
|
|
30
|
-
* (not an operator or partial expression).
|
|
31
|
-
*/
|
|
32
|
-
function isStandaloneValue(token: string): boolean {
|
|
33
|
-
const first = token[0]!;
|
|
34
|
-
// Quoted string
|
|
35
|
-
if (first === '"' || first === "'" || first === '`') return true;
|
|
36
|
-
// Variable ($var, _var, @var)
|
|
37
|
-
if (first === '$' || first === '_' || first === '@' || first === '%')
|
|
38
|
-
return true;
|
|
39
|
-
// Number literal
|
|
40
|
-
if (/\d/.test(first)) return true;
|
|
41
|
-
// Signed number (-1, +2)
|
|
42
|
-
if (
|
|
43
|
-
(first === '-' || first === '+') &&
|
|
44
|
-
token.length > 1 &&
|
|
45
|
-
/\d/.test(token[1]!)
|
|
46
|
-
)
|
|
47
|
-
return true;
|
|
48
|
-
// Grouped expression or collection literal
|
|
49
|
-
if (first === '(' || first === '[' || first === '{') return true;
|
|
50
|
-
// Boolean / null / undefined
|
|
51
|
-
if (/^(true|false|null|undefined)$/.test(token)) return true;
|
|
52
|
-
// Negation (!$flag, !true)
|
|
53
|
-
if (first === '!' && token.length > 1) return true;
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Try to split a raw string on whitespace at depth 0 (respecting strings,
|
|
59
|
-
* template literals, parentheses, brackets, and braces). Each resulting token
|
|
60
|
-
* must pass `isStandaloneValue()` or the split is rejected and `null` is
|
|
61
|
-
* returned.
|
|
62
|
-
*/
|
|
63
|
-
function trySplitOnWhitespace(raw: string): string[] | null {
|
|
64
|
-
const args = splitTopLevel(raw, isWhitespace).filter(Boolean);
|
|
65
|
-
|
|
66
|
-
// Need 2+ tokens
|
|
67
|
-
if (args.length < 2) return null;
|
|
68
|
-
|
|
69
|
-
// Every token must be a standalone value (not an operator)
|
|
70
|
-
for (const arg of args) {
|
|
71
|
-
if (!isStandaloneValue(arg)) return null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return args;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Split rawArgs by commas, respecting parentheses, brackets, braces, and
|
|
79
|
-
* strings. When no top-level commas are present, also supports adjacent quoted
|
|
80
|
-
* string literals separated by whitespace (e.g. `"Label" "target"`).
|
|
81
|
-
*/
|
|
82
|
-
export function splitArgs(raw: string): string[] {
|
|
83
|
-
const args = splitTopLevel(raw, (ch) => ch === ',').map((a) => a.trim());
|
|
84
|
-
const hasComma = args.length > 1;
|
|
85
|
-
if (args[args.length - 1] === '') args.pop();
|
|
86
|
-
|
|
87
|
-
// If no commas were found and we got a single expression, try splitting
|
|
88
|
-
// on whitespace at depth 0 (e.g. "Label" "target", $var "text", $x $y).
|
|
89
|
-
if (!hasComma && args.length === 1) {
|
|
90
|
-
const split = trySplitOnWhitespace(args[0]!);
|
|
91
|
-
if (split) return split;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return args;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
36
|
function WidgetBody({
|
|
98
37
|
body,
|
|
99
38
|
parentValues,
|
|
@@ -105,12 +44,13 @@ function WidgetBody({
|
|
|
105
44
|
}) {
|
|
106
45
|
const nobr = useContext(NobrContext);
|
|
107
46
|
const inline = useContext(InlineContext);
|
|
47
|
+
const raw = useContext(RawTextContext);
|
|
108
48
|
const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
|
|
109
49
|
{},
|
|
110
50
|
);
|
|
111
51
|
|
|
112
52
|
const localState = useMemo(
|
|
113
|
-
() => (
|
|
53
|
+
() => createNamespace(parentValues, ownKeys, localMutations),
|
|
114
54
|
[parentValues, ownKeys, localMutations],
|
|
115
55
|
);
|
|
116
56
|
|
|
@@ -122,7 +62,8 @@ function WidgetBody({
|
|
|
122
62
|
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
123
63
|
// second {set}) read the new value via getValues(); the state update
|
|
124
64
|
// then re-renders consumers of LocalsValuesContext.
|
|
125
|
-
|
|
65
|
+
checkVariableName(key, `@${key}`);
|
|
66
|
+
valuesRef.current = withEntry(valuesRef.current, key, value);
|
|
126
67
|
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
127
68
|
}, []);
|
|
128
69
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
@@ -130,7 +71,7 @@ function WidgetBody({
|
|
|
130
71
|
return (
|
|
131
72
|
<LocalsUpdateContext.Provider value={updater}>
|
|
132
73
|
<LocalsValuesContext.Provider value={localState}>
|
|
133
|
-
{renderNodes(body, { nobr, inline, locals: localState })}
|
|
74
|
+
{renderNodes(body, { nobr, inline, raw, locals: localState })}
|
|
134
75
|
</LocalsValuesContext.Provider>
|
|
135
76
|
</LocalsUpdateContext.Provider>
|
|
136
77
|
);
|
|
@@ -145,6 +86,7 @@ export function WidgetInvocation({
|
|
|
145
86
|
const parentValues = useContext(LocalsValuesContext);
|
|
146
87
|
const nobr = useContext(NobrContext);
|
|
147
88
|
const inline = useContext(InlineContext);
|
|
89
|
+
const raw = useContext(RawTextContext);
|
|
148
90
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
|
|
149
91
|
useMergedLocals();
|
|
150
92
|
|
|
@@ -155,7 +97,7 @@ export function WidgetInvocation({
|
|
|
155
97
|
if (params.length === 0) {
|
|
156
98
|
return (
|
|
157
99
|
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
158
|
-
{renderNodes(body, { nobr, inline, locals: parentValues })}
|
|
100
|
+
{renderNodes(body, { nobr, inline, raw, locals: parentValues })}
|
|
159
101
|
</WidgetChildrenContext.Provider>
|
|
160
102
|
);
|
|
161
103
|
}
|
|
@@ -183,7 +125,7 @@ export function WidgetInvocation({
|
|
|
183
125
|
}
|
|
184
126
|
|
|
185
127
|
const ownKeys = useMemo(() => {
|
|
186
|
-
const keys
|
|
128
|
+
const keys = createNamespace();
|
|
187
129
|
for (let i = 0; i < params.length; i++) {
|
|
188
130
|
keys[params[i]!.startsWith('@') ? params[i]!.slice(1) : params[i]!] =
|
|
189
131
|
values[i];
|
|
@@ -75,7 +75,40 @@ export function readWholeQuoted(src: string): string | null {
|
|
|
75
75
|
* lenient fallback for labels that are not one well-formed quoted string.
|
|
76
76
|
*/
|
|
77
77
|
export function stripLooseQuotes(src: string): string {
|
|
78
|
-
return /^["']?(.+?)["']
|
|
78
|
+
return /^["']?(.+?)["']?$/s.exec(src)?.[1] ?? src;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An operator character, or an operator keyword, at the end of code. */
|
|
82
|
+
const TRAILING_OPERATOR_RE =
|
|
83
|
+
/(?:[-+*/%&|^!~=<>?:,.]|(?:^|[^\w$@.])(?:typeof|void|new|delete|in|instanceof|await|yield))$/;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether `src` ends with an operator that still needs an operand (`$a +`,
|
|
87
|
+
* `$a+`, `$x ==`, `typeof`), so it cannot be a complete value on its own.
|
|
88
|
+
* Only code counts: the closing `/` of a regex literal is not division, a
|
|
89
|
+
* variable reference (`$a`, `%name`, ...) is an operand, trailing comments are skipped,
|
|
90
|
+
* and a postfix `++` or `--` completes its operand.
|
|
91
|
+
*/
|
|
92
|
+
export function endsWithOperator(src: string): boolean {
|
|
93
|
+
// Index just past the last code token, or -1 when a literal or transient
|
|
94
|
+
// reference ends the source.
|
|
95
|
+
let end = -1;
|
|
96
|
+
lexJs(src, {
|
|
97
|
+
code(ch, i, nesting) {
|
|
98
|
+
if (nesting === 0 && !isWhitespace(ch)) end = i + 1;
|
|
99
|
+
},
|
|
100
|
+
literal(text, _i, nesting) {
|
|
101
|
+
const comment = text.startsWith('//') || text.startsWith('/*');
|
|
102
|
+
if (nesting === 0 && !comment) end = -1;
|
|
103
|
+
},
|
|
104
|
+
variable(_sigil, _name, _i, nesting) {
|
|
105
|
+
if (nesting === 0) end = -1;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (end < 0) return false;
|
|
109
|
+
const code = src.slice(0, end);
|
|
110
|
+
if (/(?:\+\+|--)$/.test(code)) return false;
|
|
111
|
+
return TRAILING_OPERATOR_RE.test(code);
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
/**
|
|
@@ -118,3 +151,76 @@ export function splitTopLevel(
|
|
|
118
151
|
segments.push(src.slice(from));
|
|
119
152
|
return segments;
|
|
120
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check whether a whitespace-delimited token looks like a standalone value
|
|
157
|
+
* (not an operator or partial expression): it starts like a value and does
|
|
158
|
+
* not end with an operator still waiting for its operand.
|
|
159
|
+
*/
|
|
160
|
+
function isStandaloneValue(token: string): boolean {
|
|
161
|
+
return startsLikeValue(token) && !endsWithOperator(token);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function startsLikeValue(token: string): boolean {
|
|
165
|
+
const first = token[0]!;
|
|
166
|
+
// Quoted string
|
|
167
|
+
if (first === '"' || first === "'" || first === '`') return true;
|
|
168
|
+
// Variable ($var, _var, @var, %var), but not the modulo operator in `$a % 2`
|
|
169
|
+
if (/^[$_@]\w|^%[A-Za-z_]/.test(token)) return true;
|
|
170
|
+
// Number literal
|
|
171
|
+
if (/\d/.test(first)) return true;
|
|
172
|
+
// Signed number (-1, +2)
|
|
173
|
+
if (
|
|
174
|
+
(first === '-' || first === '+') &&
|
|
175
|
+
token.length > 1 &&
|
|
176
|
+
/\d/.test(token[1]!)
|
|
177
|
+
)
|
|
178
|
+
return true;
|
|
179
|
+
// Grouped expression or collection literal
|
|
180
|
+
if (first === '(' || first === '[' || first === '{') return true;
|
|
181
|
+
// Boolean / null / undefined, alone or leading an expression (true||$x)
|
|
182
|
+
if (/^(?:true|false|null|undefined)(?![\w$])/.test(token)) return true;
|
|
183
|
+
// Negation (!$flag, !true), but not the operators != and !==
|
|
184
|
+
if (first === '!' && token.length > 1 && token[1] !== '=') return true;
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Try to split a raw string on whitespace at depth 0 (respecting strings,
|
|
190
|
+
* template literals, parentheses, brackets, and braces). Each resulting token
|
|
191
|
+
* must pass `isStandaloneValue()` or the split is rejected and `null` is
|
|
192
|
+
* returned.
|
|
193
|
+
*/
|
|
194
|
+
function trySplitOnWhitespace(raw: string): string[] | null {
|
|
195
|
+
const args = splitTopLevel(raw, isWhitespace).filter(Boolean);
|
|
196
|
+
|
|
197
|
+
// Need 2+ tokens
|
|
198
|
+
if (args.length < 2) return null;
|
|
199
|
+
|
|
200
|
+
// Every token must be a standalone value (not an operator)
|
|
201
|
+
for (const arg of args) {
|
|
202
|
+
if (!isStandaloneValue(arg)) return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return args;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Split rawArgs by commas, respecting parentheses, brackets, braces, and
|
|
210
|
+
* strings. When no top-level commas are present, also supports adjacent quoted
|
|
211
|
+
* string literals separated by whitespace (e.g. `"Label" "target"`).
|
|
212
|
+
*/
|
|
213
|
+
export function splitArgs(raw: string): string[] {
|
|
214
|
+
const args = splitTopLevel(raw, (ch) => ch === ',').map((a) => a.trim());
|
|
215
|
+
const hasComma = args.length > 1;
|
|
216
|
+
if (args[args.length - 1] === '') args.pop();
|
|
217
|
+
|
|
218
|
+
// If no commas were found and we got a single expression, try splitting
|
|
219
|
+
// on whitespace at depth 0 (e.g. "Label" "target", $var "text", $x $y).
|
|
220
|
+
if (!hasComma && args.length === 1) {
|
|
221
|
+
const split = trySplitOnWhitespace(args[0]!);
|
|
222
|
+
if (split) return split;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return args;
|
|
226
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { render } from 'preact';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import {
|
|
4
|
+
renderNodes,
|
|
5
|
+
LocalsUpdateContext,
|
|
6
|
+
LocalsValuesContext,
|
|
7
|
+
NobrContext,
|
|
8
|
+
InlineContext,
|
|
9
|
+
WidgetChildrenContext,
|
|
10
|
+
} from '../../markup/render';
|
|
11
|
+
import type { ASTNode } from '../../markup/ast';
|
|
12
|
+
import { liveLocalsView } from '../../utils/live-locals';
|
|
13
|
+
import { runWithCommittedMutations } from '../../execute-mutation';
|
|
14
|
+
import { RepeatContext } from './Repeat';
|
|
15
|
+
import { DialogCloseContext } from '../PassageDialog';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return a function that runs a macro body once, outside the passage tree:
|
|
19
|
+
* it renders `children` into a detached node, so the body's macros ({set},
|
|
20
|
+
* {if}, {goto}, ...) fire their side effects through the normal Preact
|
|
21
|
+
* pipeline, then unmounts it. Used by {button} and {link} on click.
|
|
22
|
+
*
|
|
23
|
+
* A detached render starts with no contexts, so the hook captures the ones
|
|
24
|
+
* the owning macro sees and provides them again: the locals scope (read
|
|
25
|
+
* through a live view, so a local assigned earlier in the body is visible),
|
|
26
|
+
* nobr/inline rendering, the enclosing block widget's {@children}, the
|
|
27
|
+
* enclosing {repeat}'s {stop} and the enclosing dialog's close callback.
|
|
28
|
+
* The body then behaves as if it rendered in place.
|
|
29
|
+
*
|
|
30
|
+
* The body's macros read story state from the store. Run from mutation code
|
|
31
|
+
* (Story.performAction, a click the code dispatches), it runs in program
|
|
32
|
+
* order: the code's writes so far are committed first, so the body sees
|
|
33
|
+
* them, and the code goes on from the state the body leaves (see
|
|
34
|
+
* runWithCommittedMutations).
|
|
35
|
+
*/
|
|
36
|
+
export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
37
|
+
const updater = useContext(LocalsUpdateContext);
|
|
38
|
+
const nobr = useContext(NobrContext);
|
|
39
|
+
const inline = useContext(InlineContext);
|
|
40
|
+
const widgetChildren = useContext(WidgetChildrenContext);
|
|
41
|
+
const repeat = useContext(RepeatContext);
|
|
42
|
+
const closeDialog = useContext(DialogCloseContext);
|
|
43
|
+
|
|
44
|
+
const run = (children: ASTNode[]) => {
|
|
45
|
+
const locals = liveLocalsView(updater.getValues);
|
|
46
|
+
const container = document.createElement('div');
|
|
47
|
+
render(
|
|
48
|
+
<LocalsUpdateContext.Provider value={updater}>
|
|
49
|
+
<LocalsValuesContext.Provider value={locals}>
|
|
50
|
+
<NobrContext.Provider value={nobr}>
|
|
51
|
+
<InlineContext.Provider value={inline}>
|
|
52
|
+
<WidgetChildrenContext.Provider value={widgetChildren}>
|
|
53
|
+
<RepeatContext.Provider value={repeat}>
|
|
54
|
+
<DialogCloseContext.Provider value={closeDialog}>
|
|
55
|
+
{renderNodes(children, { nobr, inline, locals })}
|
|
56
|
+
</DialogCloseContext.Provider>
|
|
57
|
+
</RepeatContext.Provider>
|
|
58
|
+
</WidgetChildrenContext.Provider>
|
|
59
|
+
</InlineContext.Provider>
|
|
60
|
+
</NobrContext.Provider>
|
|
61
|
+
</LocalsValuesContext.Provider>
|
|
62
|
+
</LocalsUpdateContext.Provider>,
|
|
63
|
+
container,
|
|
64
|
+
);
|
|
65
|
+
render(null, container);
|
|
66
|
+
};
|
|
67
|
+
return (children) => runWithCommittedMutations(() => run(children));
|
|
68
|
+
}
|
|
@@ -5,7 +5,8 @@ export function parseVarArgs(rawArgs: string): {
|
|
|
5
5
|
varName: string;
|
|
6
6
|
placeholder: string;
|
|
7
7
|
} {
|
|
8
|
-
|
|
8
|
+
// `s`: a quoted placeholder may span lines.
|
|
9
|
+
const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/s);
|
|
9
10
|
if (!match) {
|
|
10
11
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
11
12
|
}
|
|
@@ -28,7 +29,7 @@ export function extractOptions(children: ASTNode[]): string[] {
|
|
|
28
29
|
const raw = node.rawArgs.trim();
|
|
29
30
|
// Strip surrounding quotes so {option "Long Sword"} gives "Long Sword"
|
|
30
31
|
const stripped =
|
|
31
|
-
readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1
|
|
32
|
+
readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/s, '$2');
|
|
32
33
|
options.push(stripped);
|
|
33
34
|
}
|
|
34
35
|
}
|
package/src/define-macro.ts
CHANGED
|
@@ -16,14 +16,16 @@ import {
|
|
|
16
16
|
LocalsValuesContext,
|
|
17
17
|
NobrContext,
|
|
18
18
|
InlineContext,
|
|
19
|
+
RawTextContext,
|
|
19
20
|
renderNodes as _renderNodes,
|
|
20
21
|
renderInlineNodes,
|
|
21
22
|
} from './markup/render';
|
|
22
23
|
import type { ASTNode } from './markup/ast';
|
|
23
|
-
import { executeMutation } from './execute-mutation';
|
|
24
|
+
import { executeMutation, readState } from './execute-mutation';
|
|
24
25
|
import { evaluate } from './expression';
|
|
25
26
|
import { useStoryStore } from './store';
|
|
26
27
|
import { getByPath, setByPath } from './utils/object-path';
|
|
28
|
+
import { RESERVED_NAME } from './utils/namespace';
|
|
27
29
|
import { useAction } from './hooks/use-action';
|
|
28
30
|
import type { UseActionOptions } from './hooks/use-action';
|
|
29
31
|
import { collectText } from './utils/extract-text';
|
|
@@ -31,12 +33,15 @@ import { currentSourceLocation } from './utils/source-location';
|
|
|
31
33
|
import { parseVarArgs, extractOptions } from './components/macros/option-utils';
|
|
32
34
|
import {
|
|
33
35
|
registerMacro,
|
|
36
|
+
registerMacroText,
|
|
34
37
|
registerSubMacro,
|
|
35
38
|
registerMacroMetadata,
|
|
36
39
|
} from './registry';
|
|
37
|
-
import type { MacroProps, ParameterDef } from './registry';
|
|
40
|
+
import type { MacroProps, MacroTextContext, ParameterDef } from './registry';
|
|
38
41
|
import { registerBlockMacro } from './markup/ast';
|
|
39
42
|
|
|
43
|
+
export type { MacroTextContext };
|
|
44
|
+
|
|
40
45
|
export function macroClass(type: string, className?: string): string {
|
|
41
46
|
const base = `macro-${type}`;
|
|
42
47
|
return className ? `${base} ${className}` : base;
|
|
@@ -91,6 +96,12 @@ export interface MacroDefinition {
|
|
|
91
96
|
description?: string;
|
|
92
97
|
parameters?: ParameterDef[];
|
|
93
98
|
render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
|
|
99
|
+
/**
|
|
100
|
+
* The macro's text form, used where markup becomes a string: HTML
|
|
101
|
+
* attribute values, image alt text and link titles, macro labels. Without
|
|
102
|
+
* one, the macro can't be used there and is reported as an error.
|
|
103
|
+
*/
|
|
104
|
+
text?: (props: MacroProps, ctx: MacroTextContext) => string;
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
const sharedHooks = {
|
|
@@ -122,6 +133,7 @@ export function defineMacro(
|
|
|
122
133
|
const { update, getValues } = useContext(LocalsUpdateContext);
|
|
123
134
|
const nobr = useContext(NobrContext);
|
|
124
135
|
const inline = useContext(InlineContext);
|
|
136
|
+
const raw = useContext(RawTextContext);
|
|
125
137
|
const localsValues = useContext(LocalsValuesContext);
|
|
126
138
|
const renderNodes = (
|
|
127
139
|
nodes: ASTNode[],
|
|
@@ -129,9 +141,16 @@ export function defineMacro(
|
|
|
129
141
|
nobr?: boolean;
|
|
130
142
|
locals?: Record<string, unknown>;
|
|
131
143
|
inline?: boolean;
|
|
144
|
+
raw?: boolean;
|
|
132
145
|
},
|
|
133
146
|
) =>
|
|
134
|
-
_renderNodes(nodes, {
|
|
147
|
+
_renderNodes(nodes, {
|
|
148
|
+
nobr,
|
|
149
|
+
inline,
|
|
150
|
+
raw,
|
|
151
|
+
locals: localsValues,
|
|
152
|
+
...options,
|
|
153
|
+
});
|
|
135
154
|
const ctx: MacroContext = {
|
|
136
155
|
collectText,
|
|
137
156
|
sourceLocation: currentSourceLocation,
|
|
@@ -177,10 +196,17 @@ export function defineMacro(
|
|
|
177
196
|
|
|
178
197
|
const varExpr = firstToken.replace(/["']/g, '').replace(/^\$/, '');
|
|
179
198
|
const segments = varExpr.split('.');
|
|
199
|
+
if (segments.includes(RESERVED_NAME)) {
|
|
200
|
+
return h(
|
|
201
|
+
'span',
|
|
202
|
+
{ class: 'error' },
|
|
203
|
+
`{${config.name}}: "$${varExpr}" cannot be bound: ${RESERVED_NAME} is reserved`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
180
206
|
ctx.varName = varExpr;
|
|
181
207
|
ctx.value = useStoryStore((s) => getByPath(s.variables, segments));
|
|
182
|
-
|
|
183
|
-
|
|
208
|
+
// In program order, also when mutation code performs the input
|
|
209
|
+
ctx.getValue = () => getByPath(readState().variables, segments);
|
|
184
210
|
ctx.setValue = (value: unknown) => {
|
|
185
211
|
useStoryStore.setState((state) => {
|
|
186
212
|
setByPath(state.variables, segments, value, {
|
|
@@ -194,6 +220,7 @@ export function defineMacro(
|
|
|
194
220
|
}
|
|
195
221
|
|
|
196
222
|
registerMacro(config.name, Wrapper);
|
|
223
|
+
registerMacroText(config.name, config.text);
|
|
197
224
|
|
|
198
225
|
// Store metadata for tooling API
|
|
199
226
|
const isBlock =
|