@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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markup validation: parse every passage when the story starts and report
|
|
3
|
+
* malformed markup and unknown macros with the passage, line and column
|
|
4
|
+
* they are at, instead of failing when the passage renders. Tooling runs it
|
|
5
|
+
* too (see src/tooling.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { lineColumn, MarkupError, parseMarkup, tokenizeMarkup } from './parse';
|
|
8
|
+
import type { Token } from './tokens';
|
|
9
|
+
import { isCodeAttribute } from './code-attributes';
|
|
10
|
+
import { parseWidgetDef } from '../widgets/widget-def';
|
|
11
|
+
|
|
12
|
+
/** A passage to validate. */
|
|
13
|
+
export interface MarkupPassage {
|
|
14
|
+
name: string;
|
|
15
|
+
content: string;
|
|
16
|
+
tags?: string[];
|
|
17
|
+
/**
|
|
18
|
+
* Passage attributes; `data-source-file` and `data-source-line` (the
|
|
19
|
+
* line of its `::` header) place errors in the source file.
|
|
20
|
+
*/
|
|
21
|
+
metadata?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An error in a passage's markup. */
|
|
25
|
+
export interface MarkupDiagnostic {
|
|
26
|
+
passage: string;
|
|
27
|
+
/** 1-based line within the passage's content. */
|
|
28
|
+
line: number;
|
|
29
|
+
/** 1-based column (UTF-16 code units). */
|
|
30
|
+
column: number;
|
|
31
|
+
message: string;
|
|
32
|
+
/** The source file and line, when the passage says where it came from. */
|
|
33
|
+
file?: string;
|
|
34
|
+
fileLine?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MarkupValidationOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Whether a macro of this name exists. Widgets that passages define with
|
|
40
|
+
* `{widget}` and branch macros (`{else}`, `{case}`, …) always do.
|
|
41
|
+
*/
|
|
42
|
+
isKnownMacro(name: string): boolean;
|
|
43
|
+
/** Names to suggest for a misspelt macro. */
|
|
44
|
+
macroNames?: Iterable<string>;
|
|
45
|
+
/** Whether a macro takes a body (default: the registered block macros). */
|
|
46
|
+
isBlockMacro?(name: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Report on only the passages for which this holds (default: all). The
|
|
49
|
+
* others are still read for the widgets they define.
|
|
50
|
+
*/
|
|
51
|
+
only?(passage: MarkupPassage): boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Passages that hold no markup. */
|
|
55
|
+
const NOT_MARKUP = new Set(['StoryVariables', 'StoryTransients']);
|
|
56
|
+
const NOT_MARKUP_TAGS = ['script', 'stylesheet'];
|
|
57
|
+
|
|
58
|
+
/** Branch macros, which their parent macro renders. */
|
|
59
|
+
const BRANCH_MACROS = new Set(['elseif', 'else', 'case', 'default', 'next']);
|
|
60
|
+
|
|
61
|
+
/** A diagnostic as one line of text. */
|
|
62
|
+
export function formatDiagnostic(d: MarkupDiagnostic): string {
|
|
63
|
+
const source = d.file
|
|
64
|
+
? ` (${d.file}${d.fileLine ? `:${d.fileLine}` : ''})`
|
|
65
|
+
: '';
|
|
66
|
+
return `Passage "${d.passage}", line ${d.line}, column ${d.column}${source}: ${d.message}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Edit distance, for "did you mean" suggestions. */
|
|
70
|
+
function distance(a: string, b: string): number {
|
|
71
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
72
|
+
for (let i = 1; i <= a.length; i++) {
|
|
73
|
+
const row = [i];
|
|
74
|
+
for (let j = 1; j <= b.length; j++) {
|
|
75
|
+
row[j] = Math.min(
|
|
76
|
+
prev[j]! + 1,
|
|
77
|
+
row[j - 1]! + 1,
|
|
78
|
+
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
prev = row;
|
|
82
|
+
}
|
|
83
|
+
return prev[b.length]!;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** " Did you mean {name}?" for the closest known name, if one is close. */
|
|
87
|
+
function suggestion(name: string, names: readonly string[]): string {
|
|
88
|
+
const lower = name.toLowerCase();
|
|
89
|
+
let best = '';
|
|
90
|
+
let bestDistance = Math.max(2, Math.floor(lower.length / 3)) + 1;
|
|
91
|
+
for (const candidate of names) {
|
|
92
|
+
const d = distance(lower, candidate.toLowerCase());
|
|
93
|
+
if (d < bestDistance) {
|
|
94
|
+
best = candidate;
|
|
95
|
+
bestDistance = d;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return best ? ` Did you mean {${best}}?` : '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The name of the widget a `{widget}` with these arguments defines. */
|
|
102
|
+
function widgetName(rawArgs: string): string | undefined {
|
|
103
|
+
try {
|
|
104
|
+
return parseWidgetDef(rawArgs).name.toLowerCase() || undefined;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined; // the {widget} macro reports it when it renders
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Validate the markup of every passage. */
|
|
111
|
+
export function validateMarkup(
|
|
112
|
+
passages: Iterable<MarkupPassage>,
|
|
113
|
+
options: MarkupValidationOptions,
|
|
114
|
+
): MarkupDiagnostic[] {
|
|
115
|
+
const diagnostics: MarkupDiagnostic[] = [];
|
|
116
|
+
const hooks = options.isBlockMacro
|
|
117
|
+
? { isBlock: options.isBlockMacro }
|
|
118
|
+
: undefined;
|
|
119
|
+
const report = (passage: MarkupPassage, offset: number, message: string) => {
|
|
120
|
+
const { line, column } = lineColumn(passage.content, offset);
|
|
121
|
+
const d: MarkupDiagnostic = {
|
|
122
|
+
passage: passage.name,
|
|
123
|
+
line,
|
|
124
|
+
column,
|
|
125
|
+
message,
|
|
126
|
+
};
|
|
127
|
+
const file = passage.metadata?.['data-source-file'];
|
|
128
|
+
if (file) {
|
|
129
|
+
d.file = file;
|
|
130
|
+
const header = Number(passage.metadata?.['data-source-line']);
|
|
131
|
+
if (header) d.fileLine = header + line;
|
|
132
|
+
}
|
|
133
|
+
diagnostics.push(d);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Parse every passage first: the widgets they define are known macros.
|
|
137
|
+
const parsed: [MarkupPassage, Token[]][] = [];
|
|
138
|
+
const widgets = new Set<string>();
|
|
139
|
+
for (const passage of passages) {
|
|
140
|
+
if (NOT_MARKUP.has(passage.name)) continue;
|
|
141
|
+
if (passage.tags?.some((tag) => NOT_MARKUP_TAGS.includes(tag))) continue;
|
|
142
|
+
const reported = !options.only || options.only(passage);
|
|
143
|
+
try {
|
|
144
|
+
// The tokens first: they don't depend on which macros take a body
|
|
145
|
+
const tokens = tokenizeMarkup(passage.content);
|
|
146
|
+
for (const token of tokens) {
|
|
147
|
+
if (token.type !== 'macro' || token.isClose) continue;
|
|
148
|
+
if (token.name.toLowerCase() !== 'widget') continue;
|
|
149
|
+
const name = widgetName(token.rawArgs);
|
|
150
|
+
if (name) widgets.add(name);
|
|
151
|
+
}
|
|
152
|
+
if (!reported) continue;
|
|
153
|
+
parseMarkup(passage.content, { hooks });
|
|
154
|
+
parsed.push([passage, tokens]);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (!(err instanceof MarkupError)) throw err;
|
|
157
|
+
if (reported) {
|
|
158
|
+
report(passage, err.offset, err.reason);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const names = [...(options.macroNames ?? []), ...widgets];
|
|
163
|
+
|
|
164
|
+
/** Report the unknown macros among `tokens`, which start at `base`. */
|
|
165
|
+
const checkMacros = (
|
|
166
|
+
passage: MarkupPassage,
|
|
167
|
+
tokens: Token[],
|
|
168
|
+
base: number,
|
|
169
|
+
where: string,
|
|
170
|
+
) => {
|
|
171
|
+
for (const token of tokens) {
|
|
172
|
+
if (token.type !== 'macro' || token.isClose) continue;
|
|
173
|
+
const lower = token.name.toLowerCase();
|
|
174
|
+
if (
|
|
175
|
+
BRANCH_MACROS.has(lower) ||
|
|
176
|
+
widgets.has(lower) ||
|
|
177
|
+
options.isKnownMacro(lower)
|
|
178
|
+
) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
report(
|
|
182
|
+
passage,
|
|
183
|
+
base + token.start,
|
|
184
|
+
`${where}Unknown macro {${token.name}}.${suggestion(token.name, names)}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (const [passage, tokens] of parsed) {
|
|
190
|
+
checkMacros(passage, tokens, 0, '');
|
|
191
|
+
for (const token of tokens) {
|
|
192
|
+
if (token.type !== 'html') continue;
|
|
193
|
+
for (const [attr, value] of Object.entries(token.attributes)) {
|
|
194
|
+
if (isCodeAttribute(attr) || !value.includes('{')) continue;
|
|
195
|
+
// The value is the source text between its quotes
|
|
196
|
+
const found = passage.content.indexOf(value, token.start);
|
|
197
|
+
const at = found === -1 ? token.start : found;
|
|
198
|
+
const where = `In the ${attr} attribute of <${token.tag}>: `;
|
|
199
|
+
try {
|
|
200
|
+
parseMarkup(value, { text: true, hooks });
|
|
201
|
+
checkMacros(
|
|
202
|
+
passage,
|
|
203
|
+
tokenizeMarkup(value, { text: true }),
|
|
204
|
+
at,
|
|
205
|
+
where,
|
|
206
|
+
);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (!(err instanceof MarkupError)) throw err;
|
|
209
|
+
report(
|
|
210
|
+
passage,
|
|
211
|
+
found === -1 ? at : at + err.offset,
|
|
212
|
+
where + err.reason,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return diagnostics;
|
|
219
|
+
}
|
package/src/parser.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MarkupError, tokenizeMarkup } from './markup/parse';
|
|
2
2
|
import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
3
3
|
|
|
4
4
|
export interface Passage {
|
|
@@ -88,7 +88,7 @@ function elementMarkup(el: Element): string {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* An attribute value quoted so that the passage
|
|
91
|
+
* An attribute value quoted so that the passage parser reads it back:
|
|
92
92
|
* in double quotes, else single ones, else with the double quotes in its
|
|
93
93
|
* text written as `"`, which rendering decodes. Quotes inside markup in
|
|
94
94
|
* the value (`{print "a"}`) are code, and don't end it.
|
|
@@ -97,7 +97,14 @@ function quoteAttribute(name: string, value: string): string {
|
|
|
97
97
|
const code = isCodeAttribute(name);
|
|
98
98
|
const probe = code ? 'on' : 'a';
|
|
99
99
|
const readsBack = (quoted: string) => {
|
|
100
|
-
|
|
100
|
+
let tokens;
|
|
101
|
+
try {
|
|
102
|
+
tokens = tokenizeMarkup(`<i ${probe}=${quoted}>`);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Malformed markup in the value, such as an unclosed quote
|
|
105
|
+
if (err instanceof MarkupError) return false;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
101
108
|
const tag = tokens[0];
|
|
102
109
|
return (
|
|
103
110
|
tokens.length === 1 &&
|
|
@@ -129,7 +136,15 @@ function escapeTextQuotes(value: string, code: boolean): string {
|
|
|
129
136
|
)
|
|
130
137
|
.join('');
|
|
131
138
|
}
|
|
132
|
-
|
|
139
|
+
let tokens;
|
|
140
|
+
try {
|
|
141
|
+
tokens = tokenizeMarkup(value, { text: true });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// Malformed markup: validation reports it when the story starts
|
|
144
|
+
if (err instanceof MarkupError) return escape(value);
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
return tokens
|
|
133
148
|
.map((token) => {
|
|
134
149
|
const source = value.slice(token.start, token.end);
|
|
135
150
|
return token.type === 'text' ? escape(source) : source;
|
package/src/story-init.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
2
|
import { useStoryStore, recordStoryInitState } from './store';
|
|
3
|
-
import {
|
|
4
|
-
import { buildAST } from './markup/ast';
|
|
3
|
+
import { parseMarkup } from './markup/parse';
|
|
5
4
|
import { renderNodes } from './markup/render';
|
|
6
5
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
7
6
|
import { emit } from './event-emitter';
|
|
@@ -22,7 +21,7 @@ function unmountStoryInit(): void {
|
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
* Execute the StoryInit passage:
|
|
24
|
+
* Execute the StoryInit passage: parse it and render all macros
|
|
26
25
|
* into a detached DOM node so their side effects fire through the normal
|
|
27
26
|
* Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
|
|
28
27
|
* Re-executing (on restart) first unmounts the previous StoryInit tree.
|
|
@@ -35,8 +34,7 @@ export function executeStoryInit() {
|
|
|
35
34
|
|
|
36
35
|
const storyInit = state.storyData.passages.get('StoryInit');
|
|
37
36
|
if (storyInit) {
|
|
38
|
-
const
|
|
39
|
-
const ast = buildAST(tokens);
|
|
37
|
+
const ast = parseMarkup(storyInit.content);
|
|
40
38
|
|
|
41
39
|
// Mount into a persistent hidden container. It stays mounted until the
|
|
42
40
|
// next execution (restart) — this lets async effects (useEffect,
|
package/src/story-variables.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type { Passage } from './parser';
|
|
2
|
-
import {
|
|
2
|
+
import type { Token } from './markup/tokens';
|
|
3
|
+
import { MarkupError, tokenizeMarkup } from './markup/parse';
|
|
3
4
|
import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
4
5
|
import { errorMessage } from './utils/error-message';
|
|
5
6
|
import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
|
|
6
7
|
import { createNamespace, variableNameError } from './utils/namespace';
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The tokens of markup, or none if it is malformed: validateMarkup reports
|
|
11
|
+
* that, with its position.
|
|
12
|
+
*/
|
|
13
|
+
function tokensOf(text: string, textMode: boolean): Token[] {
|
|
14
|
+
try {
|
|
15
|
+
return tokenizeMarkup(text, { text: textMode });
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err instanceof MarkupError) return [];
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
|
|
9
23
|
|
|
10
24
|
export interface FieldSchema {
|
|
@@ -217,7 +231,7 @@ function scanInterpolations(
|
|
|
217
231
|
storeVarMacros: ReadonlySet<string> = NO_STORE_VAR_MACROS,
|
|
218
232
|
): void {
|
|
219
233
|
if (!text.includes('{')) return;
|
|
220
|
-
collectTokenRefs(text,
|
|
234
|
+
collectTokenRefs(text, tokensOf(text, true), storeVarMacros, onRef);
|
|
221
235
|
}
|
|
222
236
|
|
|
223
237
|
/**
|
|
@@ -281,7 +295,7 @@ function collectPassageRefs(
|
|
|
281
295
|
storeVarMacros: ReadonlySet<string>,
|
|
282
296
|
onRef: RefCallback,
|
|
283
297
|
): void {
|
|
284
|
-
collectTokenRefs(content,
|
|
298
|
+
collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef);
|
|
285
299
|
}
|
|
286
300
|
|
|
287
301
|
/** Report the `$var` references in the tokens of `content`. */
|
|
@@ -313,7 +327,7 @@ function collectTokenRefs(
|
|
|
313
327
|
|
|
314
328
|
if (token.name === 'do') {
|
|
315
329
|
// A {do} body is JavaScript: scan its source text as code, however
|
|
316
|
-
// the markup
|
|
330
|
+
// the markup tokens split it up.
|
|
317
331
|
let close = t + 1;
|
|
318
332
|
while (close < tokens.length) {
|
|
319
333
|
const c = tokens[close]!;
|
package/src/tooling.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the tooling entry point (`@rohal12/spindle/tooling`) bundles as
|
|
3
|
+
* dist/pkg/story-variables.js: the StoryVariables parser and the markup
|
|
4
|
+
* validator, with no Preact or DOM dependencies. pkg/tooling.js re-exports
|
|
5
|
+
* them, binding the validator to its macro metadata.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
validateMarkup,
|
|
9
|
+
type MarkupDiagnostic,
|
|
10
|
+
type MarkupPassage,
|
|
11
|
+
} from './markup/validate';
|
|
12
|
+
import { isBlockMacro } from './markup/ast';
|
|
13
|
+
import { blockWidgetNames } from './widgets/widget-def';
|
|
14
|
+
|
|
15
|
+
export { parseStoryVariables } from './story-variables';
|
|
16
|
+
export { formatDiagnostic } from './markup/validate';
|
|
17
|
+
export type { MarkupDiagnostic, MarkupPassage } from './markup/validate';
|
|
18
|
+
|
|
19
|
+
/** What tooling knows about a macro (see MacroMetadata). */
|
|
20
|
+
export interface ToolingMacro {
|
|
21
|
+
name: string;
|
|
22
|
+
block: boolean;
|
|
23
|
+
subMacros: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate the markup of a story's passages as Spindle does when the story
|
|
28
|
+
* starts, against the given macros (built-in and user-defined): malformed
|
|
29
|
+
* markup and unknown macros, with their passage, line and column.
|
|
30
|
+
*/
|
|
31
|
+
export function validateStoryMarkup(
|
|
32
|
+
passages: Iterable<MarkupPassage>,
|
|
33
|
+
macros: Iterable<ToolingMacro>,
|
|
34
|
+
): MarkupDiagnostic[] {
|
|
35
|
+
const list = [...passages];
|
|
36
|
+
const known = new Set<string>();
|
|
37
|
+
const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
|
|
38
|
+
for (const macro of macros) {
|
|
39
|
+
const name = macro.name.toLowerCase();
|
|
40
|
+
known.add(name);
|
|
41
|
+
for (const sub of macro.subMacros) known.add(sub.toLowerCase());
|
|
42
|
+
if (macro.block) blocks.add(name);
|
|
43
|
+
}
|
|
44
|
+
return validateMarkup(list, {
|
|
45
|
+
isKnownMacro: (name) => known.has(name),
|
|
46
|
+
macroNames: known,
|
|
47
|
+
isBlockMacro: (name) =>
|
|
48
|
+
blocks.has(name.toLowerCase()) || isBlockMacro(name),
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/types-drift-check.ts
CHANGED
|
@@ -21,7 +21,19 @@ import type {
|
|
|
21
21
|
MacroProps as PublishedMacroProps,
|
|
22
22
|
ASTNode as PublishedASTNode,
|
|
23
23
|
} from '../types/index';
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
parseStoryVariables as PublishedParse,
|
|
26
|
+
formatDiagnostic as PublishedFormatDiagnostic,
|
|
27
|
+
MarkupDiagnostic as PublishedMarkupDiagnostic,
|
|
28
|
+
MarkupPassage as PublishedMarkupPassage,
|
|
29
|
+
validateMarkup as PublishedValidateMarkup,
|
|
30
|
+
} from '../types/tooling';
|
|
31
|
+
import type {
|
|
32
|
+
formatDiagnostic as SourceFormatDiagnostic,
|
|
33
|
+
MarkupDiagnostic as SourceMarkupDiagnostic,
|
|
34
|
+
MarkupPassage as SourceMarkupPassage,
|
|
35
|
+
validateStoryMarkup as SourceValidateStoryMarkup,
|
|
36
|
+
} from './tooling';
|
|
25
37
|
import type { bootStory as PublishedBootStory } from '../types/headless';
|
|
26
38
|
|
|
27
39
|
/** What the source declares, by published name. */
|
|
@@ -30,6 +42,11 @@ interface Source {
|
|
|
30
42
|
// Tooling entry point (`@rohal12/spindle/tooling`): the parser that
|
|
31
43
|
// dist/pkg/tooling.js re-exports.
|
|
32
44
|
parseStoryVariables: typeof SourceParse;
|
|
45
|
+
// Its markup validator (pkg/tooling.js binds validateStoryMarkup to the
|
|
46
|
+
// macro metadata, so validateMarkup itself takes only the passages).
|
|
47
|
+
MarkupPassage: SourceMarkupPassage;
|
|
48
|
+
MarkupDiagnostic: SourceMarkupDiagnostic;
|
|
49
|
+
formatDiagnostic: typeof SourceFormatDiagnostic;
|
|
33
50
|
// Custom macro API: the MacroContext/MacroDefinition defineMacro()
|
|
34
51
|
// actually passes and accepts.
|
|
35
52
|
MacroContext: SourceMacroContext;
|
|
@@ -44,6 +61,9 @@ interface Source {
|
|
|
44
61
|
interface Published {
|
|
45
62
|
StoryAPI: PublishedAPI;
|
|
46
63
|
parseStoryVariables: typeof PublishedParse;
|
|
64
|
+
MarkupPassage: PublishedMarkupPassage;
|
|
65
|
+
MarkupDiagnostic: PublishedMarkupDiagnostic;
|
|
66
|
+
formatDiagnostic: typeof PublishedFormatDiagnostic;
|
|
47
67
|
MacroContext: PublishedMacroContext;
|
|
48
68
|
MacroDefinition: PublishedMacroDefinition;
|
|
49
69
|
MacroProps: PublishedMacroProps;
|
|
@@ -90,3 +110,10 @@ const _exampleMacro: PublishedMacroDefinition = {
|
|
|
90
110
|
);
|
|
91
111
|
},
|
|
92
112
|
};
|
|
113
|
+
|
|
114
|
+
// pkg/tooling.js's validateMarkup is validateStoryMarkup bound to the macro
|
|
115
|
+
// metadata.
|
|
116
|
+
declare const validateStoryMarkup: typeof SourceValidateStoryMarkup;
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
118
|
+
const _validateMarkup: typeof PublishedValidateMarkup = (passages) =>
|
|
119
|
+
validateStoryMarkup(passages, []);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading widget definitions without rendering anything: the `{widget}`
|
|
3
|
+
* macro, startup and markup validation (also in tooling) share these.
|
|
4
|
+
*/
|
|
5
|
+
import { parseMacroArgs } from '../components/macros/macro-args';
|
|
6
|
+
import type { MacroArgs } from '../registry';
|
|
7
|
+
|
|
8
|
+
/** A {widget} definition's name, then its `@` parameters. */
|
|
9
|
+
export const WIDGET_PARAMETERS = [
|
|
10
|
+
{ name: 'name', type: 'text', required: true },
|
|
11
|
+
{ name: 'parameters', type: 'expression' },
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export interface WidgetDef {
|
|
15
|
+
name: string;
|
|
16
|
+
params: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The widget a {widget} definition's arguments declare: its name and its
|
|
21
|
+
* parameters, the words after it that start with `@` (docs/widgets.md).
|
|
22
|
+
* Other words, such as `$name`, are not parameters.
|
|
23
|
+
*/
|
|
24
|
+
export function widgetDef({
|
|
25
|
+
name = '',
|
|
26
|
+
parameters = '',
|
|
27
|
+
}: MacroArgs<typeof WIDGET_PARAMETERS>): WidgetDef {
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
params: parameters.split(/\s+/).filter((word) => word.startsWith('@')),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read the arguments of a {widget} definition (see widgetDef). */
|
|
35
|
+
export function parseWidgetDef(rawArgs: string): WidgetDef {
|
|
36
|
+
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The names of the block widgets (those whose body renders `{@children}`)
|
|
43
|
+
* that StoryInit and the passages tagged `widget` define. They must be
|
|
44
|
+
* known as block macros before any passage is parsed, so that passages
|
|
45
|
+
* invoking them nest their content whatever the passage order.
|
|
46
|
+
*/
|
|
47
|
+
export function blockWidgetNames(
|
|
48
|
+
passages: Iterable<{ name: string; tags?: string[]; content: string }>,
|
|
49
|
+
): string[] {
|
|
50
|
+
const names: string[] = [];
|
|
51
|
+
for (const passage of passages) {
|
|
52
|
+
if (passage.name !== 'StoryInit' && !passage.tags?.includes('widget')) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const match of passage.content.matchAll(BLOCK_WIDGET)) {
|
|
56
|
+
if (/\{@children\}/.test(match[2]!)) names.push(match[1]!);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return names;
|
|
60
|
+
}
|
package/types/tooling.d.ts
CHANGED
|
@@ -99,3 +99,45 @@ export declare function parseStoryVariables(
|
|
|
99
99
|
content: string,
|
|
100
100
|
sigil?: '$' | '%',
|
|
101
101
|
): Map<string, VariableSchema>;
|
|
102
|
+
|
|
103
|
+
/** A passage to validate. */
|
|
104
|
+
export interface MarkupPassage {
|
|
105
|
+
name: string;
|
|
106
|
+
content: string;
|
|
107
|
+
tags?: string[];
|
|
108
|
+
/**
|
|
109
|
+
* Passage attributes; `data-source-file` and `data-source-line` (the
|
|
110
|
+
* line of its `::` header) place errors in the source file.
|
|
111
|
+
*/
|
|
112
|
+
metadata?: Record<string, string>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** An error in a passage's markup. */
|
|
116
|
+
export interface MarkupDiagnostic {
|
|
117
|
+
passage: string;
|
|
118
|
+
/** 1-based line within the passage's content. */
|
|
119
|
+
line: number;
|
|
120
|
+
/** 1-based column (UTF-16 code units). */
|
|
121
|
+
column: number;
|
|
122
|
+
message: string;
|
|
123
|
+
/** The source file and line, when the passage says where it came from. */
|
|
124
|
+
file?: string;
|
|
125
|
+
fileLine?: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate the markup of a story's passages as Spindle does when the story
|
|
130
|
+
* starts: malformed markup (unclosed or mismatched macros, tags, links,
|
|
131
|
+
* braces and attribute values) and unknown macros, checked against the
|
|
132
|
+
* built-in macros, those registered with `defineMacro` and the widgets the
|
|
133
|
+
* passages define. Spindle refuses to start a story with any of these.
|
|
134
|
+
*/
|
|
135
|
+
export declare function validateMarkup(
|
|
136
|
+
passages: Iterable<MarkupPassage>,
|
|
137
|
+
): MarkupDiagnostic[];
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A diagnostic as one line of text, as Spindle shows it:
|
|
141
|
+
* `Passage "Start", line 3, column 5 (story.twee:12): Unclosed {if}: …`.
|
|
142
|
+
*/
|
|
143
|
+
export declare function formatDiagnostic(diagnostic: MarkupDiagnostic): string;
|