@rohal12/spindle 0.53.0 → 0.55.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/README.md +3 -1
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +10058 -2075
- package/dist/pkg/macro-registry.json +4 -4
- package/dist/pkg/story-variables.js +10197 -1925
- package/dist/pkg/tooling.js +15 -1
- package/package.json +3 -1
- package/src/code-check.ts +314 -0
- package/src/components/Passage.tsx +3 -3
- package/src/components/PassageDialog.tsx +2 -4
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Do.tsx +1 -1
- package/src/components/macros/Goto.tsx +19 -8
- package/src/components/macros/Include.tsx +23 -11
- package/src/components/macros/Set.tsx +1 -1
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/components/macros/macro-args.ts +16 -7
- package/src/define-macro.ts +2 -0
- package/src/expression.ts +25 -20
- package/src/index.tsx +63 -37
- package/src/interpolation.ts +4 -5
- package/src/js-lexer.ts +981 -1342
- package/src/markup/ast.ts +11 -226
- package/src/markup/code-attributes.ts +3 -3
- package/src/markup/code-end.ts +121 -0
- package/src/markup/parse.ts +118 -0
- package/src/markup/render.tsx +1 -1
- package/src/markup/spindle.d.peggy.ts +24 -0
- package/src/markup/spindle.peggy +463 -0
- package/src/markup/tokens.ts +95 -0
- package/src/markup/validate.ts +281 -0
- package/src/parser.ts +19 -4
- package/src/registry.ts +49 -14
- package/src/runtime-errors.ts +10 -0
- package/src/store.ts +8 -2
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +49 -8
- package/src/tooling.ts +58 -0
- package/src/types-drift-check.ts +28 -1
- package/src/widgets/widget-def.ts +60 -0
- package/types/index.d.ts +7 -3
- package/types/tooling.d.ts +53 -3
- package/src/markup/tokenizer.ts +0 -1112
package/dist/pkg/tooling.js
CHANGED
|
@@ -2,7 +2,9 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { checkParameterTypes, validateStoryMarkup } from './story-variables.js';
|
|
6
|
+
|
|
7
|
+
export { parseStoryVariables, formatDiagnostic } from './story-variables.js';
|
|
6
8
|
|
|
7
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
|
|
@@ -22,8 +24,10 @@ for (const m of builtins) metadata.set(m.name.toLowerCase(), m);
|
|
|
22
24
|
* Metadata-only defineMacro for tooling.
|
|
23
25
|
* Captures macro metadata without creating Preact components.
|
|
24
26
|
* LSP servers call this to register user-defined macros discovered in story scripts.
|
|
27
|
+
* Like Story.defineMacro(), it throws if a declared parameter has no type.
|
|
25
28
|
*/
|
|
26
29
|
export function defineMacro(config) {
|
|
30
|
+
checkParameterTypes(config.name, config.parameters ?? []);
|
|
27
31
|
const name = config.name.toLowerCase();
|
|
28
32
|
metadata.set(name, {
|
|
29
33
|
name: config.name,
|
|
@@ -44,3 +48,13 @@ export function defineMacro(config) {
|
|
|
44
48
|
export function getMacroRegistry() {
|
|
45
49
|
return Array.from(metadata.values());
|
|
46
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate the markup of a story's passages as Spindle does when the story
|
|
54
|
+
* starts: malformed markup and unknown macros (checked against the built-in
|
|
55
|
+
* macros and those registered with defineMacro), with passage, line and
|
|
56
|
+
* column.
|
|
57
|
+
*/
|
|
58
|
+
export function validateMarkup(passages) {
|
|
59
|
+
return validateStoryMarkup(passages, metadata.values());
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"prepare": "husky"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
+
"acorn": "^8.19.0",
|
|
70
71
|
"devalue": "^6",
|
|
71
72
|
"immer": "^11.1.4",
|
|
72
73
|
"micromark": "^4.0.2",
|
|
@@ -94,6 +95,7 @@
|
|
|
94
95
|
"js-yaml": "^4.1.1",
|
|
95
96
|
"jscpd": "5.4.0",
|
|
96
97
|
"lint-staged": "^16.4.0",
|
|
98
|
+
"peggy": "5.1.0",
|
|
97
99
|
"playwright": "^1.58.2",
|
|
98
100
|
"prettier": "^3.8.1",
|
|
99
101
|
"typescript": "^6.0.2",
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The code and the markup-holding text in a passage's tokens, for the
|
|
3
|
+
* story-start check (markup/validate.ts): every piece of code a passage
|
|
4
|
+
* runs is parsed when the story starts, and each syntax error is reported
|
|
5
|
+
* with its line and column in the passage.
|
|
6
|
+
*
|
|
7
|
+
* Code is found where passages run it:
|
|
8
|
+
* - `{$…}` expressions, and `{do}` bodies (statements);
|
|
9
|
+
* - the conditions of `{if}`, `{elseif}` and `{case}`;
|
|
10
|
+
* - macro arguments whose declared parameter type is `expression`,
|
|
11
|
+
* `statements` or `passage`, built-in and custom macros alike; the
|
|
12
|
+
* condition and `run` action of `{watch}`, code in quoted strings;
|
|
13
|
+
* - the `{$…}` references in attributes holding code (`onclick`).
|
|
14
|
+
*
|
|
15
|
+
* Text that may hold markup is found in quoted labels (arguments of type
|
|
16
|
+
* `text` and `string`) and HTML attribute values; the check reads its
|
|
17
|
+
* markup in turn.
|
|
18
|
+
*
|
|
19
|
+
* Passage names written out are found in links (`[[Go->Hall]]`), in
|
|
20
|
+
* `passage` arguments that are one quoted string (`{goto "Hall"}`), in the
|
|
21
|
+
* passage of `{link}`, the `goto` and `dialog` actions of `{watch}` and the
|
|
22
|
+
* body of `{dialog}`; the check looks each one up.
|
|
23
|
+
*
|
|
24
|
+
* The arguments of macros that declare no parameters may be anything:
|
|
25
|
+
* they are not checked.
|
|
26
|
+
*/
|
|
27
|
+
import type { Token } from './markup/tokens';
|
|
28
|
+
import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
29
|
+
import { CodeSyntaxError, parseCode, type JsGoal } from './js-lexer';
|
|
30
|
+
import type { ParsedCode } from './js-lexer';
|
|
31
|
+
import type { ParameterDef } from './registry';
|
|
32
|
+
import { parseMacroArgs } from './components/macros/macro-args';
|
|
33
|
+
import { readWholeQuoted } from './components/macros/arg-utils';
|
|
34
|
+
|
|
35
|
+
/** While a pass runs: the code it parsed so far, by goal and source. */
|
|
36
|
+
let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `fn` with a cache of parsed code, so that a pass parses each piece of
|
|
40
|
+
* code once however often it meets it.
|
|
41
|
+
*/
|
|
42
|
+
export function withParseCache<T>(fn: () => T): T {
|
|
43
|
+
const outer = parses;
|
|
44
|
+
parses = new Map();
|
|
45
|
+
try {
|
|
46
|
+
return fn();
|
|
47
|
+
} finally {
|
|
48
|
+
parses = outer;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `parseCode`, with the error it throws for code that is not well-formed. */
|
|
53
|
+
export function parseOrError(
|
|
54
|
+
code: string,
|
|
55
|
+
goal: JsGoal,
|
|
56
|
+
): ParsedCode | CodeSyntaxError {
|
|
57
|
+
const key = goal[0] + code;
|
|
58
|
+
let result = parses?.get(key);
|
|
59
|
+
if (result) return result;
|
|
60
|
+
try {
|
|
61
|
+
result = parseCode(code, goal);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!(error instanceof CodeSyntaxError)) throw error;
|
|
64
|
+
result = error;
|
|
65
|
+
}
|
|
66
|
+
parses?.set(key, result);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A piece of code in markup, at `offset` in it. */
|
|
71
|
+
export interface CodePiece {
|
|
72
|
+
kind: 'code';
|
|
73
|
+
code: string;
|
|
74
|
+
offset: number;
|
|
75
|
+
goal: JsGoal;
|
|
76
|
+
/** The markup it is in, for the error: `{print $a +}`. */
|
|
77
|
+
label: string;
|
|
78
|
+
/** Whether it names a passage: a `passage` argument. */
|
|
79
|
+
passage?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A passage name written out in markup, at `offset` in it. */
|
|
83
|
+
export interface PassagePiece {
|
|
84
|
+
kind: 'passage';
|
|
85
|
+
name: string;
|
|
86
|
+
offset: number;
|
|
87
|
+
/** The markup it is in, for the error: `[[Go->Hall]]`. */
|
|
88
|
+
label: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Text in markup that may hold markup of its own, at `offset` in it. */
|
|
92
|
+
export interface TextPiece {
|
|
93
|
+
kind: 'text';
|
|
94
|
+
text: string;
|
|
95
|
+
offset: number;
|
|
96
|
+
/** Where it is, for the error: `In the label of {button}: `. */
|
|
97
|
+
where: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Macros whose whole argument text is one expression: branch conditions. */
|
|
101
|
+
const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
|
|
102
|
+
|
|
103
|
+
/** Code inside the quoted strings of a macro's arguments, by parameter. */
|
|
104
|
+
const CODE_IN_STRINGS: Record<string, Record<string, JsGoal>> = {
|
|
105
|
+
watch: { condition: 'expression', run: 'statements' },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** The `string` parameters of a macro that name a passage. */
|
|
109
|
+
const PASSAGE_STRINGS: Record<string, readonly string[]> = {
|
|
110
|
+
link: ['passage'],
|
|
111
|
+
watch: ['goto', 'dialog'],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Block macros whose body is the name of a passage. */
|
|
115
|
+
const PASSAGE_BODIES = new Set(['dialog']);
|
|
116
|
+
|
|
117
|
+
type Piece = CodePiece | TextPiece | PassagePiece;
|
|
118
|
+
|
|
119
|
+
/** The goal of the code an argument of this type holds, if it is code. */
|
|
120
|
+
function codeGoal(param: ParameterDef): JsGoal | undefined {
|
|
121
|
+
if (param.type === 'expression' || param.type === 'passage') {
|
|
122
|
+
return 'expression';
|
|
123
|
+
}
|
|
124
|
+
if (param.type === 'statements') return 'statements';
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The code and the markup-holding text in `tokens`, the tokens of `src`.
|
|
130
|
+
* `parametersOf` gives the declared parameters of a macro, if it has any.
|
|
131
|
+
*/
|
|
132
|
+
export function* codeAndText(
|
|
133
|
+
src: string,
|
|
134
|
+
tokens: readonly Token[],
|
|
135
|
+
parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
|
|
136
|
+
): Generator<Piece> {
|
|
137
|
+
/** The index of the closing tag of the `name` macro opened at `t`. */
|
|
138
|
+
const closeOf = (t: number, name: string) =>
|
|
139
|
+
tokens.findIndex(
|
|
140
|
+
(c, k) =>
|
|
141
|
+
k > t &&
|
|
142
|
+
c.type === 'macro' &&
|
|
143
|
+
c.isClose &&
|
|
144
|
+
c.name.toLowerCase() === name,
|
|
145
|
+
);
|
|
146
|
+
for (let t = 0; t < tokens.length; t++) {
|
|
147
|
+
const token = tokens[t]!;
|
|
148
|
+
if (token.type === 'link') {
|
|
149
|
+
yield {
|
|
150
|
+
kind: 'passage',
|
|
151
|
+
name: token.target,
|
|
152
|
+
offset: locate(src, token.target, token.start),
|
|
153
|
+
label: src.slice(token.start, token.end),
|
|
154
|
+
};
|
|
155
|
+
} else if (token.type === 'expression') {
|
|
156
|
+
yield {
|
|
157
|
+
kind: 'code',
|
|
158
|
+
code: token.expression,
|
|
159
|
+
offset: token.end - 1 - token.expression.length,
|
|
160
|
+
goal: 'expression',
|
|
161
|
+
label: `{${token.expression}}`,
|
|
162
|
+
};
|
|
163
|
+
} else if (token.type === 'html') {
|
|
164
|
+
for (const [name, value] of Object.entries(token.attributes)) {
|
|
165
|
+
// The value is the source text between its quotes
|
|
166
|
+
const at = locate(src, value, token.start);
|
|
167
|
+
if (!isCodeAttribute(name)) {
|
|
168
|
+
yield {
|
|
169
|
+
kind: 'text',
|
|
170
|
+
text: value,
|
|
171
|
+
offset: at,
|
|
172
|
+
where: `In the ${name} attribute of <${token.tag}>: `,
|
|
173
|
+
};
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
let from = 0;
|
|
177
|
+
for (const part of splitSigilTemplate(value)) {
|
|
178
|
+
if (!('expr' in part)) continue;
|
|
179
|
+
const k = locate(value, part.expr, from);
|
|
180
|
+
from = k + part.expr.length;
|
|
181
|
+
yield {
|
|
182
|
+
kind: 'code',
|
|
183
|
+
code: part.expr,
|
|
184
|
+
offset: at + k,
|
|
185
|
+
goal: 'expression',
|
|
186
|
+
label: `{${part.expr}} in the ${name} attribute of <${token.tag}>`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else if (token.type === 'macro' && !token.isClose) {
|
|
191
|
+
const name = token.name.toLowerCase();
|
|
192
|
+
if (name === 'do') {
|
|
193
|
+
const close = closeOf(t, name);
|
|
194
|
+
if (close < 0) continue;
|
|
195
|
+
const body = src.slice(token.end, tokens[close]!.start);
|
|
196
|
+
yield {
|
|
197
|
+
kind: 'code',
|
|
198
|
+
code: body,
|
|
199
|
+
offset: token.end,
|
|
200
|
+
goal: 'statements',
|
|
201
|
+
label: '{do}',
|
|
202
|
+
};
|
|
203
|
+
t = close;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const args = token.rawArgs;
|
|
207
|
+
if (PASSAGE_BODIES.has(name)) {
|
|
208
|
+
// The body names the passage when it is only text
|
|
209
|
+
const close = closeOf(t, name);
|
|
210
|
+
const body = tokens.slice(t + 1, close);
|
|
211
|
+
if (close > t + 1 && body.every((b) => b.type === 'text')) {
|
|
212
|
+
const text = src.slice(token.end, tokens[close]!.start);
|
|
213
|
+
const passage = text.trim().replace(/^["']|["']$/g, '');
|
|
214
|
+
yield {
|
|
215
|
+
kind: 'passage',
|
|
216
|
+
name: passage,
|
|
217
|
+
offset: locate(src, passage, token.end),
|
|
218
|
+
label: src.slice(token.start, token.end),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!args) continue;
|
|
223
|
+
const label = `{${token.name} ${args}}`;
|
|
224
|
+
const argsAt = locate(src, args, token.start + 1);
|
|
225
|
+
if (CONDITION_MACROS.has(name)) {
|
|
226
|
+
yield {
|
|
227
|
+
kind: 'code',
|
|
228
|
+
code: args,
|
|
229
|
+
offset: argsAt,
|
|
230
|
+
goal: 'expression',
|
|
231
|
+
label,
|
|
232
|
+
};
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const params = parametersOf(name);
|
|
236
|
+
if (params) yield* argPieces(args, argsAt, params, token.name, label);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The code and text in the arguments `args` (at `offset`) of `macro`. */
|
|
242
|
+
function* argPieces(
|
|
243
|
+
args: string,
|
|
244
|
+
offset: number,
|
|
245
|
+
params: readonly ParameterDef[],
|
|
246
|
+
macro: string,
|
|
247
|
+
label: string,
|
|
248
|
+
): Generator<Piece> {
|
|
249
|
+
const values = parseMacroArgs(args, params) as Record<string, unknown>;
|
|
250
|
+
const inStrings = CODE_IN_STRINGS[macro.toLowerCase()] ?? {};
|
|
251
|
+
const passageStrings = PASSAGE_STRINGS[macro.toLowerCase()] ?? [];
|
|
252
|
+
let cursor = 0;
|
|
253
|
+
/** The pieces of the parameters `list`, with their values in `from`. */
|
|
254
|
+
function* visit(
|
|
255
|
+
list: readonly ParameterDef[],
|
|
256
|
+
from: Record<string, unknown>,
|
|
257
|
+
): Generator<Piece> {
|
|
258
|
+
for (const param of list) {
|
|
259
|
+
const value = from[param.name];
|
|
260
|
+
if (param.type === 'options' && param.parameters) {
|
|
261
|
+
yield* visit(
|
|
262
|
+
param.parameters,
|
|
263
|
+
(value ?? {}) as Record<string, unknown>,
|
|
264
|
+
);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
268
|
+
const at = locate(args, value, cursor);
|
|
269
|
+
if (at >= cursor) cursor = at + value.length;
|
|
270
|
+
const goal = codeGoal(param) ?? inStrings[param.name];
|
|
271
|
+
if (goal) {
|
|
272
|
+
const piece: CodePiece = {
|
|
273
|
+
kind: 'code',
|
|
274
|
+
code: value,
|
|
275
|
+
offset: offset + at,
|
|
276
|
+
goal,
|
|
277
|
+
label,
|
|
278
|
+
};
|
|
279
|
+
if (param.type === 'passage') piece.passage = true;
|
|
280
|
+
yield piece;
|
|
281
|
+
}
|
|
282
|
+
// A passage written out: a quoted `passage` argument, or a string
|
|
283
|
+
const name =
|
|
284
|
+
param.type === 'passage'
|
|
285
|
+
? readWholeQuoted(value.trim())
|
|
286
|
+
: passageStrings.includes(param.name)
|
|
287
|
+
? value
|
|
288
|
+
: null;
|
|
289
|
+
if (name !== null) {
|
|
290
|
+
yield { kind: 'passage', name, offset: offset + at, label };
|
|
291
|
+
}
|
|
292
|
+
if (!goal && (param.type === 'text' || param.type === 'string')) {
|
|
293
|
+
yield {
|
|
294
|
+
kind: 'text',
|
|
295
|
+
text: value,
|
|
296
|
+
offset: offset + at,
|
|
297
|
+
where: `In the ${param.name} of {${macro}}: `,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
yield* visit(params, values);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Index of `part` in `text` from `from` on, else anywhere, else `from`: the
|
|
307
|
+
* place to report an error in `part` at.
|
|
308
|
+
*/
|
|
309
|
+
function locate(text: string, part: string, from: number): number {
|
|
310
|
+
const at = text.indexOf(part, from);
|
|
311
|
+
if (at >= 0) return at;
|
|
312
|
+
const anywhere = text.indexOf(part);
|
|
313
|
+
return anywhere >= 0 ? anywhere : from;
|
|
314
|
+
}
|
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
useRef,
|
|
6
6
|
useState,
|
|
7
7
|
} from 'preact/hooks';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { parseMarkup } from '../markup/parse';
|
|
9
|
+
import type { ASTNode } from '../markup/ast';
|
|
10
10
|
import { renderNodes, NobrContext } from '../markup/render';
|
|
11
11
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
12
12
|
import type { Passage as PassageData } from '../parser';
|
|
@@ -29,7 +29,7 @@ const astCache = new WeakMap<
|
|
|
29
29
|
function parsePassage(passage: PassageData): ASTNode[] {
|
|
30
30
|
const cached = astCache.get(passage);
|
|
31
31
|
if (cached && cached.content === passage.content) return cached.ast;
|
|
32
|
-
const ast =
|
|
32
|
+
const ast = parseMarkup(passage.content);
|
|
33
33
|
astCache.set(passage, { content: passage.content, ast });
|
|
34
34
|
return ast;
|
|
35
35
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
|
|
3
|
-
import {
|
|
4
|
-
import { buildAST } from '../markup/ast';
|
|
3
|
+
import { parseMarkup } from '../markup/parse';
|
|
5
4
|
import { renderNodes, NobrContext } from '../markup/render';
|
|
6
5
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
7
6
|
import { emitFromRender } from '../event-emitter';
|
|
@@ -53,8 +52,7 @@ export function PassageDialog({
|
|
|
53
52
|
return <div class="error">Dialog: no content available</div>;
|
|
54
53
|
}
|
|
55
54
|
try {
|
|
56
|
-
const
|
|
57
|
-
const ast = buildAST(tokens);
|
|
55
|
+
const ast = parseMarkup(markup);
|
|
58
56
|
const nodes = renderNodes(ast, nobr ? { nobr: true } : undefined);
|
|
59
57
|
return nobr ? (
|
|
60
58
|
<NobrContext.Provider value={true}>{nodes}</NobrContext.Provider>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useMemo } from 'preact/hooks';
|
|
2
2
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
3
|
-
import {
|
|
4
|
-
import { buildAST } from '../markup/ast';
|
|
3
|
+
import { parseMarkup } from '../markup/parse';
|
|
5
4
|
import { renderInlineNodes, NobrContext } from '../markup/render';
|
|
6
5
|
import { errorMessage } from '../utils/error-message';
|
|
7
6
|
|
|
@@ -18,8 +17,7 @@ export function StoryInterface() {
|
|
|
18
17
|
|
|
19
18
|
const rendered = useMemo(() => {
|
|
20
19
|
try {
|
|
21
|
-
const
|
|
22
|
-
const ast = buildAST(tokens);
|
|
20
|
+
const ast = parseMarkup(markup);
|
|
23
21
|
return <>{renderInlineNodes(ast)}</>;
|
|
24
22
|
} catch (err) {
|
|
25
23
|
return (
|
|
@@ -5,7 +5,7 @@ defineMacro({
|
|
|
5
5
|
name: 'do',
|
|
6
6
|
block: true,
|
|
7
7
|
render({ children = [] }, ctx) {
|
|
8
|
-
// The
|
|
8
|
+
// The parser keeps the body verbatim as a single text node
|
|
9
9
|
const code = ctx.collectText(children);
|
|
10
10
|
|
|
11
11
|
ctx.hooks.useLayoutEffect(() => {
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
3
|
import { evaluatePassageName } from './macro-args';
|
|
4
|
-
import { logMacroError } from './MacroError';
|
|
4
|
+
import { logMacroError, MacroError } from './MacroError';
|
|
5
5
|
|
|
6
6
|
defineMacro({
|
|
7
7
|
name: 'goto',
|
|
8
8
|
merged: true,
|
|
9
|
-
parameters: [{ name: 'passage', type: '
|
|
9
|
+
parameters: [{ name: 'passage', type: 'passage', required: true }],
|
|
10
10
|
render(_props, ctx) {
|
|
11
|
+
// Boxed: anything can be thrown, including null and other falsy values
|
|
12
|
+
const [failure, setFailure] = ctx.hooks.useState<{ error: unknown }>();
|
|
11
13
|
ctx.hooks.useLayoutEffect(() => {
|
|
12
14
|
// Reported like an error in {do}: thrown out of an effect, it would
|
|
13
15
|
// abort the rest of the render's effects (e.g. the session could not
|
|
@@ -15,14 +17,23 @@ defineMacro({
|
|
|
15
17
|
// this passage's, taken before navigating away from it
|
|
16
18
|
const location = ctx.sourceLocation();
|
|
17
19
|
try {
|
|
18
|
-
useStoryStore
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const store = useStoryStore.getState();
|
|
21
|
+
store.navigate(
|
|
22
|
+
evaluatePassageName(ctx.args.passage, ctx.evaluate!, store),
|
|
23
|
+
);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
logMacroError('goto', error, location);
|
|
26
|
+
// Shown in place, as other macros show their errors, unless it
|
|
27
|
+
// navigated away
|
|
28
|
+
setFailure({ error });
|
|
23
29
|
}
|
|
24
30
|
}, []);
|
|
25
31
|
|
|
26
|
-
return
|
|
32
|
+
return failure ? (
|
|
33
|
+
<MacroError
|
|
34
|
+
macro="goto"
|
|
35
|
+
error={failure.error}
|
|
36
|
+
/>
|
|
37
|
+
) : null;
|
|
27
38
|
},
|
|
28
39
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
3
|
-
import {
|
|
4
|
-
import { buildAST } from '../../markup/ast';
|
|
3
|
+
import { parseMarkup } from '../../markup/parse';
|
|
5
4
|
import { NobrContext } from '../../markup/render';
|
|
6
5
|
import { defineMacro } from '../../define-macro';
|
|
7
6
|
import { evaluatePassageName } from './macro-args';
|
|
7
|
+
import { MacroError } from './MacroError';
|
|
8
8
|
|
|
9
9
|
defineMacro({
|
|
10
10
|
name: 'include',
|
|
@@ -14,13 +14,23 @@ defineMacro({
|
|
|
14
14
|
// passage names and expressions containing the word stay intact (#201).
|
|
15
15
|
parameters: [
|
|
16
16
|
{ name: 'inline', type: 'flag' },
|
|
17
|
-
{ name: 'passage', type: '
|
|
17
|
+
{ name: 'passage', type: 'passage', required: true },
|
|
18
18
|
],
|
|
19
19
|
render(_props, ctx) {
|
|
20
20
|
const { storyData } = useStoryFields('storyData');
|
|
21
21
|
|
|
22
22
|
const { inline } = ctx.args;
|
|
23
|
-
|
|
23
|
+
let passageName = '';
|
|
24
|
+
let failure: { error: unknown } | undefined;
|
|
25
|
+
try {
|
|
26
|
+
passageName = evaluatePassageName(
|
|
27
|
+
ctx.args.passage,
|
|
28
|
+
ctx.evaluate!,
|
|
29
|
+
useStoryStore.getState(),
|
|
30
|
+
);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
failure = { error };
|
|
33
|
+
}
|
|
24
34
|
|
|
25
35
|
const passage = storyData?.passages.get(passageName);
|
|
26
36
|
// Parse once per included passage: the renderer keys children by AST
|
|
@@ -28,20 +38,22 @@ defineMacro({
|
|
|
28
38
|
// included passage changes, while re-renders of the same passage keep
|
|
29
39
|
// its macros mounted (#175).
|
|
30
40
|
const ast = ctx.hooks.useMemo(
|
|
31
|
-
() => (passage ?
|
|
41
|
+
() => (passage ? parseMarkup(passage.content) : null),
|
|
32
42
|
[passage],
|
|
33
43
|
);
|
|
34
44
|
|
|
35
45
|
if (!storyData) return null;
|
|
36
|
-
|
|
37
|
-
if (passage) {
|
|
38
|
-
useStoryStore.getState().trackRender(passageName);
|
|
39
|
-
}
|
|
40
|
-
if (!passage || !ast) {
|
|
46
|
+
if (failure) {
|
|
41
47
|
return (
|
|
42
|
-
<
|
|
48
|
+
<MacroError
|
|
49
|
+
macro="include"
|
|
50
|
+
error={failure.error}
|
|
51
|
+
/>
|
|
43
52
|
);
|
|
44
53
|
}
|
|
54
|
+
if (!passage || !ast) return null;
|
|
55
|
+
|
|
56
|
+
useStoryStore.getState().trackRender(passageName);
|
|
45
57
|
|
|
46
58
|
const nobr = passage.tags.includes('nobr');
|
|
47
59
|
const content = inline
|
|
@@ -3,7 +3,7 @@ import { useRunOnce } from './MacroError';
|
|
|
3
3
|
|
|
4
4
|
defineMacro({
|
|
5
5
|
name: 'set',
|
|
6
|
-
parameters: [{ name: 'code', type: '
|
|
6
|
+
parameters: [{ name: 'code', type: 'statements', required: true }],
|
|
7
7
|
render({ rawArgs }, ctx) {
|
|
8
8
|
return useRunOnce('set', rawArgs, () => ctx.mutate(rawArgs));
|
|
9
9
|
},
|
|
@@ -3,7 +3,7 @@ import { useContext } from 'preact/hooks';
|
|
|
3
3
|
import { LocalsValuesContext } from '../../markup/render';
|
|
4
4
|
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
5
5
|
import { ownValue, variableNameError } from '../../utils/namespace';
|
|
6
|
-
import { SCOPE_SIGILS } from '../../markup/
|
|
6
|
+
import { SCOPE_SIGILS } from '../../markup/tokens';
|
|
7
7
|
import type { VariableNode } from '../../markup/ast';
|
|
8
8
|
import { display, wrapContent } from './display';
|
|
9
9
|
|
|
@@ -4,39 +4,11 @@ import { registerBlockMacro, type ASTNode } from '../../markup/ast';
|
|
|
4
4
|
import { defineMacro } from '../../define-macro';
|
|
5
5
|
import { checkVariableName } from '../../utils/namespace';
|
|
6
6
|
import { MacroError } from './MacroError';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{ name: 'name', type: 'text', required: true },
|
|
13
|
-
{ name: 'parameters', type: 'expression' },
|
|
14
|
-
] as const;
|
|
15
|
-
|
|
16
|
-
interface WidgetDef {
|
|
17
|
-
name: string;
|
|
18
|
-
params: string[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The widget a {widget} definition's arguments declare: its name and its
|
|
23
|
-
* parameters, the words after it that start with `@` (docs/widgets.md).
|
|
24
|
-
* Other words, such as `$name`, are not parameters.
|
|
25
|
-
*/
|
|
26
|
-
function widgetDef({
|
|
27
|
-
name = '',
|
|
28
|
-
parameters = '',
|
|
29
|
-
}: MacroArgs<typeof WIDGET_PARAMETERS>): WidgetDef {
|
|
30
|
-
return {
|
|
31
|
-
name,
|
|
32
|
-
params: parameters.split(/\s+/).filter((word) => word.startsWith('@')),
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Read the arguments of a {widget} definition (see widgetDef). */
|
|
37
|
-
export function parseWidgetDef(rawArgs: string): WidgetDef {
|
|
38
|
-
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
39
|
-
}
|
|
7
|
+
import {
|
|
8
|
+
WIDGET_PARAMETERS,
|
|
9
|
+
widgetDef,
|
|
10
|
+
type WidgetDef,
|
|
11
|
+
} from '../../widgets/widget-def';
|
|
40
12
|
|
|
41
13
|
/**
|
|
42
14
|
* Register the widget a definition declares, with its body. Widgets whose
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { parseDelay } from '../../utils/parse-delay';
|
|
18
18
|
import type { MacroArgs, ParameterDef } from '../../registry';
|
|
19
|
+
import type { StoryState } from '../../store';
|
|
20
|
+
import { noPassageError } from '../../runtime-errors';
|
|
19
21
|
import {
|
|
20
22
|
endsWithOperator,
|
|
21
23
|
isWhitespace,
|
|
@@ -177,8 +179,9 @@ function findSeparator(src: string, word: string): Span | null {
|
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
const takesRest = (param: ParameterDef) =>
|
|
180
|
-
param.type === undefined ||
|
|
181
182
|
param.type === 'expression' ||
|
|
183
|
+
param.type === 'statements' ||
|
|
184
|
+
param.type === 'passage' ||
|
|
182
185
|
param.type === 'text';
|
|
183
186
|
|
|
184
187
|
/** Read the parameters of one group from `src` (`null`: no text) into `args`. */
|
|
@@ -262,18 +265,24 @@ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
|
|
|
262
265
|
}
|
|
263
266
|
|
|
264
267
|
/**
|
|
265
|
-
* The passage a passage
|
|
266
|
-
*
|
|
268
|
+
* The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
|
|
269
|
+
* the value of its expression. Throws what evaluating it throws, and if no
|
|
270
|
+
* passage of the story `state` holds has that name, an error naming it and
|
|
271
|
+
* the current passage.
|
|
267
272
|
*/
|
|
268
273
|
export function evaluatePassageName(
|
|
269
274
|
expr: string | undefined,
|
|
270
275
|
evaluate: (expr: string) => unknown,
|
|
276
|
+
{
|
|
277
|
+
storyData,
|
|
278
|
+
currentPassage,
|
|
279
|
+
}: Pick<StoryState, 'storyData' | 'currentPassage'>,
|
|
271
280
|
): string {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return readText(expr ?? '');
|
|
281
|
+
const name = String(evaluate(expr ?? ''));
|
|
282
|
+
if (storyData && !storyData.passages.has(name)) {
|
|
283
|
+
throw noPassageError(name, currentPassage);
|
|
276
284
|
}
|
|
285
|
+
return name;
|
|
277
286
|
}
|
|
278
287
|
|
|
279
288
|
/**
|