@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/src/define-macro.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readBoundVariable,
|
|
35
35
|
} from './components/macros/macro-args';
|
|
36
36
|
import {
|
|
37
|
+
checkParameterTypes,
|
|
37
38
|
registerMacro,
|
|
38
39
|
registerMacroText,
|
|
39
40
|
registerSubMacro,
|
|
@@ -136,6 +137,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
136
137
|
source: 'builtin' | 'user' = 'builtin',
|
|
137
138
|
): void {
|
|
138
139
|
const parameters: readonly ParameterDef[] = config.parameters ?? [];
|
|
140
|
+
checkParameterTypes(config.name, parameters);
|
|
139
141
|
|
|
140
142
|
function Wrapper(props: MacroProps) {
|
|
141
143
|
// className/id resolved first (interpolate may transform them)
|
package/src/expression.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { StoryState } from './store';
|
|
|
2
2
|
import { useStoryStore } from './store';
|
|
3
3
|
import type { Passage } from './parser';
|
|
4
4
|
import { random, randomInt } from './prng';
|
|
5
|
-
import {
|
|
5
|
+
import { parseCode, type JsGoal, type Sigil } from './js-lexer';
|
|
6
6
|
import {
|
|
7
7
|
EMPTY_NAMESPACE,
|
|
8
8
|
asNamespace,
|
|
@@ -63,29 +63,33 @@ const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
|
|
|
63
63
|
* Exported for tests.
|
|
64
64
|
*/
|
|
65
65
|
export function transform(expr: string, goal: JsGoal = 'expression'): string {
|
|
66
|
+
const key = goal === 'statements' ? 's' + expr : 'e' + expr;
|
|
67
|
+
const cached = transformCache.get(key);
|
|
68
|
+
if (cached !== undefined) return cached;
|
|
69
|
+
const { refs } = parseCode(expr, goal);
|
|
66
70
|
let result = '';
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
},
|
|
84
|
-
goal,
|
|
85
|
-
);
|
|
71
|
+
let at = 0;
|
|
72
|
+
for (const ref of refs) {
|
|
73
|
+
const error = variableNameError(ref.name, ref.sigil + ref.name);
|
|
74
|
+
if (error) throw new SyntaxError(`spindle: ${error}`);
|
|
75
|
+
result += expr.slice(at, ref.start);
|
|
76
|
+
if (ref.shorthand) result += `${ref.name}: `;
|
|
77
|
+
// `typeof%x` needs a space once `%x` turns into an identifier.
|
|
78
|
+
else if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
|
|
79
|
+
result += `${NAMESPACES[ref.sigil]}["${ref.name}"]`;
|
|
80
|
+
at = ref.end;
|
|
81
|
+
}
|
|
82
|
+
result += expr.slice(at);
|
|
83
|
+
if (transformCache.size >= FN_CACHE_MAX) {
|
|
84
|
+
transformCache.delete(transformCache.keys().next().value!);
|
|
85
|
+
}
|
|
86
|
+
transformCache.set(key, result);
|
|
86
87
|
return result;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/** Transformed code by goal and source. */
|
|
91
|
+
const transformCache = new Map<string, string>();
|
|
92
|
+
|
|
89
93
|
const preamble =
|
|
90
94
|
'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
|
|
91
95
|
|
|
@@ -265,6 +269,7 @@ export function execute(code: string, ...namespaces: Namespaces): void {
|
|
|
265
269
|
/** Clear the compiled expression cache. Useful for testing and HMR. */
|
|
266
270
|
export function clearExpressionCache(): void {
|
|
267
271
|
fnCache.clear();
|
|
272
|
+
transformCache.clear();
|
|
268
273
|
cachedFns = null;
|
|
269
274
|
cachedVisitCounts = null;
|
|
270
275
|
cachedRenderCounts = null;
|
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,42 @@ 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, syntax errors in
|
|
66
|
+
* code) of the passages for which `only` holds, with the macros and widgets
|
|
67
|
+
* known now.
|
|
68
|
+
*/
|
|
69
|
+
function markupErrors(
|
|
70
|
+
storyData: StoryData,
|
|
71
|
+
only: (passage: MarkupPassage) => boolean,
|
|
72
|
+
): string[] {
|
|
73
|
+
const macros = getMacroRegistry();
|
|
74
|
+
const parameters = new Map(
|
|
75
|
+
macros.map((m) => [m.name.toLowerCase(), m.parameters]),
|
|
76
|
+
);
|
|
77
|
+
return validateMarkup(storyData.passages.values(), {
|
|
78
|
+
isKnownMacro: (name) =>
|
|
79
|
+
!!getMacro(name) || isSubMacro(name) || !!getWidget(name),
|
|
80
|
+
macroNames: macros.map((m) => m.name),
|
|
81
|
+
parametersOf: (name) => parameters.get(name),
|
|
82
|
+
only,
|
|
83
|
+
}).map(formatDiagnostic);
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
/**
|
|
49
87
|
* Boot Spindle in the current document: parse `<tw-storydata>`, install the
|
|
50
|
-
* `Story` API, run author JavaScript, validate `StoryVariables
|
|
51
|
-
* `StoryInit
|
|
88
|
+
* `Story` API, run author JavaScript, validate `StoryVariables` and
|
|
89
|
+
* `StoryInit`'s markup, run `StoryInit`, validate the other passages' markup
|
|
90
|
+
* (with the macros StoryInit defined), and render into `#root`. Dispatches `:storyready` when the
|
|
52
91
|
* first passage is shown. Call once per page (module state is global).
|
|
53
92
|
* The browser story format calls it from `main.tsx`.
|
|
54
93
|
*/
|
|
@@ -101,7 +140,18 @@ export function boot() {
|
|
|
101
140
|
const storeVarMacros = getMacroRegistry()
|
|
102
141
|
.filter((m) => m.storeVar)
|
|
103
142
|
.map((m) => m.name);
|
|
143
|
+
// Pass 1: Register the block widgets as block macros BEFORE any passage
|
|
144
|
+
// is parsed (validation and StoryInit included), so that passages
|
|
145
|
+
// invoking block widgets and widget bodies using other block widgets
|
|
146
|
+
// parse correctly regardless of passage or definition order.
|
|
147
|
+
for (const name of blockWidgetNames(storyData.passages.values())) {
|
|
148
|
+
registerBlockMacro(name);
|
|
149
|
+
}
|
|
150
|
+
|
|
104
151
|
const errors = validatePassages(storyData.passages, schema, storeVarMacros);
|
|
152
|
+
// StoryInit's markup must be valid before it runs; the other passages are
|
|
153
|
+
// validated once it has run, as it may define macros (see below).
|
|
154
|
+
errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
|
|
105
155
|
|
|
106
156
|
// Parse StoryTransients (optional — no error if missing)
|
|
107
157
|
let transientDefaults: Record<string, unknown> = {};
|
|
@@ -124,13 +174,7 @@ export function boot() {
|
|
|
124
174
|
transientDefaults = extractDefaults(transientSchema);
|
|
125
175
|
}
|
|
126
176
|
|
|
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
|
-
}
|
|
177
|
+
if (errors.length > 0) stopWithErrors(errors);
|
|
134
178
|
|
|
135
179
|
defaults = extractDefaults(schema);
|
|
136
180
|
setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
|
|
@@ -140,36 +184,18 @@ export function boot() {
|
|
|
140
184
|
// Enter runtime phase — handlers registered from here on are cleaned on restart
|
|
141
185
|
enterRuntimePhase();
|
|
142
186
|
|
|
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
187
|
// Run StoryInit, restore the session if the page was refreshed, and fire
|
|
165
188
|
// storyinit after all state is settled (defaults + StoryInit + session)
|
|
166
189
|
initializeStory(loadSession(storyData.ifid));
|
|
167
190
|
|
|
191
|
+
// Every other passage's markup, now that StoryInit may have defined macros
|
|
192
|
+
const markup = markupErrors(storyData, (p) => p.name !== 'StoryInit');
|
|
193
|
+
if (markup.length > 0) stopWithErrors(markup);
|
|
194
|
+
|
|
168
195
|
// Pass 2: Full parse and register widgets from passages tagged "widget"
|
|
169
196
|
for (const [, passage] of storyData.passages) {
|
|
170
197
|
if (passage.tags.includes('widget')) {
|
|
171
|
-
const
|
|
172
|
-
const widgetAST = buildAST(widgetTokens);
|
|
198
|
+
const widgetAST = parseMarkup(passage.content);
|
|
173
199
|
for (const node of widgetAST) {
|
|
174
200
|
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
175
201
|
// 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
|
}
|