@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,121 @@
|
|
|
1
|
+
import { findCodeEnd } from '../js-lexer';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the JavaScript inside markup ends. The grammar (spindle.peggy) leaves
|
|
5
|
+
* this to these two functions, so the JavaScript reader can be swapped (for
|
|
6
|
+
* example for acorn's `parseExpressionAt`) without touching the grammar.
|
|
7
|
+
*/
|
|
8
|
+
export interface CodeEnd {
|
|
9
|
+
/**
|
|
10
|
+
* Index of the `}` ending the code that starts at `codeStart` (macro
|
|
11
|
+
* arguments, a `{$…}` expression), or -1 if there is none. Code that is
|
|
12
|
+
* not well-formed JavaScript is read leniently from `lenientStart`.
|
|
13
|
+
*/
|
|
14
|
+
closeBrace(input: string, codeStart: number, lenientStart: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Index of the `{` of the `{/name}` ending the raw JavaScript body (of
|
|
17
|
+
* `{do}`) that starts at `bodyStart`, or -1 if there is none.
|
|
18
|
+
*/
|
|
19
|
+
rawBodyEnd(input: string, bodyStart: number, name: string): number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A quote directly after a letter/digit is an apostrophe (don't), not a
|
|
24
|
+
* string; after a backslash it is an escaped attribute delimiter (\").
|
|
25
|
+
*/
|
|
26
|
+
const NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Skip the literal opening at `i`: a '…' or "…" string, which must close on
|
|
30
|
+
* its line, or a `…` template, whose `${…}` parts are read as braces.
|
|
31
|
+
* Returns the index just past its closing quote or backtick, or -1.
|
|
32
|
+
*/
|
|
33
|
+
function skipLiteral(input: string, i: number): number {
|
|
34
|
+
const quote = input[i];
|
|
35
|
+
const template = quote === '`';
|
|
36
|
+
for (let j = i + 1; j < input.length; ) {
|
|
37
|
+
const c = input[j];
|
|
38
|
+
if (c === '\\') j += 2;
|
|
39
|
+
else if (c === quote) return j + 1;
|
|
40
|
+
else if (!template && c === '\n') return -1;
|
|
41
|
+
else if (template && c === '$' && input[j + 1] === '{') {
|
|
42
|
+
const close = lenientClose(input, j + 2);
|
|
43
|
+
if (close === -1) return -1;
|
|
44
|
+
j = close + 1;
|
|
45
|
+
} else j++;
|
|
46
|
+
}
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The closing `}` of braces read leniently from `i` (just past a `{`): braces
|
|
52
|
+
* count except inside string and template literals, and a quote that can't
|
|
53
|
+
* start a string (an apostrophe, or one not closed on its line) is text, as
|
|
54
|
+
* is a backtick without a closing one.
|
|
55
|
+
*/
|
|
56
|
+
export function lenientClose(input: string, i: number): number {
|
|
57
|
+
// Results per start, for the last input: unclosed nested template
|
|
58
|
+
// literals (`` {$a`${$a`${… ``) would otherwise take exponential time.
|
|
59
|
+
if (memoInput !== input) {
|
|
60
|
+
memoInput = input;
|
|
61
|
+
memo.clear();
|
|
62
|
+
}
|
|
63
|
+
let end = memo.get(i);
|
|
64
|
+
if (end === undefined) {
|
|
65
|
+
end = scanLenient(input, i);
|
|
66
|
+
memo.set(i, end);
|
|
67
|
+
}
|
|
68
|
+
return end;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let memoInput = '';
|
|
72
|
+
const memo = new Map<number, number>();
|
|
73
|
+
|
|
74
|
+
function scanLenient(input: string, i: number): number {
|
|
75
|
+
let depth = 0;
|
|
76
|
+
while (i < input.length) {
|
|
77
|
+
const c = input[i]!;
|
|
78
|
+
if (c === '{') {
|
|
79
|
+
depth++;
|
|
80
|
+
i++;
|
|
81
|
+
} else if (c === '}') {
|
|
82
|
+
if (depth === 0) return i;
|
|
83
|
+
depth--;
|
|
84
|
+
i++;
|
|
85
|
+
} else if (
|
|
86
|
+
c === '`' ||
|
|
87
|
+
((c === '"' || c === "'") &&
|
|
88
|
+
!(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]!)))
|
|
89
|
+
) {
|
|
90
|
+
const close = skipLiteral(input, i);
|
|
91
|
+
i = close === -1 ? i + 1 : close;
|
|
92
|
+
} else {
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The JavaScript reader Spindle uses today: js-lexer, then a lenient scan. */
|
|
100
|
+
export const defaultCodeEnd: CodeEnd = {
|
|
101
|
+
closeBrace(input, codeStart, lenientStart) {
|
|
102
|
+
const end = findCodeEnd(input, codeStart);
|
|
103
|
+
return end !== -1 ? end : lenientClose(input, lenientStart);
|
|
104
|
+
},
|
|
105
|
+
rawBodyEnd(input, bodyStart, name) {
|
|
106
|
+
const closer = `\\{/${name}\\s*\\}`;
|
|
107
|
+
const first = new RegExp(closer, 'gi');
|
|
108
|
+
first.lastIndex = bodyStart;
|
|
109
|
+
const firstAt = first.exec(input)?.index ?? -1;
|
|
110
|
+
if (firstAt === -1) return -1;
|
|
111
|
+
const at = new RegExp(closer, 'iy');
|
|
112
|
+
const end = findCodeEnd(input, bodyStart, {
|
|
113
|
+
goal: 'statements',
|
|
114
|
+
stop: (k) => {
|
|
115
|
+
at.lastIndex = k;
|
|
116
|
+
return at.test(input);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return end === -1 ? firstAt : end;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passage markup parser: the Peggy grammar (spindle.peggy) with Spindle's
|
|
3
|
+
* hooks plugged in. It builds the AST directly and reports malformed markup
|
|
4
|
+
* as a MarkupError carrying its line and column.
|
|
5
|
+
*/
|
|
6
|
+
import { parse as pegParse } from './spindle.peggy';
|
|
7
|
+
import { isBlockMacro, type ASTNode } from './ast';
|
|
8
|
+
import type { Token } from './tokens';
|
|
9
|
+
import { isCodeAttribute } from './code-attributes';
|
|
10
|
+
import { defaultCodeEnd, type CodeEnd } from './code-end';
|
|
11
|
+
|
|
12
|
+
/** Macros whose body is JavaScript source, kept verbatim. */
|
|
13
|
+
const RAW_BODY_MACROS = new Set(['do']);
|
|
14
|
+
|
|
15
|
+
/** What the grammar leaves to code. */
|
|
16
|
+
export interface MarkupHooks extends CodeEnd {
|
|
17
|
+
/** Whether a macro takes a body closed by `{/name}`. */
|
|
18
|
+
isBlock(name: string): boolean;
|
|
19
|
+
/** Whether a macro's body is JavaScript, kept verbatim (`{do}`). */
|
|
20
|
+
isRaw(name: string): boolean;
|
|
21
|
+
/** Whether an attribute's value is code (`onclick`), not markup. */
|
|
22
|
+
isCodeAttribute(name: string): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultHooks: MarkupHooks = {
|
|
26
|
+
...defaultCodeEnd,
|
|
27
|
+
isBlock: isBlockMacro,
|
|
28
|
+
isRaw: (name) => RAW_BODY_MACROS.has(name),
|
|
29
|
+
isCodeAttribute,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Malformed markup, with where it starts (1-based line and column). */
|
|
33
|
+
export class MarkupError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
/** What is wrong, without the position. */
|
|
36
|
+
readonly reason: string,
|
|
37
|
+
/** Where it is, from the start of the markup (0-based). */
|
|
38
|
+
readonly offset: number,
|
|
39
|
+
readonly line: number,
|
|
40
|
+
readonly column: number,
|
|
41
|
+
) {
|
|
42
|
+
super(`${reason} (line ${line}, column ${column})`);
|
|
43
|
+
this.name = 'MarkupError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ParseMarkupOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Text mode, for markup that becomes a string (HTML attribute values,
|
|
50
|
+
* macro labels): only `{…}` markup and brace escapes are recognized, while
|
|
51
|
+
* `[[` and `<` are text. With no markdown to pair up the backslashes of a
|
|
52
|
+
* run before a brace, they are paired up here: `\\{` is one backslash
|
|
53
|
+
* before a live brace, `\\\{` one before a literal one.
|
|
54
|
+
*/
|
|
55
|
+
text?: boolean;
|
|
56
|
+
/** Replace some hooks, e.g. which macros take a body. */
|
|
57
|
+
hooks?: Partial<MarkupHooks>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 1-based line and column of `offset` in `text`. */
|
|
61
|
+
export function lineColumn(
|
|
62
|
+
text: string,
|
|
63
|
+
offset: number,
|
|
64
|
+
): { line: number; column: number } {
|
|
65
|
+
let line = 1;
|
|
66
|
+
let lineStart = 0;
|
|
67
|
+
for (let i = text.indexOf('\n'); i !== -1 && i < offset; ) {
|
|
68
|
+
line++;
|
|
69
|
+
lineStart = i + 1;
|
|
70
|
+
i = text.indexOf('\n', lineStart);
|
|
71
|
+
}
|
|
72
|
+
return { line, column: offset - lineStart + 1 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(
|
|
76
|
+
source: string,
|
|
77
|
+
startRule: 'Markup' | 'Tokens',
|
|
78
|
+
options: ParseMarkupOptions,
|
|
79
|
+
): unknown {
|
|
80
|
+
const hooks = options.hooks
|
|
81
|
+
? { ...defaultHooks, ...options.hooks }
|
|
82
|
+
: defaultHooks;
|
|
83
|
+
try {
|
|
84
|
+
return pegParse(source, { startRule, text: options.text === true, hooks });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const location = (err as { location?: { start: { offset: number } } })
|
|
87
|
+
.location;
|
|
88
|
+
if (err instanceof Error && err.name === 'SyntaxError' && location) {
|
|
89
|
+
const { offset } = location.start;
|
|
90
|
+
const { line, column } = lineColumn(source, offset);
|
|
91
|
+
throw new MarkupError(err.message, offset, line, column);
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse markup into its AST: macros with their bodies and branches, HTML
|
|
99
|
+
* elements with their children. Throws a MarkupError for malformed markup.
|
|
100
|
+
*/
|
|
101
|
+
export function parseMarkup(
|
|
102
|
+
source: string,
|
|
103
|
+
options: ParseMarkupOptions = {},
|
|
104
|
+
): ASTNode[] {
|
|
105
|
+
return run(source, 'Markup', options) as ASTNode[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The flat tokens of markup, without nesting, so unclosed or mismatched
|
|
110
|
+
* macros and elements are no error here. Throws a MarkupError for a
|
|
111
|
+
* malformed tag, such as an unclosed `{`, `[[` or attribute value.
|
|
112
|
+
*/
|
|
113
|
+
export function tokenizeMarkup(
|
|
114
|
+
source: string,
|
|
115
|
+
options: ParseMarkupOptions = {},
|
|
116
|
+
): Token[] {
|
|
117
|
+
return run(source, 'Tokens', options) as Token[];
|
|
118
|
+
}
|
package/src/markup/render.tsx
CHANGED
|
@@ -297,7 +297,7 @@ function convertDomNode(
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
// Convert attributes, as author HTML's (see splitAttributes): markdown
|
|
300
|
-
// output holds raw HTML
|
|
300
|
+
// output holds raw HTML from text nodes (a custom macro's, or comments)
|
|
301
301
|
const svg = el.namespaceURI === SVG_NAMESPACE;
|
|
302
302
|
const attributes: Attribute[] = [];
|
|
303
303
|
let placeholders: Record<string, ASTNode[]> | undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Types of the parser spindle.peggy compiles to (see scripts/peggy.ts).
|
|
2
|
+
|
|
3
|
+
/** A position in the input; only `offset` is used by parse.ts. */
|
|
4
|
+
export interface Location {
|
|
5
|
+
offset: number;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** What the generated parser throws for malformed markup. */
|
|
11
|
+
export declare class SyntaxError extends globalThis.SyntaxError {
|
|
12
|
+
location: { start: Location; end: Location };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ParseOptions {
|
|
16
|
+
startRule: 'Markup' | 'Tokens';
|
|
17
|
+
/** Text mode (attribute values, labels). */
|
|
18
|
+
text: boolean;
|
|
19
|
+
/** What the grammar leaves to code (see parse.ts MarkupHooks). */
|
|
20
|
+
hooks: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse markup: the AST for `Markup`, the tokens for `Tokens`. */
|
|
24
|
+
export declare function parse(input: string, options: ParseOptions): unknown;
|