@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.
@@ -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 { tokenize } from './markup/tokenizer';
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 tokenizer reads it back:
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 `&quot;`, 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
- const tokens = tokenize(`<i ${probe}=${quoted}>`);
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
- return tokenize(value, { text: true })
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 { tokenize } from './markup/tokenizer';
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: tokenize, parse, and render all macros
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 tokens = tokenize(storyInit.content);
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,
@@ -1,10 +1,24 @@
1
1
  import type { Passage } from './parser';
2
- import { tokenize, type Token } from './markup/tokenizer';
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, tokenize(text, { text: true }), storeVarMacros, onRef);
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, tokenize(content), storeVarMacros, onRef);
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 tokenizer split it up.
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
+ }
@@ -21,7 +21,19 @@ import type {
21
21
  MacroProps as PublishedMacroProps,
22
22
  ASTNode as PublishedASTNode,
23
23
  } from '../types/index';
24
- import type { parseStoryVariables as PublishedParse } from '../types/tooling';
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
+ }
@@ -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;