@rohal12/spindle 0.53.0 → 0.54.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 +4813 -2037
- package/dist/pkg/story-variables.js +4009 -940
- package/dist/pkg/tooling.js +13 -1
- package/package.json +2 -1
- 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/Include.tsx +2 -3
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/index.tsx +57 -37
- package/src/interpolation.ts +4 -5
- 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 +219 -0
- package/src/parser.ts +19 -4
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +18 -4
- package/src/tooling.ts +50 -0
- package/src/types-drift-check.ts +28 -1
- package/src/widgets/widget-def.ts +60 -0
- package/types/tooling.d.ts +42 -0
- 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 { 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
|
|
|
@@ -44,3 +46,13 @@ export function defineMacro(config) {
|
|
|
44
46
|
export function getMacroRegistry() {
|
|
45
47
|
return Array.from(metadata.values());
|
|
46
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate the markup of a story's passages as Spindle does when the story
|
|
52
|
+
* starts: malformed markup and unknown macros (checked against the built-in
|
|
53
|
+
* macros and those registered with defineMacro), with passage, line and
|
|
54
|
+
* column.
|
|
55
|
+
*/
|
|
56
|
+
export function validateMarkup(passages) {
|
|
57
|
+
return validateStoryMarkup(passages, metadata.values());
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"js-yaml": "^4.1.1",
|
|
95
95
|
"jscpd": "5.4.0",
|
|
96
96
|
"lint-staged": "^16.4.0",
|
|
97
|
+
"peggy": "5.1.0",
|
|
97
98
|
"playwright": "^1.58.2",
|
|
98
99
|
"prettier": "^3.8.1",
|
|
99
100
|
"typescript": "^6.0.2",
|
|
@@ -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,7 +1,6 @@
|
|
|
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';
|
|
@@ -28,7 +27,7 @@ defineMacro({
|
|
|
28
27
|
// included passage changes, while re-renders of the same passage keep
|
|
29
28
|
// its macros mounted (#175).
|
|
30
29
|
const ast = ctx.hooks.useMemo(
|
|
31
|
-
() => (passage ?
|
|
30
|
+
() => (passage ? parseMarkup(passage.content) : null),
|
|
32
31
|
[passage],
|
|
33
32
|
);
|
|
34
33
|
|
|
@@ -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
|
package/src/index.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { render } from 'preact';
|
|
2
2
|
import { App } from './components/App';
|
|
3
|
-
import { parseStoryData } from './parser';
|
|
3
|
+
import { parseStoryData, type StoryData } from './parser';
|
|
4
4
|
import { useStoryStore, enterRuntimePhase } from './store';
|
|
5
5
|
import {
|
|
6
6
|
installStoryAPI,
|
|
@@ -16,10 +16,17 @@ import {
|
|
|
16
16
|
validatePassages,
|
|
17
17
|
extractDefaults,
|
|
18
18
|
} from './story-variables';
|
|
19
|
-
import { getMacroRegistry } from './registry';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
19
|
+
import { getMacro, getMacroRegistry, isSubMacro } from './registry';
|
|
20
|
+
import { getWidget } from './widgets/widget-registry';
|
|
21
|
+
import {
|
|
22
|
+
formatDiagnostic,
|
|
23
|
+
validateMarkup,
|
|
24
|
+
type MarkupPassage,
|
|
25
|
+
} from './markup/validate';
|
|
26
|
+
import { blockWidgetNames, parseWidgetDef } from './widgets/widget-def';
|
|
27
|
+
import { parseMarkup } from './markup/parse';
|
|
28
|
+
import { registerBlockMacro } from './markup/ast';
|
|
29
|
+
import { registerWidgetDef } from './components/macros/Widget';
|
|
23
30
|
import { errorMessage } from './utils/error-message';
|
|
24
31
|
import type { ASTNode } from './markup/ast';
|
|
25
32
|
import './macros/register-builtins';
|
|
@@ -45,10 +52,36 @@ function renderErrors(root: HTMLElement, errors: string[]) {
|
|
|
45
52
|
root.appendChild(container);
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/** Show the validation errors instead of the story, and stop booting. */
|
|
56
|
+
function stopWithErrors(errors: string[]): never {
|
|
57
|
+
const root = document.getElementById('root');
|
|
58
|
+
if (root) renderErrors(root, errors);
|
|
59
|
+
throw new Error(
|
|
60
|
+
`spindle: ${errors.length} validation error(s):\n${errors.join('\n')}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The markup errors (malformed markup, unknown macros) of the passages for
|
|
66
|
+
* which `only` holds, with the macros and widgets known now.
|
|
67
|
+
*/
|
|
68
|
+
function markupErrors(
|
|
69
|
+
storyData: StoryData,
|
|
70
|
+
only: (passage: MarkupPassage) => boolean,
|
|
71
|
+
): string[] {
|
|
72
|
+
return validateMarkup(storyData.passages.values(), {
|
|
73
|
+
isKnownMacro: (name) =>
|
|
74
|
+
!!getMacro(name) || isSubMacro(name) || !!getWidget(name),
|
|
75
|
+
macroNames: getMacroRegistry().map((m) => m.name),
|
|
76
|
+
only,
|
|
77
|
+
}).map(formatDiagnostic);
|
|
78
|
+
}
|
|
79
|
+
|
|
48
80
|
/**
|
|
49
81
|
* Boot Spindle in the current document: parse `<tw-storydata>`, install the
|
|
50
|
-
* `Story` API, run author JavaScript, validate `StoryVariables
|
|
51
|
-
* `StoryInit
|
|
82
|
+
* `Story` API, run author JavaScript, validate `StoryVariables` and
|
|
83
|
+
* `StoryInit`'s markup, run `StoryInit`, validate the other passages' markup
|
|
84
|
+
* (with the macros StoryInit defined), and render into `#root`. Dispatches `:storyready` when the
|
|
52
85
|
* first passage is shown. Call once per page (module state is global).
|
|
53
86
|
* The browser story format calls it from `main.tsx`.
|
|
54
87
|
*/
|
|
@@ -101,7 +134,18 @@ export function boot() {
|
|
|
101
134
|
const storeVarMacros = getMacroRegistry()
|
|
102
135
|
.filter((m) => m.storeVar)
|
|
103
136
|
.map((m) => m.name);
|
|
137
|
+
// Pass 1: Register the block widgets as block macros BEFORE any passage
|
|
138
|
+
// is parsed (validation and StoryInit included), so that passages
|
|
139
|
+
// invoking block widgets and widget bodies using other block widgets
|
|
140
|
+
// parse correctly regardless of passage or definition order.
|
|
141
|
+
for (const name of blockWidgetNames(storyData.passages.values())) {
|
|
142
|
+
registerBlockMacro(name);
|
|
143
|
+
}
|
|
144
|
+
|
|
104
145
|
const errors = validatePassages(storyData.passages, schema, storeVarMacros);
|
|
146
|
+
// StoryInit's markup must be valid before it runs; the other passages are
|
|
147
|
+
// validated once it has run, as it may define macros (see below).
|
|
148
|
+
errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
|
|
105
149
|
|
|
106
150
|
// Parse StoryTransients (optional — no error if missing)
|
|
107
151
|
let transientDefaults: Record<string, unknown> = {};
|
|
@@ -124,13 +168,7 @@ export function boot() {
|
|
|
124
168
|
transientDefaults = extractDefaults(transientSchema);
|
|
125
169
|
}
|
|
126
170
|
|
|
127
|
-
if (errors.length > 0)
|
|
128
|
-
const root = document.getElementById('root');
|
|
129
|
-
if (root) renderErrors(root, errors);
|
|
130
|
-
throw new Error(
|
|
131
|
-
`spindle: ${errors.length} validation error(s):\n${errors.join('\n')}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
171
|
+
if (errors.length > 0) stopWithErrors(errors);
|
|
134
172
|
|
|
135
173
|
defaults = extractDefaults(schema);
|
|
136
174
|
setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
|
|
@@ -140,36 +178,18 @@ export function boot() {
|
|
|
140
178
|
// Enter runtime phase — handlers registered from here on are cleaned on restart
|
|
141
179
|
enterRuntimePhase();
|
|
142
180
|
|
|
143
|
-
// Pass 1: Pre-scan StoryInit and all widget passages to discover block
|
|
144
|
-
// widgets. Register them as block macros BEFORE any tokenize/buildAST
|
|
145
|
-
// calls (StoryInit's included), so that passages invoking block widgets
|
|
146
|
-
// and widget bodies using other block widgets parse correctly regardless
|
|
147
|
-
// of passage or definition order.
|
|
148
|
-
const blockWidgetPattern =
|
|
149
|
-
/\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
|
|
150
|
-
for (const [passageName, passage] of storyData.passages) {
|
|
151
|
-
if (passageName === 'StoryInit' || passage.tags.includes('widget')) {
|
|
152
|
-
let match;
|
|
153
|
-
while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
|
|
154
|
-
const name = match[1]!;
|
|
155
|
-
const body = match[2]!;
|
|
156
|
-
if (/\{@children\}/.test(body)) {
|
|
157
|
-
registerBlockMacro(name);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
blockWidgetPattern.lastIndex = 0;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
181
|
// Run StoryInit, restore the session if the page was refreshed, and fire
|
|
165
182
|
// storyinit after all state is settled (defaults + StoryInit + session)
|
|
166
183
|
initializeStory(loadSession(storyData.ifid));
|
|
167
184
|
|
|
185
|
+
// Every other passage's markup, now that StoryInit may have defined macros
|
|
186
|
+
const markup = markupErrors(storyData, (p) => p.name !== 'StoryInit');
|
|
187
|
+
if (markup.length > 0) stopWithErrors(markup);
|
|
188
|
+
|
|
168
189
|
// Pass 2: Full parse and register widgets from passages tagged "widget"
|
|
169
190
|
for (const [, passage] of storyData.passages) {
|
|
170
191
|
if (passage.tags.includes('widget')) {
|
|
171
|
-
const
|
|
172
|
-
const widgetAST = buildAST(widgetTokens);
|
|
192
|
+
const widgetAST = parseMarkup(passage.content);
|
|
173
193
|
for (const node of widgetAST) {
|
|
174
194
|
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
175
195
|
// Read and registered as the {widget} macro does
|
package/src/interpolation.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and macro labels resolve the inline markup of passage text (variables,
|
|
4
4
|
* expressions, macros, widgets) to a string.
|
|
5
5
|
*
|
|
6
|
-
* Such a value is parsed with the passage
|
|
6
|
+
* Such a value is parsed with the passage parser in text mode, so `{…}`
|
|
7
7
|
* means the same as in passage text, and its AST is evaluated directly to a
|
|
8
8
|
* string rather than rendered: the result is ready during the render that
|
|
9
9
|
* needs it (so it goes through the usual attribute handling, boolean
|
|
@@ -17,11 +17,10 @@ import { evaluate } from './expression';
|
|
|
17
17
|
import {
|
|
18
18
|
SCOPE_SIGILS,
|
|
19
19
|
SIGIL_SCOPES,
|
|
20
|
-
tokenize,
|
|
21
20
|
type VariableScope,
|
|
22
|
-
} from './markup/
|
|
21
|
+
} from './markup/tokens';
|
|
23
22
|
import type { Sigil } from './js-lexer';
|
|
24
|
-
import {
|
|
23
|
+
import { parseMarkup } from './markup/parse';
|
|
25
24
|
import type { ASTNode, MacroNode, TextNode } from './markup/ast';
|
|
26
25
|
import { getMacro, getMacroText, isSubMacro } from './registry';
|
|
27
26
|
import type { MacroTextContext } from './registry';
|
|
@@ -76,7 +75,7 @@ export function parseText(template: string): ParsedText {
|
|
|
76
75
|
let parsed = parseCache.get(template);
|
|
77
76
|
if (parsed === undefined) {
|
|
78
77
|
try {
|
|
79
|
-
parsed = { nodes:
|
|
78
|
+
parsed = { nodes: parseMarkup(template, { text: true }) };
|
|
80
79
|
} catch (error) {
|
|
81
80
|
parsed = { error };
|
|
82
81
|
}
|
package/src/markup/ast.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type Token,
|
|
7
|
-
type VariableScope,
|
|
8
|
-
} from './tokenizer';
|
|
1
|
+
/**
|
|
2
|
+
* The passage AST (built by parse.ts), and the registry of block macros:
|
|
3
|
+
* those whose body the parser nests up to their `{/name}`.
|
|
4
|
+
*/
|
|
5
|
+
import type { Selectors, VariableScope } from './tokens';
|
|
9
6
|
import { NameSet } from '../utils/macro-names';
|
|
10
7
|
|
|
11
8
|
export interface TextNode {
|
|
@@ -69,7 +66,12 @@ const BLOCK_MACROS = new NameSet([
|
|
|
69
66
|
'nobr',
|
|
70
67
|
]);
|
|
71
68
|
|
|
72
|
-
/**
|
|
69
|
+
/** Whether a macro takes a body closed by `{/name}`. */
|
|
70
|
+
export function isBlockMacro(name: string): boolean {
|
|
71
|
+
return BLOCK_MACROS.has(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Register a custom macro as a block macro so the parser nests children. */
|
|
73
75
|
export function registerBlockMacro(name: string): void {
|
|
74
76
|
BLOCK_MACROS.add(name);
|
|
75
77
|
}
|
|
@@ -78,220 +80,3 @@ export function registerBlockMacro(name: string): void {
|
|
|
78
80
|
export function unregisterBlockMacro(name: string): void {
|
|
79
81
|
BLOCK_MACROS.delete(name);
|
|
80
82
|
}
|
|
81
|
-
|
|
82
|
-
/** Map from branch macro name → required parent macro name */
|
|
83
|
-
const BRANCH_PARENT: Record<string, string> = {
|
|
84
|
-
elseif: 'if',
|
|
85
|
-
else: 'if',
|
|
86
|
-
case: 'switch',
|
|
87
|
-
default: 'switch',
|
|
88
|
-
next: 'timed',
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
/** Block macros that use the branches[] array */
|
|
92
|
-
const BRANCHING_BLOCK_MACROS = new Set(['if', 'switch', 'timed']);
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Quote a bracket-link value as a {link} argument, escaping backslashes and
|
|
96
|
-
* double quotes so MacroLink reads the value back unchanged (#200).
|
|
97
|
-
*/
|
|
98
|
-
function quoteArg(value: string): string {
|
|
99
|
-
return `"${value.replace(/[\\"]/g, '\\$&')}"`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** A new branch for the macro token opening it, with its selectors. */
|
|
103
|
-
function newBranch(token: MacroToken): Branch {
|
|
104
|
-
return withSelectors<Branch>({ rawArgs: token.rawArgs, children: [] }, token);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** A macro or HTML element, or a token opening or closing one. */
|
|
108
|
-
type Tagged = MacroNode | HtmlNode | MacroToken | HtmlToken;
|
|
109
|
-
|
|
110
|
-
/** The name of a macro or HTML element. */
|
|
111
|
-
function tagName(tagged: Tagged): string {
|
|
112
|
-
return tagged.type === 'html' ? tagged.tag : tagged.name;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** The tag closing a macro or HTML element, as written in markup. */
|
|
116
|
-
function closerOf(tagged: Tagged): string {
|
|
117
|
-
return tagged.type === 'html' ? `</${tagged.tag}>` : `{/${tagged.name}}`;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Build an AST from a token array. Block macros are nested into trees
|
|
122
|
-
* using a stack. Throws on unclosed or mismatched macros.
|
|
123
|
-
*/
|
|
124
|
-
export function buildAST(tokens: Token[]): ASTNode[] {
|
|
125
|
-
const root: ASTNode[] = [];
|
|
126
|
-
|
|
127
|
-
// Stack entries: the node being built and its token start position
|
|
128
|
-
const stack: { node: MacroNode | HtmlNode; start: number }[] = [];
|
|
129
|
-
|
|
130
|
-
function current(): ASTNode[] {
|
|
131
|
-
if (stack.length === 0) return root;
|
|
132
|
-
const top = stack[stack.length - 1]!.node;
|
|
133
|
-
// For if-blocks, append to the last branch's children
|
|
134
|
-
if (top.type === 'macro' && top.branches && top.branches.length > 0) {
|
|
135
|
-
return top.branches[top.branches.length - 1]!.children;
|
|
136
|
-
}
|
|
137
|
-
return top.children;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Pop the node on top of the stack, which the closing `token` (`</b>`,
|
|
142
|
-
* `{/if}`) must close. Names match ignoring case.
|
|
143
|
-
*/
|
|
144
|
-
function close(token: MacroToken | HtmlToken) {
|
|
145
|
-
const found = closerOf(token);
|
|
146
|
-
if (stack.length === 0) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
`Unexpected closing ${found} (at character ${token.start})`,
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const top = stack[stack.length - 1]!;
|
|
153
|
-
if (
|
|
154
|
-
top.node.type !== token.type ||
|
|
155
|
-
tagName(top.node).toLowerCase() !== tagName(token).toLowerCase()
|
|
156
|
-
) {
|
|
157
|
-
throw new Error(
|
|
158
|
-
`Expected ${closerOf(top.node)} but found ${found} (at character ${token.start})`,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
stack.pop();
|
|
163
|
-
current().push(top.node);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
for (const token of tokens) {
|
|
167
|
-
switch (token.type) {
|
|
168
|
-
case 'text':
|
|
169
|
-
current().push({ type: 'text', value: token.value });
|
|
170
|
-
break;
|
|
171
|
-
|
|
172
|
-
case 'link': {
|
|
173
|
-
const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
|
|
174
|
-
current().push(
|
|
175
|
-
withSelectors<MacroNode>(
|
|
176
|
-
{ type: 'macro', name: 'link', rawArgs, children: [] },
|
|
177
|
-
token,
|
|
178
|
-
),
|
|
179
|
-
);
|
|
180
|
-
break;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
case 'variable':
|
|
184
|
-
current().push(
|
|
185
|
-
withSelectors<VariableNode>(
|
|
186
|
-
{ type: 'variable', name: token.name, scope: token.scope },
|
|
187
|
-
token,
|
|
188
|
-
),
|
|
189
|
-
);
|
|
190
|
-
break;
|
|
191
|
-
|
|
192
|
-
case 'expression':
|
|
193
|
-
current().push(
|
|
194
|
-
withSelectors<ExpressionNode>(
|
|
195
|
-
{ type: 'expression', expression: token.expression },
|
|
196
|
-
token,
|
|
197
|
-
),
|
|
198
|
-
);
|
|
199
|
-
break;
|
|
200
|
-
|
|
201
|
-
case 'html': {
|
|
202
|
-
const htmlNode: HtmlNode = {
|
|
203
|
-
type: 'html',
|
|
204
|
-
tag: token.tag,
|
|
205
|
-
attributes: token.attributes,
|
|
206
|
-
children: [],
|
|
207
|
-
};
|
|
208
|
-
if (token.isSelfClose) {
|
|
209
|
-
// Self-closing HTML tag (br, hr, img, etc.)
|
|
210
|
-
current().push(htmlNode);
|
|
211
|
-
} else if (token.isClose) {
|
|
212
|
-
// Closing HTML tag — pop from stack
|
|
213
|
-
close(token);
|
|
214
|
-
} else {
|
|
215
|
-
// Opening HTML tag — push onto stack
|
|
216
|
-
stack.push({ node: htmlNode, start: token.start });
|
|
217
|
-
}
|
|
218
|
-
break;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
case 'macro': {
|
|
222
|
-
if (token.isClose) {
|
|
223
|
-
// Closing tag — pop from stack
|
|
224
|
-
close(token);
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Normalize macro name to lowercase — registration is lowercase,
|
|
229
|
-
// but the tokenizer preserves original casing from passage markup.
|
|
230
|
-
const name = token.name.toLowerCase();
|
|
231
|
-
|
|
232
|
-
// Handle branch macros (elseif/else, case/default, next)
|
|
233
|
-
const expectedParent = Object.prototype.hasOwnProperty.call(
|
|
234
|
-
BRANCH_PARENT,
|
|
235
|
-
name,
|
|
236
|
-
)
|
|
237
|
-
? BRANCH_PARENT[name]
|
|
238
|
-
: undefined;
|
|
239
|
-
if (expectedParent) {
|
|
240
|
-
const topNode =
|
|
241
|
-
stack.length > 0 ? stack[stack.length - 1]!.node : null;
|
|
242
|
-
if (
|
|
243
|
-
!topNode ||
|
|
244
|
-
topNode.type !== 'macro' ||
|
|
245
|
-
topNode.name !== expectedParent
|
|
246
|
-
) {
|
|
247
|
-
throw new Error(
|
|
248
|
-
`{${token.name}} without matching {${expectedParent}} (at character ${token.start})`,
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
topNode.branches!.push(newBranch(token));
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const node: MacroNode = {
|
|
257
|
-
type: 'macro',
|
|
258
|
-
name,
|
|
259
|
-
rawArgs: token.rawArgs,
|
|
260
|
-
children: [],
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
if (!BLOCK_MACROS.has(name)) {
|
|
264
|
-
// Self-closing macro (set, print, etc.)
|
|
265
|
-
current().push(withSelectors(node, token));
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Block macro — push onto stack. Branching blocks: className/id
|
|
270
|
-
// goes on the first branch, not the node
|
|
271
|
-
if (BRANCHING_BLOCK_MACROS.has(name))
|
|
272
|
-
node.branches = [newBranch(token)];
|
|
273
|
-
else withSelectors(node, token);
|
|
274
|
-
stack.push({ node, start: token.start });
|
|
275
|
-
break;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
default: {
|
|
279
|
-
const _exhaustive: never = token;
|
|
280
|
-
throw new Error(`Unknown token type: ${(_exhaustive as Token).type}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (stack.length > 0) {
|
|
286
|
-
const unclosed = stack[stack.length - 1]!;
|
|
287
|
-
const label =
|
|
288
|
-
unclosed.node.type === 'html'
|
|
289
|
-
? `<${unclosed.node.tag}>`
|
|
290
|
-
: `{${unclosed.node.name}} macro`;
|
|
291
|
-
throw new Error(
|
|
292
|
-
`Unclosed ${label} (opened at character ${unclosed.start})`,
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return root;
|
|
297
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isSigil } from './tokens';
|
|
2
|
+
import { defaultCodeEnd } from './code-end';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Attributes whose value is code with braces of its own, not text: event
|
|
@@ -29,7 +30,6 @@ export type SigilPart =
|
|
|
29
30
|
*/
|
|
30
31
|
export function splitSigilTemplate(template: string): SigilPart[] {
|
|
31
32
|
const parts: SigilPart[] = [];
|
|
32
|
-
const memo = createScanMemo();
|
|
33
33
|
let text = '';
|
|
34
34
|
let i = 0;
|
|
35
35
|
|
|
@@ -42,7 +42,7 @@ export function splitSigilTemplate(template: string): SigilPart[] {
|
|
|
42
42
|
text += template.slice(i, brace);
|
|
43
43
|
i = brace + 1;
|
|
44
44
|
const end = isSigil(template[i])
|
|
45
|
-
?
|
|
45
|
+
? defaultCodeEnd.closeBrace(template, i, i)
|
|
46
46
|
: -1;
|
|
47
47
|
if (end === -1) {
|
|
48
48
|
text += '{';
|