@rohal12/spindle 0.51.3 → 0.52.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/dist/pkg/headless.js +4833 -1603
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1658 -189
- package/package.json +5 -2
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +277 -90
- 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 +9 -32
- package/src/components/macros/Checkbox.tsx +10 -4
- package/src/components/macros/Computed.tsx +19 -13
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +33 -4
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +15 -24
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +14 -46
- package/src/components/macros/Meter.tsx +26 -40
- 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 +27 -2
- package/src/components/macros/SaveManager.tsx +39 -14
- 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 +30 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +20 -158
- package/src/components/macros/arg-utils.ts +226 -0
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +10 -5
- package/src/define-macro.ts +44 -38
- package/src/execute-mutation.ts +499 -28
- package/src/expression.ts +88 -272
- package/src/hooks/use-action.ts +18 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +10 -1
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1460 -0
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +552 -113
- package/src/markup/tokenizer.ts +601 -119
- package/src/prng.ts +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +368 -153
- package/src/saves/storage.ts +24 -7
- package/src/saves/types.ts +20 -6
- package/src/store.ts +549 -137
- package/src/story-api.ts +46 -81
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -2
- 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 +194 -0
- package/src/utils/stable-key.ts +82 -0
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
|
@@ -9,12 +9,22 @@ import {
|
|
|
9
9
|
LocalsValuesContext,
|
|
10
10
|
LocalsUpdateContext,
|
|
11
11
|
NobrContext,
|
|
12
|
+
InlineContext,
|
|
12
13
|
WidgetChildrenContext,
|
|
14
|
+
RawTextContext,
|
|
13
15
|
renderNodes,
|
|
14
16
|
} from '../../markup/render';
|
|
15
17
|
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
16
18
|
import { evaluate } from '../../expression';
|
|
17
19
|
import type { ASTNode } from '../../markup/ast';
|
|
20
|
+
import { splitArgs } from './arg-utils';
|
|
21
|
+
import {
|
|
22
|
+
checkVariableName,
|
|
23
|
+
createNamespace,
|
|
24
|
+
withEntry,
|
|
25
|
+
} from '../../utils/namespace';
|
|
26
|
+
|
|
27
|
+
export { splitArgs };
|
|
18
28
|
|
|
19
29
|
interface WidgetInvocationProps {
|
|
20
30
|
body: ASTNode[];
|
|
@@ -23,159 +33,6 @@ interface WidgetInvocationProps {
|
|
|
23
33
|
invocationChildren?: ASTNode[];
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
/**
|
|
27
|
-
* Check whether a whitespace-delimited token looks like a standalone value
|
|
28
|
-
* (not an operator or partial expression).
|
|
29
|
-
*/
|
|
30
|
-
function isStandaloneValue(token: string): boolean {
|
|
31
|
-
const first = token[0]!;
|
|
32
|
-
// Quoted string
|
|
33
|
-
if (first === '"' || first === "'" || first === '`') return true;
|
|
34
|
-
// Variable ($var, _var, @var)
|
|
35
|
-
if (first === '$' || first === '_' || first === '@' || first === '%')
|
|
36
|
-
return true;
|
|
37
|
-
// Number literal
|
|
38
|
-
if (/\d/.test(first)) return true;
|
|
39
|
-
// Signed number (-1, +2)
|
|
40
|
-
if (
|
|
41
|
-
(first === '-' || first === '+') &&
|
|
42
|
-
token.length > 1 &&
|
|
43
|
-
/\d/.test(token[1]!)
|
|
44
|
-
)
|
|
45
|
-
return true;
|
|
46
|
-
// Grouped expression or collection literal
|
|
47
|
-
if (first === '(' || first === '[' || first === '{') return true;
|
|
48
|
-
// Boolean / null / undefined
|
|
49
|
-
if (/^(true|false|null|undefined)$/.test(token)) return true;
|
|
50
|
-
// Negation (!$flag, !true)
|
|
51
|
-
if (first === '!' && token.length > 1) return true;
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Try to split a raw string on whitespace at depth 0 (respecting quotes,
|
|
57
|
-
* parentheses, brackets, and braces). Each resulting token must pass
|
|
58
|
-
* `isStandaloneValue()` or the split is rejected and `null` is returned.
|
|
59
|
-
*/
|
|
60
|
-
function trySplitOnWhitespace(raw: string): string[] | null {
|
|
61
|
-
const args: string[] = [];
|
|
62
|
-
let current = '';
|
|
63
|
-
let depth = 0;
|
|
64
|
-
let inString: string | null = null;
|
|
65
|
-
|
|
66
|
-
for (let i = 0; i < raw.length; i++) {
|
|
67
|
-
const ch = raw[i]!;
|
|
68
|
-
|
|
69
|
-
if (inString) {
|
|
70
|
-
current += ch;
|
|
71
|
-
if (ch === inString && raw[i - 1] !== '\\') inString = null;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (ch === '"' || ch === "'" || ch === '`') {
|
|
76
|
-
inString = ch;
|
|
77
|
-
current += ch;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (ch === '(' || ch === '[' || ch === '{') {
|
|
82
|
-
depth++;
|
|
83
|
-
current += ch;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (ch === ')' || ch === ']' || ch === '}') {
|
|
88
|
-
depth--;
|
|
89
|
-
current += ch;
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (/\s/.test(ch) && depth === 0) {
|
|
94
|
-
if (current) {
|
|
95
|
-
args.push(current);
|
|
96
|
-
current = '';
|
|
97
|
-
}
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
current += ch;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (current) args.push(current);
|
|
105
|
-
|
|
106
|
-
// Need 2+ tokens
|
|
107
|
-
if (args.length < 2) return null;
|
|
108
|
-
|
|
109
|
-
// Every token must be a standalone value (not an operator)
|
|
110
|
-
for (const arg of args) {
|
|
111
|
-
if (!isStandaloneValue(arg)) return null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return args;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Split rawArgs by commas, respecting parentheses, brackets, braces, and
|
|
119
|
-
* strings. When no top-level commas are present, also supports adjacent quoted
|
|
120
|
-
* string literals separated by whitespace (e.g. `"Label" "target"`).
|
|
121
|
-
*/
|
|
122
|
-
export function splitArgs(raw: string): string[] {
|
|
123
|
-
const args: string[] = [];
|
|
124
|
-
let current = '';
|
|
125
|
-
let depth = 0;
|
|
126
|
-
let inString: string | null = null;
|
|
127
|
-
let hasComma = false;
|
|
128
|
-
|
|
129
|
-
for (let i = 0; i < raw.length; i++) {
|
|
130
|
-
const ch = raw[i]!;
|
|
131
|
-
|
|
132
|
-
if (inString) {
|
|
133
|
-
current += ch;
|
|
134
|
-
if (ch === inString && raw[i - 1] !== '\\') inString = null;
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (ch === '"' || ch === "'" || ch === '`') {
|
|
139
|
-
inString = ch;
|
|
140
|
-
current += ch;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (ch === '(' || ch === '[' || ch === '{') {
|
|
145
|
-
depth++;
|
|
146
|
-
current += ch;
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (ch === ')' || ch === ']' || ch === '}') {
|
|
151
|
-
depth--;
|
|
152
|
-
current += ch;
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (ch === ',' && depth === 0) {
|
|
157
|
-
hasComma = true;
|
|
158
|
-
args.push(current.trim());
|
|
159
|
-
current = '';
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
current += ch;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const last = current.trim();
|
|
167
|
-
if (last) args.push(last);
|
|
168
|
-
|
|
169
|
-
// If no commas were found and we got a single expression, try splitting
|
|
170
|
-
// on whitespace at depth 0 (e.g. "Label" "target", $var "text", $x $y).
|
|
171
|
-
if (!hasComma && args.length === 1) {
|
|
172
|
-
const split = trySplitOnWhitespace(args[0]!);
|
|
173
|
-
if (split) return split;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return args;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
36
|
function WidgetBody({
|
|
180
37
|
body,
|
|
181
38
|
parentValues,
|
|
@@ -186,12 +43,14 @@ function WidgetBody({
|
|
|
186
43
|
ownKeys: Record<string, unknown>;
|
|
187
44
|
}) {
|
|
188
45
|
const nobr = useContext(NobrContext);
|
|
46
|
+
const inline = useContext(InlineContext);
|
|
47
|
+
const raw = useContext(RawTextContext);
|
|
189
48
|
const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
|
|
190
49
|
{},
|
|
191
50
|
);
|
|
192
51
|
|
|
193
52
|
const localState = useMemo(
|
|
194
|
-
() => (
|
|
53
|
+
() => createNamespace(parentValues, ownKeys, localMutations),
|
|
195
54
|
[parentValues, ownKeys, localMutations],
|
|
196
55
|
);
|
|
197
56
|
|
|
@@ -203,7 +62,8 @@ function WidgetBody({
|
|
|
203
62
|
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
204
63
|
// second {set}) read the new value via getValues(); the state update
|
|
205
64
|
// then re-renders consumers of LocalsValuesContext.
|
|
206
|
-
|
|
65
|
+
checkVariableName(key, `@${key}`);
|
|
66
|
+
valuesRef.current = withEntry(valuesRef.current, key, value);
|
|
207
67
|
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
208
68
|
}, []);
|
|
209
69
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
@@ -211,7 +71,7 @@ function WidgetBody({
|
|
|
211
71
|
return (
|
|
212
72
|
<LocalsUpdateContext.Provider value={updater}>
|
|
213
73
|
<LocalsValuesContext.Provider value={localState}>
|
|
214
|
-
{renderNodes(body, { nobr, locals: localState })}
|
|
74
|
+
{renderNodes(body, { nobr, inline, raw, locals: localState })}
|
|
215
75
|
</LocalsValuesContext.Provider>
|
|
216
76
|
</LocalsUpdateContext.Provider>
|
|
217
77
|
);
|
|
@@ -225,6 +85,8 @@ export function WidgetInvocation({
|
|
|
225
85
|
}: WidgetInvocationProps) {
|
|
226
86
|
const parentValues = useContext(LocalsValuesContext);
|
|
227
87
|
const nobr = useContext(NobrContext);
|
|
88
|
+
const inline = useContext(InlineContext);
|
|
89
|
+
const raw = useContext(RawTextContext);
|
|
228
90
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
|
|
229
91
|
useMergedLocals();
|
|
230
92
|
|
|
@@ -235,7 +97,7 @@ export function WidgetInvocation({
|
|
|
235
97
|
if (params.length === 0) {
|
|
236
98
|
return (
|
|
237
99
|
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
238
|
-
{renderNodes(body, { nobr, locals: parentValues })}
|
|
100
|
+
{renderNodes(body, { nobr, inline, raw, locals: parentValues })}
|
|
239
101
|
</WidgetChildrenContext.Provider>
|
|
240
102
|
);
|
|
241
103
|
}
|
|
@@ -263,7 +125,7 @@ export function WidgetInvocation({
|
|
|
263
125
|
}
|
|
264
126
|
|
|
265
127
|
const ownKeys = useMemo(() => {
|
|
266
|
-
const keys
|
|
128
|
+
const keys = createNamespace();
|
|
267
129
|
for (let i = 0; i < params.length; i++) {
|
|
268
130
|
keys[params[i]!.startsWith('@') ? params[i]!.slice(1) : params[i]!] =
|
|
269
131
|
values[i];
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for macros that split or read their raw argument text by
|
|
3
|
+
* hand. Their lexical model is the expression lexer's (`js-lexer.ts`):
|
|
4
|
+
* string, template and regex literals and comments are opaque (a backslash
|
|
5
|
+
* inside a literal escapes the character after it, and `${…}` parts of a
|
|
6
|
+
* template are code), `/` opens a regex only where an operand is expected,
|
|
7
|
+
* and `(`/`[`/`{` nest.
|
|
8
|
+
*/
|
|
9
|
+
import { lexJs, lexTemplate, scanStringLiteral } from '../../js-lexer';
|
|
10
|
+
|
|
11
|
+
/** True for a whitespace character. */
|
|
12
|
+
export function isWhitespace(ch: string): boolean {
|
|
13
|
+
return /\s/.test(ch);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isOpener(ch: string): boolean {
|
|
17
|
+
return ch === '(' || ch === '[' || ch === '{';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isCloser(ch: string): boolean {
|
|
21
|
+
return ch === ')' || ch === ']' || ch === '}';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Given the index of an opening quote (`"`, `'` or a backtick), return the
|
|
26
|
+
* index just past its closing quote, or `src.length` if it is unterminated.
|
|
27
|
+
* A backslash escapes the character after it, so a quote preceded by an even
|
|
28
|
+
* run of backslashes still closes the string and one preceded by an odd run
|
|
29
|
+
* does not. Inside a template literal, `${…}` interpolations are lexed as
|
|
30
|
+
* code, so quotes, backticks, braces and regexes inside them do not end it.
|
|
31
|
+
*/
|
|
32
|
+
export function skipString(src: string, start: number): number {
|
|
33
|
+
return src[start] === '`'
|
|
34
|
+
? lexTemplate(src, start)
|
|
35
|
+
: scanStringLiteral(src, start).end;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Undo the escapes allowed in a literal quoted macro argument: `\"`, `\'`
|
|
40
|
+
* and `\\` become `"`, `'` and `\`. Other backslash sequences (`\n`, `\d`)
|
|
41
|
+
* are kept as written, so code and regex text inside the string survive.
|
|
42
|
+
*/
|
|
43
|
+
export function unescapeQuoted(body: string): string {
|
|
44
|
+
return body.replace(/\\(["'\\])/g, '$1');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the `"…"` or `'…'` string whose opening quote is at `start`.
|
|
49
|
+
* Returns its unescaped value and the index just past the closing quote, or
|
|
50
|
+
* `null` if there is no such quote at `start` or the string is unterminated.
|
|
51
|
+
*/
|
|
52
|
+
export function readQuoted(
|
|
53
|
+
src: string,
|
|
54
|
+
start: number,
|
|
55
|
+
): { value: string; end: number } | null {
|
|
56
|
+
const quote = src[start];
|
|
57
|
+
if (quote !== '"' && quote !== "'") return null;
|
|
58
|
+
const { end, closed } = scanStringLiteral(src, start);
|
|
59
|
+
if (!closed) return null;
|
|
60
|
+
return { value: unescapeQuoted(src.slice(start + 1, end - 1)), end };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The unescaped value of `src` when it is exactly one `"…"` or `'…'` string
|
|
65
|
+
* (as with `readQuoted`), otherwise `null`.
|
|
66
|
+
*/
|
|
67
|
+
export function readWholeQuoted(src: string): string | null {
|
|
68
|
+
const quoted = readQuoted(src, 0);
|
|
69
|
+
return quoted && quoted.end === src.length ? quoted.value : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip an optional quote from each end of an unquoted-or-loosely-quoted
|
|
74
|
+
* argument (`"Red`, `Red'`), leaving at least one character. This is the
|
|
75
|
+
* lenient fallback for labels that are not one well-formed quoted string.
|
|
76
|
+
*/
|
|
77
|
+
export function stripLooseQuotes(src: string): string {
|
|
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);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Indices of the characters in `src` that satisfy `isSeparator` and sit at
|
|
116
|
+
* depth 0: in code outside string, template and regex literals and comments,
|
|
117
|
+
* and outside `()`, `[]` and `{}` pairs.
|
|
118
|
+
*/
|
|
119
|
+
export function topLevelIndices(
|
|
120
|
+
src: string,
|
|
121
|
+
isSeparator: (ch: string) => boolean,
|
|
122
|
+
): number[] {
|
|
123
|
+
const indices: number[] = [];
|
|
124
|
+
let depth = 0;
|
|
125
|
+
lexJs(src, {
|
|
126
|
+
code(ch, i, nesting) {
|
|
127
|
+
if (nesting > 0) return; // inside a template interpolation
|
|
128
|
+
if (isOpener(ch)) depth++;
|
|
129
|
+
else if (isCloser(ch)) depth--;
|
|
130
|
+
else if (depth === 0 && isSeparator(ch)) indices.push(i);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return indices;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Split `src` at every depth-0 separator (see `topLevelIndices`). Segments
|
|
138
|
+
* are returned as written, untrimmed, and empty segments are kept, so
|
|
139
|
+
* `n` separators always give `n + 1` segments.
|
|
140
|
+
*/
|
|
141
|
+
export function splitTopLevel(
|
|
142
|
+
src: string,
|
|
143
|
+
isSeparator: (ch: string) => boolean,
|
|
144
|
+
): string[] {
|
|
145
|
+
const segments: string[] = [];
|
|
146
|
+
let from = 0;
|
|
147
|
+
for (const i of topLevelIndices(src, isSeparator)) {
|
|
148
|
+
segments.push(src.slice(from, i));
|
|
149
|
+
from = i + 1;
|
|
150
|
+
}
|
|
151
|
+
segments.push(src.slice(from));
|
|
152
|
+
return segments;
|
|
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
|
+
}
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import type { ASTNode } from '../../markup/ast';
|
|
2
|
+
import { readWholeQuoted } from './arg-utils';
|
|
2
3
|
|
|
3
4
|
export function parseVarArgs(rawArgs: string): {
|
|
4
5
|
varName: string;
|
|
5
6
|
placeholder: string;
|
|
6
7
|
} {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
);
|
|
8
|
+
// `s`: a quoted placeholder may span lines.
|
|
9
|
+
const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/s);
|
|
10
10
|
if (!match) {
|
|
11
11
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
12
12
|
}
|
|
13
13
|
const varName = match[1]!.replace(/["']/g, '');
|
|
14
|
-
|
|
14
|
+
// A quoted placeholder accepts \" \' and \\ escapes.
|
|
15
|
+
const quoted = match[2];
|
|
16
|
+
const placeholder = quoted
|
|
17
|
+
? (readWholeQuoted(quoted) ?? quoted.slice(1, -1))
|
|
18
|
+
: '';
|
|
15
19
|
return { varName, placeholder };
|
|
16
20
|
}
|
|
17
21
|
|
|
@@ -24,7 +28,8 @@ export function extractOptions(children: ASTNode[]): string[] {
|
|
|
24
28
|
if (node.type === 'macro' && node.name === 'option') {
|
|
25
29
|
const raw = node.rawArgs.trim();
|
|
26
30
|
// Strip surrounding quotes so {option "Long Sword"} gives "Long Sword"
|
|
27
|
-
const stripped =
|
|
31
|
+
const stripped =
|
|
32
|
+
readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/s, '$2');
|
|
28
33
|
options.push(stripped);
|
|
29
34
|
}
|
|
30
35
|
}
|