@rohal12/spindle 0.52.2 → 0.52.4
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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +430 -786
- package/dist/pkg/story-variables.js +179 -476
- package/package.json +1 -1
- package/src/components/App.tsx +6 -3
- package/src/components/macros/ExprDisplay.tsx +2 -7
- package/src/components/macros/MenubarButtons.tsx +13 -12
- package/src/components/macros/PassageDisplay.tsx +8 -4
- package/src/components/macros/SaveManager.tsx +8 -4
- package/src/components/macros/VarDisplay.tsx +11 -12
- package/src/components/macros/Widget.tsx +18 -7
- package/src/event-emitter.ts +15 -11
- package/src/expression.ts +44 -42
- package/src/hooks/use-merged-locals.ts +6 -4
- package/src/hooks/use-story-fields.ts +19 -0
- package/src/index.tsx +5 -8
- package/src/interpolation.ts +16 -22
- package/src/js-lexer.ts +20 -9
- package/src/markup/ast.ts +108 -147
- package/src/markup/code-attributes.ts +4 -6
- package/src/markup/tokenizer.ts +218 -574
- package/src/settings.ts +20 -22
- package/src/store.ts +2 -1
- package/src/story-api.ts +29 -77
- package/src/story-variables.ts +5 -9
- package/src/utils/live-locals.ts +2 -4
- package/src/utils/namespace.ts +89 -25
- package/src/utils/object-path.ts +1 -3
- package/src/utils/counts.ts +0 -45
package/src/markup/tokenizer.ts
CHANGED
|
@@ -1,61 +1,91 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createJsScanCache,
|
|
3
|
+
findCodeEnd,
|
|
4
|
+
type JsScanCache,
|
|
5
|
+
type Sigil,
|
|
6
|
+
} from '../js-lexer';
|
|
2
7
|
import { isCodeAttribute } from './code-attributes';
|
|
3
8
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
/** The namespace a variable reference reads. */
|
|
10
|
+
export type VariableScope = 'variable' | 'temporary' | 'local' | 'transient';
|
|
11
|
+
|
|
12
|
+
/** Variable sigils: story ($), temporary (_), local (@), transient (%). */
|
|
13
|
+
export const SIGIL_SCOPES: Readonly<Record<Sigil, VariableScope>> = {
|
|
14
|
+
$: 'variable',
|
|
15
|
+
_: 'temporary',
|
|
16
|
+
'@': 'local',
|
|
17
|
+
'%': 'transient',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The sigil of each variable scope. */
|
|
21
|
+
export const SCOPE_SIGILS = Object.fromEntries(
|
|
22
|
+
Object.entries(SIGIL_SCOPES).map(([sigil, scope]) => [scope, sigil]),
|
|
23
|
+
) as Readonly<Record<VariableScope, Sigil>>;
|
|
24
|
+
|
|
25
|
+
const SIGIL_CHARS: ReadonlySet<string> = new Set(Object.keys(SIGIL_SCOPES));
|
|
26
|
+
|
|
27
|
+
/** Whether `c` is a variable sigil. */
|
|
28
|
+
export function isSigil(c: string | undefined): c is Sigil {
|
|
29
|
+
return c !== undefined && SIGIL_CHARS.has(c);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `.class#id` selectors written before a link, variable or macro. */
|
|
33
|
+
export interface Selectors {
|
|
34
|
+
className?: string;
|
|
35
|
+
id?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Copy the selectors set in `from` onto `target`, and return it. */
|
|
39
|
+
export function withSelectors<T extends Selectors>(
|
|
40
|
+
target: T,
|
|
41
|
+
from: Selectors,
|
|
42
|
+
): T {
|
|
43
|
+
if (from.className) target.className = from.className;
|
|
44
|
+
if (from.id) target.id = from.id;
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where a token is in the input: from `start` up to `end`. */
|
|
49
|
+
interface Span {
|
|
7
50
|
start: number;
|
|
8
51
|
end: number;
|
|
9
52
|
}
|
|
10
53
|
|
|
11
|
-
export interface
|
|
54
|
+
export interface TextToken extends Span {
|
|
55
|
+
type: 'text';
|
|
56
|
+
value: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LinkToken extends Span, Selectors {
|
|
12
60
|
type: 'link';
|
|
13
61
|
display: string;
|
|
14
62
|
target: string;
|
|
15
|
-
className?: string;
|
|
16
|
-
id?: string;
|
|
17
|
-
start: number;
|
|
18
|
-
end: number;
|
|
19
63
|
}
|
|
20
64
|
|
|
21
|
-
export interface MacroToken {
|
|
65
|
+
export interface MacroToken extends Span, Selectors {
|
|
22
66
|
type: 'macro';
|
|
23
67
|
name: string;
|
|
24
68
|
rawArgs: string;
|
|
25
69
|
isClose: boolean;
|
|
26
|
-
className?: string;
|
|
27
|
-
id?: string;
|
|
28
|
-
start: number;
|
|
29
|
-
end: number;
|
|
30
70
|
}
|
|
31
71
|
|
|
32
|
-
export interface VariableToken {
|
|
72
|
+
export interface VariableToken extends Span, Selectors {
|
|
33
73
|
type: 'variable';
|
|
34
74
|
name: string;
|
|
35
|
-
scope:
|
|
36
|
-
className?: string;
|
|
37
|
-
id?: string;
|
|
38
|
-
start: number;
|
|
39
|
-
end: number;
|
|
75
|
+
scope: VariableScope;
|
|
40
76
|
}
|
|
41
77
|
|
|
42
|
-
export interface ExpressionToken {
|
|
78
|
+
export interface ExpressionToken extends Span, Selectors {
|
|
43
79
|
type: 'expression';
|
|
44
80
|
expression: string;
|
|
45
|
-
className?: string;
|
|
46
|
-
id?: string;
|
|
47
|
-
start: number;
|
|
48
|
-
end: number;
|
|
49
81
|
}
|
|
50
82
|
|
|
51
|
-
export interface HtmlToken {
|
|
83
|
+
export interface HtmlToken extends Span {
|
|
52
84
|
type: 'html';
|
|
53
85
|
tag: string;
|
|
54
86
|
attributes: Record<string, string>;
|
|
55
87
|
isClose: boolean;
|
|
56
88
|
isSelfClose: boolean;
|
|
57
|
-
start: number;
|
|
58
|
-
end: number;
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
export type Token =
|
|
@@ -87,9 +117,6 @@ const HTML_VOID_TAGS = new Set([
|
|
|
87
117
|
'wbr',
|
|
88
118
|
]);
|
|
89
119
|
|
|
90
|
-
/** Variable sigils: story ($), temporary (_), local (@), transient (%). */
|
|
91
|
-
const SIGIL_CHARS = new Set(['$', '_', '@', '%']);
|
|
92
|
-
|
|
93
120
|
/**
|
|
94
121
|
* Characters other than a sigil that open an expression after `{`:
|
|
95
122
|
* `{(Math.max($a, 0))}`, `{!$done}`. Other characters that can start an
|
|
@@ -101,36 +128,29 @@ const EXPRESSION_START = new Set(['(', '!']);
|
|
|
101
128
|
/** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
|
|
102
129
|
const RAW_BODY_MACROS = new Set(['do']);
|
|
103
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Link separators, in the order they are tried, and whether the target
|
|
133
|
+
* comes first: display|target, display->target, target<-display.
|
|
134
|
+
*/
|
|
135
|
+
const LINK_SEPARATORS: readonly (readonly [string, boolean])[] = [
|
|
136
|
+
['|', false],
|
|
137
|
+
['->', false],
|
|
138
|
+
['<-', true],
|
|
139
|
+
];
|
|
140
|
+
|
|
104
141
|
/**
|
|
105
142
|
* Parse a Twine link interior into display and target.
|
|
106
143
|
* Supports: display|target, display->target, target<-display, plain
|
|
107
144
|
*/
|
|
108
145
|
function parseLink(inner: string): { display: string; target: string } {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// Arrow syntax: display->target
|
|
119
|
-
const arrowIdx = inner.indexOf('->');
|
|
120
|
-
if (arrowIdx !== -1) {
|
|
121
|
-
return {
|
|
122
|
-
display: inner.slice(0, arrowIdx).trim(),
|
|
123
|
-
target: inner.slice(arrowIdx + 2).trim(),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Reverse arrow: target<-display
|
|
128
|
-
const revIdx = inner.indexOf('<-');
|
|
129
|
-
if (revIdx !== -1) {
|
|
130
|
-
return {
|
|
131
|
-
target: inner.slice(0, revIdx).trim(),
|
|
132
|
-
display: inner.slice(revIdx + 2).trim(),
|
|
133
|
-
};
|
|
146
|
+
for (const [separator, targetFirst] of LINK_SEPARATORS) {
|
|
147
|
+
const idx = inner.indexOf(separator);
|
|
148
|
+
if (idx === -1) continue;
|
|
149
|
+
const before = inner.slice(0, idx).trim();
|
|
150
|
+
const after = inner.slice(idx + separator.length).trim();
|
|
151
|
+
return targetFirst
|
|
152
|
+
? { display: after, target: before }
|
|
153
|
+
: { display: before, target: after };
|
|
134
154
|
}
|
|
135
155
|
|
|
136
156
|
// Plain: [[passage]]
|
|
@@ -167,13 +187,15 @@ function parseMacroContent(content: string): {
|
|
|
167
187
|
|
|
168
188
|
/**
|
|
169
189
|
* Parse CSS selectors: .foo.bar#baz → { className: "foo bar", id: "baz" }
|
|
170
|
-
* Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order
|
|
171
|
-
* Returns space-joined class string, last id wins
|
|
190
|
+
* Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order, and one
|
|
191
|
+
* space after them. Returns space-joined class string, last id wins (each
|
|
192
|
+
* left out if empty), and the position after them: `startIdx` if there are
|
|
193
|
+
* none.
|
|
172
194
|
*/
|
|
173
195
|
function parseSelectors(
|
|
174
196
|
input: string,
|
|
175
197
|
startIdx: number,
|
|
176
|
-
): {
|
|
198
|
+
): { selectors: Selectors; end: number } {
|
|
177
199
|
const classes: string[] = [];
|
|
178
200
|
let id = '';
|
|
179
201
|
let i = startIdx;
|
|
@@ -186,7 +208,7 @@ function parseSelectors(
|
|
|
186
208
|
if (/[a-zA-Z0-9_-]/.test(input[i]!)) {
|
|
187
209
|
name += input[i];
|
|
188
210
|
i++;
|
|
189
|
-
} else if (input[i] === '{' &&
|
|
211
|
+
} else if (input[i] === '{' && isSigil(input[i + 1])) {
|
|
190
212
|
// Consume interpolation: {$var}, {_var}, {@var}, {%var} (with optional dot paths)
|
|
191
213
|
const braceStart = i;
|
|
192
214
|
i += 2; // skip { and prefix
|
|
@@ -212,7 +234,15 @@ function parseSelectors(
|
|
|
212
234
|
}
|
|
213
235
|
}
|
|
214
236
|
|
|
215
|
-
|
|
237
|
+
// Consume the space after the selectors
|
|
238
|
+
if (i > startIdx && input[i] === ' ') i++;
|
|
239
|
+
return {
|
|
240
|
+
selectors: withSelectors<Selectors>(
|
|
241
|
+
{},
|
|
242
|
+
{ className: classes.join(' '), id },
|
|
243
|
+
),
|
|
244
|
+
end: i,
|
|
245
|
+
};
|
|
216
246
|
}
|
|
217
247
|
|
|
218
248
|
/**
|
|
@@ -380,7 +410,7 @@ function scanQuotedValue(
|
|
|
380
410
|
if (input[j] === '{') {
|
|
381
411
|
const closeIdx = !code
|
|
382
412
|
? scanBlockClose(input, j, memo)
|
|
383
|
-
:
|
|
413
|
+
: isSigil(input[j + 1])
|
|
384
414
|
? scanBalancedBrace(input, j + 1, memo)
|
|
385
415
|
: -1;
|
|
386
416
|
if (closeIdx !== -1) {
|
|
@@ -502,12 +532,7 @@ function scanMacroClose(
|
|
|
502
532
|
* code from just past the {. Returns -1 if it is unclosed.
|
|
503
533
|
*/
|
|
504
534
|
function scanBlockClose(input: string, open: number, memo: ScanMemo): number {
|
|
505
|
-
|
|
506
|
-
const c = input[at];
|
|
507
|
-
if (c === '.' || c === '#') {
|
|
508
|
-
at = parseSelectors(input, at).endIdx;
|
|
509
|
-
if (input[at] === ' ') at++;
|
|
510
|
-
}
|
|
535
|
+
const at = parseSelectors(input, open + 1).end;
|
|
511
536
|
const first = input[at];
|
|
512
537
|
if (first !== undefined && (first === '/' || /[a-zA-Z]/.test(first))) {
|
|
513
538
|
return scanMacroClose(input, at, memo);
|
|
@@ -765,6 +790,7 @@ export function tokenize(
|
|
|
765
790
|
let i = 0;
|
|
766
791
|
let textStart = 0;
|
|
767
792
|
|
|
793
|
+
/** Push a text token for the text not yet tokenized before `end`. */
|
|
768
794
|
function flushText(end: number) {
|
|
769
795
|
if (end > textStart) {
|
|
770
796
|
tokens.push({
|
|
@@ -773,9 +799,16 @@ export function tokenize(
|
|
|
773
799
|
start: textStart,
|
|
774
800
|
end,
|
|
775
801
|
});
|
|
802
|
+
textStart = end;
|
|
776
803
|
}
|
|
777
804
|
}
|
|
778
805
|
|
|
806
|
+
/** Push `token` and go on after it. */
|
|
807
|
+
function emit(token: Token) {
|
|
808
|
+
tokens.push(token);
|
|
809
|
+
i = textStart = token.end;
|
|
810
|
+
}
|
|
811
|
+
|
|
779
812
|
/**
|
|
780
813
|
* After an opening raw-body macro ({do}), emit everything up to its
|
|
781
814
|
* {/name} as a single text token so JavaScript source is not parsed as
|
|
@@ -815,41 +848,99 @@ export function tokenize(
|
|
|
815
848
|
atRe.lastIndex = closeStart;
|
|
816
849
|
const closeEnd = closeStart + atRe.exec(input)![0].length;
|
|
817
850
|
flushText(closeStart);
|
|
818
|
-
|
|
851
|
+
emit({
|
|
819
852
|
type: 'macro',
|
|
820
853
|
...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
|
|
821
854
|
start: closeStart,
|
|
822
855
|
end: closeEnd,
|
|
823
856
|
});
|
|
824
|
-
i = closeEnd;
|
|
825
|
-
textStart = closeEnd;
|
|
826
857
|
}
|
|
827
858
|
|
|
828
859
|
/**
|
|
829
860
|
* Push an expression token for the `{…}` block opened at `start` whose
|
|
830
|
-
* expression starts at `exprStart`, flushing the text before it first
|
|
831
|
-
*
|
|
861
|
+
* expression starts at `exprStart`, flushing the text before it first.
|
|
862
|
+
* Returns false, consuming nothing, if the block is unclosed.
|
|
832
863
|
*/
|
|
833
864
|
function pushExpression(
|
|
834
865
|
exprStart: number,
|
|
835
866
|
start: number,
|
|
836
|
-
|
|
837
|
-
className?: string,
|
|
838
|
-
id?: string,
|
|
867
|
+
selectors: Selectors,
|
|
839
868
|
): boolean {
|
|
840
869
|
const closeIdx = scanBalancedBrace(input, exprStart, memo);
|
|
841
870
|
if (closeIdx === -1) return false;
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
871
|
+
flushText(start);
|
|
872
|
+
emit(
|
|
873
|
+
withSelectors<ExpressionToken>(
|
|
874
|
+
{
|
|
875
|
+
type: 'expression',
|
|
876
|
+
expression: input.slice(exprStart, closeIdx),
|
|
877
|
+
start,
|
|
878
|
+
end: closeIdx + 1,
|
|
879
|
+
},
|
|
880
|
+
selectors,
|
|
881
|
+
),
|
|
882
|
+
);
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Push a variable token for the `{…}` block opened at `start` whose sigil
|
|
888
|
+
* is at `sigilAt`: `{$name}`, `{_name.field.subfield}`. Anything else
|
|
889
|
+
* after the name makes the block an expression from the sigil on
|
|
890
|
+
* (`{$expr[...]}`). Returns false, consuming nothing, if it is unclosed.
|
|
891
|
+
*/
|
|
892
|
+
function pushVariable(
|
|
893
|
+
sigilAt: number,
|
|
894
|
+
start: number,
|
|
895
|
+
selectors: Selectors,
|
|
896
|
+
): boolean {
|
|
897
|
+
let nameEnd = sigilAt + 1;
|
|
898
|
+
while (nameEnd < input.length && /[\w.]/.test(input[nameEnd]!)) nameEnd++;
|
|
899
|
+
if (input[nameEnd] !== '}') {
|
|
900
|
+
// Complex expression — scan for balanced closing }
|
|
901
|
+
return pushExpression(sigilAt, start, selectors);
|
|
902
|
+
}
|
|
903
|
+
emit(
|
|
904
|
+
withSelectors<VariableToken>(
|
|
905
|
+
{
|
|
906
|
+
type: 'variable',
|
|
907
|
+
name: input.slice(sigilAt + 1, nameEnd),
|
|
908
|
+
scope: SIGIL_SCOPES[input[sigilAt] as Sigil],
|
|
909
|
+
start,
|
|
910
|
+
end: nameEnd + 1,
|
|
911
|
+
},
|
|
912
|
+
selectors,
|
|
913
|
+
),
|
|
914
|
+
);
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Push a macro token for the `{…}` block opened at `start` whose content
|
|
920
|
+
* (name, then arguments) starts at `contentStart`, then consume the body
|
|
921
|
+
* of a raw-body macro. Returns false, consuming nothing, if the block is
|
|
922
|
+
* unclosed.
|
|
923
|
+
*/
|
|
924
|
+
function pushMacro(
|
|
925
|
+
contentStart: number,
|
|
926
|
+
start: number,
|
|
927
|
+
selectors: Selectors,
|
|
928
|
+
): boolean {
|
|
929
|
+
// Scan to closing }, tracking brace nesting (object literals)
|
|
930
|
+
// and string literals
|
|
931
|
+
const closeIdx = scanMacroClose(input, contentStart, memo);
|
|
932
|
+
if (closeIdx === -1) return false;
|
|
933
|
+
const token = withSelectors<MacroToken>(
|
|
934
|
+
{
|
|
935
|
+
type: 'macro',
|
|
936
|
+
...parseMacroContent(input.slice(contentStart, closeIdx)),
|
|
937
|
+
start,
|
|
938
|
+
end: closeIdx + 1,
|
|
939
|
+
},
|
|
940
|
+
selectors,
|
|
941
|
+
);
|
|
942
|
+
emit(token);
|
|
943
|
+
consumeRawBody(token.name, token.isClose);
|
|
853
944
|
return true;
|
|
854
945
|
}
|
|
855
946
|
|
|
@@ -873,511 +964,69 @@ export function tokenize(
|
|
|
873
964
|
}
|
|
874
965
|
if ((next === '{' || next === '}') && (k - i) % 2 === 1) {
|
|
875
966
|
flushText(k - 1);
|
|
876
|
-
|
|
877
|
-
i = k + 1;
|
|
878
|
-
textStart = i;
|
|
967
|
+
emit({ type: 'text', value: next, start: k - 1, end: k + 1 });
|
|
879
968
|
continue;
|
|
880
969
|
}
|
|
881
970
|
i = k;
|
|
882
971
|
continue;
|
|
883
972
|
}
|
|
884
973
|
|
|
885
|
-
// Check for [[ link
|
|
974
|
+
// Check for [[ link, with optional .class or #id syntax after [[
|
|
886
975
|
if (!textMode && input[i] === '[' && input[i + 1] === '[') {
|
|
887
976
|
flushText(i);
|
|
888
977
|
const start = i;
|
|
889
|
-
i
|
|
890
|
-
|
|
891
|
-
// Check for .class or #id syntax after [[
|
|
892
|
-
let className: string | undefined;
|
|
893
|
-
let id: string | undefined;
|
|
894
|
-
if (input[i] === '.' || input[i] === '#') {
|
|
895
|
-
const parsed = parseSelectors(input, i);
|
|
896
|
-
className = parsed.className || undefined;
|
|
897
|
-
id = parsed.id || undefined;
|
|
898
|
-
i = parsed.endIdx;
|
|
899
|
-
// Consume trailing space after selectors
|
|
900
|
-
if (input[i] === ' ') i++;
|
|
901
|
-
}
|
|
978
|
+
const { selectors, end: innerStart } = parseSelectors(input, i + 2);
|
|
902
979
|
|
|
903
980
|
// Find closing ]]
|
|
904
|
-
const innerStart = i;
|
|
905
981
|
const closeIdx = scanLinkClose(input, innerStart, memo);
|
|
906
|
-
|
|
907
982
|
if (closeIdx === -1) {
|
|
908
983
|
// Unclosed link — treat as text
|
|
909
984
|
i = start + 2;
|
|
910
|
-
textStart = start;
|
|
911
985
|
continue;
|
|
912
986
|
}
|
|
913
987
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
if (className) linkToken.className = className;
|
|
926
|
-
if (id) linkToken.id = id;
|
|
927
|
-
tokens.push(linkToken);
|
|
928
|
-
textStart = i;
|
|
988
|
+
emit(
|
|
989
|
+
withSelectors<LinkToken>(
|
|
990
|
+
{
|
|
991
|
+
type: 'link',
|
|
992
|
+
...parseLink(input.slice(innerStart, closeIdx)),
|
|
993
|
+
start,
|
|
994
|
+
end: closeIdx + 2, // skip ]]
|
|
995
|
+
},
|
|
996
|
+
selectors,
|
|
997
|
+
),
|
|
998
|
+
);
|
|
929
999
|
continue;
|
|
930
1000
|
}
|
|
931
1001
|
|
|
932
|
-
// Check for { —
|
|
1002
|
+
// Check for { — variable, expression or macro, with an optional
|
|
1003
|
+
// .class/#id prefix: {.foo#bar $var} or {#id.foo macroName ...}
|
|
933
1004
|
if (input[i] === '{') {
|
|
934
1005
|
const start = i;
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
if (
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
// {.class#id $variable.field} or {.class $expr[...]}
|
|
952
|
-
i = afterSelectors + 1;
|
|
953
|
-
const nameStart = i;
|
|
954
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
955
|
-
const name = input.slice(nameStart, i);
|
|
956
|
-
|
|
957
|
-
if (input[i] === '}') {
|
|
958
|
-
i++; // skip }
|
|
959
|
-
const token: VariableToken = {
|
|
960
|
-
type: 'variable',
|
|
961
|
-
name,
|
|
962
|
-
scope: 'variable',
|
|
963
|
-
start,
|
|
964
|
-
end: i,
|
|
965
|
-
};
|
|
966
|
-
if (className) token.className = className;
|
|
967
|
-
if (id) token.id = id;
|
|
968
|
-
tokens.push(token);
|
|
969
|
-
textStart = i;
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
// Complex expression — scan for balanced closing }
|
|
973
|
-
const closeIdx$ = scanBalancedBrace(input, nameStart - 1, memo);
|
|
974
|
-
if (closeIdx$ !== -1) {
|
|
975
|
-
const expression = input.slice(afterSelectors, closeIdx$);
|
|
976
|
-
i = closeIdx$ + 1;
|
|
977
|
-
const token: ExpressionToken = {
|
|
978
|
-
type: 'expression',
|
|
979
|
-
expression,
|
|
980
|
-
start,
|
|
981
|
-
end: i,
|
|
982
|
-
};
|
|
983
|
-
if (className) token.className = className;
|
|
984
|
-
if (id) token.id = id;
|
|
985
|
-
tokens.push(token);
|
|
986
|
-
textStart = i;
|
|
987
|
-
continue;
|
|
988
|
-
}
|
|
989
|
-
// Unbalanced — treat as text
|
|
990
|
-
i = start + 1;
|
|
991
|
-
textStart = start;
|
|
992
|
-
continue;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
if (charAfter === '_') {
|
|
996
|
-
// {.class#id _temporary.field} or {.class _expr[...]}
|
|
997
|
-
i = afterSelectors + 1;
|
|
998
|
-
const nameStart = i;
|
|
999
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
1000
|
-
const name = input.slice(nameStart, i);
|
|
1001
|
-
|
|
1002
|
-
if (input[i] === '}') {
|
|
1003
|
-
i++; // skip }
|
|
1004
|
-
const token: VariableToken = {
|
|
1005
|
-
type: 'variable',
|
|
1006
|
-
name,
|
|
1007
|
-
scope: 'temporary',
|
|
1008
|
-
start,
|
|
1009
|
-
end: i,
|
|
1010
|
-
};
|
|
1011
|
-
if (className) token.className = className;
|
|
1012
|
-
if (id) token.id = id;
|
|
1013
|
-
tokens.push(token);
|
|
1014
|
-
textStart = i;
|
|
1015
|
-
continue;
|
|
1016
|
-
}
|
|
1017
|
-
// Complex expression — scan for balanced closing }
|
|
1018
|
-
const closeIdx_ = scanBalancedBrace(input, nameStart - 1, memo);
|
|
1019
|
-
if (closeIdx_ !== -1) {
|
|
1020
|
-
const expression = input.slice(afterSelectors, closeIdx_);
|
|
1021
|
-
i = closeIdx_ + 1;
|
|
1022
|
-
const token: ExpressionToken = {
|
|
1023
|
-
type: 'expression',
|
|
1024
|
-
expression,
|
|
1025
|
-
start,
|
|
1026
|
-
end: i,
|
|
1027
|
-
};
|
|
1028
|
-
if (className) token.className = className;
|
|
1029
|
-
if (id) token.id = id;
|
|
1030
|
-
tokens.push(token);
|
|
1031
|
-
textStart = i;
|
|
1032
|
-
continue;
|
|
1033
|
-
}
|
|
1034
|
-
// Unbalanced — treat as text
|
|
1035
|
-
i = start + 1;
|
|
1036
|
-
textStart = start;
|
|
1037
|
-
continue;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
if (charAfter === '@') {
|
|
1041
|
-
// {.class#id @local.field} or {.class @expr[...]}
|
|
1042
|
-
i = afterSelectors + 1;
|
|
1043
|
-
const nameStart = i;
|
|
1044
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
1045
|
-
const name = input.slice(nameStart, i);
|
|
1046
|
-
|
|
1047
|
-
if (input[i] === '}') {
|
|
1048
|
-
i++; // skip }
|
|
1049
|
-
const token: VariableToken = {
|
|
1050
|
-
type: 'variable',
|
|
1051
|
-
name,
|
|
1052
|
-
scope: 'local',
|
|
1053
|
-
start,
|
|
1054
|
-
end: i,
|
|
1055
|
-
};
|
|
1056
|
-
if (className) token.className = className;
|
|
1057
|
-
if (id) token.id = id;
|
|
1058
|
-
tokens.push(token);
|
|
1059
|
-
textStart = i;
|
|
1060
|
-
continue;
|
|
1061
|
-
}
|
|
1062
|
-
// Complex expression — scan for balanced closing }
|
|
1063
|
-
const closeIdx_at = scanBalancedBrace(input, nameStart - 1, memo);
|
|
1064
|
-
if (closeIdx_at !== -1) {
|
|
1065
|
-
const expression = input.slice(afterSelectors, closeIdx_at);
|
|
1066
|
-
i = closeIdx_at + 1;
|
|
1067
|
-
const token: ExpressionToken = {
|
|
1068
|
-
type: 'expression',
|
|
1069
|
-
expression,
|
|
1070
|
-
start,
|
|
1071
|
-
end: i,
|
|
1072
|
-
};
|
|
1073
|
-
if (className) token.className = className;
|
|
1074
|
-
if (id) token.id = id;
|
|
1075
|
-
tokens.push(token);
|
|
1076
|
-
textStart = i;
|
|
1077
|
-
continue;
|
|
1078
|
-
}
|
|
1079
|
-
// Unbalanced — treat as text
|
|
1080
|
-
i = start + 1;
|
|
1081
|
-
textStart = start;
|
|
1082
|
-
continue;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
if (charAfter === '%') {
|
|
1086
|
-
// {.class#id %transient.field} or {.class %expr[...]}
|
|
1087
|
-
i = afterSelectors + 1;
|
|
1088
|
-
const nameStart = i;
|
|
1089
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
1090
|
-
const name = input.slice(nameStart, i);
|
|
1091
|
-
|
|
1092
|
-
if (input[i] === '}') {
|
|
1093
|
-
i++; // skip }
|
|
1094
|
-
const token: VariableToken = {
|
|
1095
|
-
type: 'variable',
|
|
1096
|
-
name,
|
|
1097
|
-
scope: 'transient',
|
|
1098
|
-
start,
|
|
1099
|
-
end: i,
|
|
1100
|
-
};
|
|
1101
|
-
if (className) token.className = className;
|
|
1102
|
-
if (id) token.id = id;
|
|
1103
|
-
tokens.push(token);
|
|
1104
|
-
textStart = i;
|
|
1105
|
-
continue;
|
|
1106
|
-
}
|
|
1107
|
-
// Complex expression — scan for balanced closing }
|
|
1108
|
-
const closeIdx_pct = scanBalancedBrace(input, nameStart - 1, memo);
|
|
1109
|
-
if (closeIdx_pct !== -1) {
|
|
1110
|
-
const expression = input.slice(afterSelectors, closeIdx_pct);
|
|
1111
|
-
i = closeIdx_pct + 1;
|
|
1112
|
-
const token: ExpressionToken = {
|
|
1113
|
-
type: 'expression',
|
|
1114
|
-
expression,
|
|
1115
|
-
start,
|
|
1116
|
-
end: i,
|
|
1117
|
-
};
|
|
1118
|
-
if (className) token.className = className;
|
|
1119
|
-
if (id) token.id = id;
|
|
1120
|
-
tokens.push(token);
|
|
1121
|
-
textStart = i;
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
// Unbalanced — treat as text
|
|
1125
|
-
i = start + 1;
|
|
1126
|
-
textStart = start;
|
|
1127
|
-
continue;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
if (
|
|
1131
|
-
EXPRESSION_START.has(charAfter!) &&
|
|
1132
|
-
pushExpression(afterSelectors, start, false, className, id)
|
|
1133
|
-
) {
|
|
1134
|
-
continue;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
|
|
1138
|
-
// {.class#id macroName args}
|
|
1139
|
-
// Scan to closing }, tracking brace nesting and string literals
|
|
1140
|
-
const contentStart = afterSelectors;
|
|
1141
|
-
const closeIdx = scanMacroClose(input, contentStart, memo);
|
|
1142
|
-
|
|
1143
|
-
if (closeIdx === -1) {
|
|
1144
|
-
i = start + 1;
|
|
1145
|
-
textStart = start;
|
|
1146
|
-
continue;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
const content = input.slice(contentStart, closeIdx);
|
|
1150
|
-
i = closeIdx + 1; // skip closing }
|
|
1151
|
-
|
|
1152
|
-
const { name, rawArgs, isClose } = parseMacroContent(content);
|
|
1153
|
-
const token: MacroToken = {
|
|
1154
|
-
type: 'macro',
|
|
1155
|
-
name,
|
|
1156
|
-
rawArgs,
|
|
1157
|
-
isClose,
|
|
1158
|
-
start,
|
|
1159
|
-
end: i,
|
|
1160
|
-
};
|
|
1161
|
-
if (className) token.className = className;
|
|
1162
|
-
if (id) token.id = id;
|
|
1163
|
-
tokens.push(token);
|
|
1164
|
-
textStart = i;
|
|
1165
|
-
consumeRawBody(name, isClose);
|
|
1166
|
-
continue;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
// Selector prefix after { but nothing valid follows — treat as text
|
|
1170
|
-
i = start + 1;
|
|
1171
|
-
textStart = start;
|
|
1172
|
-
continue;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// {$variable} or {$variable.field.subfield} or {$expr[...]}
|
|
1176
|
-
if (nextChar === '$') {
|
|
1177
|
-
flushText(i);
|
|
1178
|
-
i += 2;
|
|
1179
|
-
const nameStart = i;
|
|
1180
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
1181
|
-
const name = input.slice(nameStart, i);
|
|
1182
|
-
|
|
1183
|
-
if (input[i] === '}') {
|
|
1184
|
-
i++; // skip }
|
|
1185
|
-
tokens.push({
|
|
1186
|
-
type: 'variable',
|
|
1187
|
-
name,
|
|
1188
|
-
scope: 'variable',
|
|
1189
|
-
start,
|
|
1190
|
-
end: i,
|
|
1191
|
-
});
|
|
1192
|
-
textStart = i;
|
|
1193
|
-
continue;
|
|
1194
|
-
}
|
|
1195
|
-
// Complex expression — scan for balanced closing }
|
|
1196
|
-
const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
|
|
1197
|
-
if (closeIdx !== -1) {
|
|
1198
|
-
const expression = input.slice(start + 1, closeIdx);
|
|
1199
|
-
i = closeIdx + 1;
|
|
1200
|
-
tokens.push({
|
|
1201
|
-
type: 'expression',
|
|
1202
|
-
expression,
|
|
1203
|
-
start,
|
|
1204
|
-
end: i,
|
|
1205
|
-
});
|
|
1206
|
-
textStart = i;
|
|
1207
|
-
continue;
|
|
1208
|
-
}
|
|
1209
|
-
// Unbalanced — treat as text
|
|
1210
|
-
i = start + 1;
|
|
1211
|
-
textStart = start;
|
|
1212
|
-
continue;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// {_temporary.field} or {_expr[...]}
|
|
1216
|
-
if (nextChar === '_') {
|
|
1217
|
-
flushText(i);
|
|
1218
|
-
i += 2;
|
|
1219
|
-
const nameStart = i;
|
|
1220
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
1221
|
-
const name = input.slice(nameStart, i);
|
|
1222
|
-
|
|
1223
|
-
if (input[i] === '}') {
|
|
1224
|
-
i++; // skip }
|
|
1225
|
-
tokens.push({
|
|
1226
|
-
type: 'variable',
|
|
1227
|
-
name,
|
|
1228
|
-
scope: 'temporary',
|
|
1229
|
-
start,
|
|
1230
|
-
end: i,
|
|
1231
|
-
});
|
|
1232
|
-
textStart = i;
|
|
1233
|
-
continue;
|
|
1234
|
-
}
|
|
1235
|
-
// Complex expression — scan for balanced closing }
|
|
1236
|
-
const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
|
|
1237
|
-
if (closeIdx !== -1) {
|
|
1238
|
-
const expression = input.slice(start + 1, closeIdx);
|
|
1239
|
-
i = closeIdx + 1;
|
|
1240
|
-
tokens.push({
|
|
1241
|
-
type: 'expression',
|
|
1242
|
-
expression,
|
|
1243
|
-
start,
|
|
1244
|
-
end: i,
|
|
1245
|
-
});
|
|
1246
|
-
textStart = i;
|
|
1247
|
-
continue;
|
|
1248
|
-
}
|
|
1249
|
-
// Unbalanced — treat as text
|
|
1250
|
-
i = start + 1;
|
|
1251
|
-
textStart = start;
|
|
1252
|
-
continue;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
// {@local.field} or {@expr[...]}
|
|
1256
|
-
if (nextChar === '@') {
|
|
1257
|
-
flushText(i);
|
|
1258
|
-
i += 2;
|
|
1259
|
-
const nameStart = i;
|
|
1260
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
1261
|
-
const name = input.slice(nameStart, i);
|
|
1262
|
-
|
|
1263
|
-
if (input[i] === '}') {
|
|
1264
|
-
i++; // skip }
|
|
1265
|
-
tokens.push({
|
|
1266
|
-
type: 'variable',
|
|
1267
|
-
name,
|
|
1268
|
-
scope: 'local',
|
|
1269
|
-
start,
|
|
1270
|
-
end: i,
|
|
1271
|
-
});
|
|
1272
|
-
textStart = i;
|
|
1273
|
-
continue;
|
|
1274
|
-
}
|
|
1275
|
-
// Complex expression — scan for balanced closing }
|
|
1276
|
-
const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
|
|
1277
|
-
if (closeIdx !== -1) {
|
|
1278
|
-
const expression = input.slice(start + 1, closeIdx);
|
|
1279
|
-
i = closeIdx + 1;
|
|
1280
|
-
tokens.push({
|
|
1281
|
-
type: 'expression',
|
|
1282
|
-
expression,
|
|
1283
|
-
start,
|
|
1284
|
-
end: i,
|
|
1285
|
-
});
|
|
1286
|
-
textStart = i;
|
|
1287
|
-
continue;
|
|
1288
|
-
}
|
|
1289
|
-
// Unbalanced — treat as text
|
|
1290
|
-
i = start + 1;
|
|
1291
|
-
textStart = start;
|
|
1292
|
-
continue;
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
// {%transient.field} or {%expr[...]}
|
|
1296
|
-
if (nextChar === '%') {
|
|
1297
|
-
flushText(i);
|
|
1298
|
-
i += 2;
|
|
1299
|
-
const nameStart = i;
|
|
1300
|
-
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
1301
|
-
const name = input.slice(nameStart, i);
|
|
1302
|
-
|
|
1303
|
-
if (input[i] === '}') {
|
|
1304
|
-
i++; // skip }
|
|
1305
|
-
tokens.push({
|
|
1306
|
-
type: 'variable',
|
|
1307
|
-
name,
|
|
1308
|
-
scope: 'transient',
|
|
1309
|
-
start,
|
|
1310
|
-
end: i,
|
|
1311
|
-
});
|
|
1312
|
-
textStart = i;
|
|
1313
|
-
continue;
|
|
1314
|
-
}
|
|
1315
|
-
// Complex expression — scan for balanced closing }
|
|
1316
|
-
const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
|
|
1317
|
-
if (closeIdx !== -1) {
|
|
1318
|
-
const expression = input.slice(start + 1, closeIdx);
|
|
1319
|
-
i = closeIdx + 1;
|
|
1320
|
-
tokens.push({
|
|
1321
|
-
type: 'expression',
|
|
1322
|
-
expression,
|
|
1323
|
-
start,
|
|
1324
|
-
end: i,
|
|
1325
|
-
});
|
|
1326
|
-
textStart = i;
|
|
1327
|
-
continue;
|
|
1328
|
-
}
|
|
1329
|
-
// Unbalanced — treat as text
|
|
1330
|
-
i = start + 1;
|
|
1331
|
-
textStart = start;
|
|
1332
|
-
continue;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
// {(expr)} or {!expr}: an expression that doesn't start with a variable
|
|
1336
|
-
if (EXPRESSION_START.has(nextChar!)) {
|
|
1337
|
-
if (pushExpression(i + 1, start, true)) continue;
|
|
1338
|
-
i++;
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// {macro ...} or {/macro} — but not bare { that's just text
|
|
1343
|
-
// Must start with a letter or /
|
|
1344
|
-
if (
|
|
1345
|
-
nextChar !== undefined &&
|
|
1346
|
-
(nextChar === '/' || /[a-zA-Z]/.test(nextChar))
|
|
1006
|
+
const { selectors, end: at } = parseSelectors(input, i + 1);
|
|
1007
|
+
const prefixed = at > i + 1;
|
|
1008
|
+
// The text before a selector prefix ends there, whatever follows it
|
|
1009
|
+
if (prefixed) flushText(start);
|
|
1010
|
+
const c = input[at];
|
|
1011
|
+
|
|
1012
|
+
if (isSigil(c)) {
|
|
1013
|
+
// {$variable.field} or {_temporary} or {@local} or {%expr[...]}
|
|
1014
|
+
flushText(start);
|
|
1015
|
+
if (pushVariable(at, start, selectors)) continue;
|
|
1016
|
+
} else if (EXPRESSION_START.has(c!)) {
|
|
1017
|
+
// {(expr)} or {!expr}: an expression that doesn't start with a variable
|
|
1018
|
+
if (pushExpression(at, start, selectors)) continue;
|
|
1019
|
+
} else if (
|
|
1020
|
+
c !== undefined &&
|
|
1021
|
+
(/[a-zA-Z]/.test(c) || (c === '/' && !prefixed))
|
|
1347
1022
|
) {
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
// and string literals
|
|
1352
|
-
const contentStart = i + 1;
|
|
1353
|
-
const closeIdx = scanMacroClose(input, contentStart, memo);
|
|
1354
|
-
|
|
1355
|
-
if (closeIdx === -1) {
|
|
1356
|
-
// Unclosed macro — treat as text
|
|
1357
|
-
i = start + 1;
|
|
1358
|
-
textStart = start;
|
|
1359
|
-
continue;
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
const content = input.slice(contentStart, closeIdx);
|
|
1363
|
-
i = closeIdx + 1; // skip closing }
|
|
1364
|
-
|
|
1365
|
-
const { name, rawArgs, isClose } = parseMacroContent(content);
|
|
1366
|
-
tokens.push({
|
|
1367
|
-
type: 'macro',
|
|
1368
|
-
name,
|
|
1369
|
-
rawArgs,
|
|
1370
|
-
isClose,
|
|
1371
|
-
start,
|
|
1372
|
-
end: i,
|
|
1373
|
-
});
|
|
1374
|
-
textStart = i;
|
|
1375
|
-
consumeRawBody(name, isClose);
|
|
1376
|
-
continue;
|
|
1023
|
+
// {macro ...} or {/macro}; a closing tag takes no selectors
|
|
1024
|
+
flushText(start);
|
|
1025
|
+
if (pushMacro(at, start, selectors)) continue;
|
|
1377
1026
|
}
|
|
1378
1027
|
|
|
1379
|
-
//
|
|
1380
|
-
i
|
|
1028
|
+
// Unclosed, or a bare { — treat as regular text
|
|
1029
|
+
i = start + 1;
|
|
1381
1030
|
continue;
|
|
1382
1031
|
}
|
|
1383
1032
|
|
|
@@ -1406,11 +1055,10 @@ export function tokenize(
|
|
|
1406
1055
|
flushText(start);
|
|
1407
1056
|
if (HTML_VOID_TAGS.has(tagLower)) {
|
|
1408
1057
|
// Void elements never take a closer; drop a redundant </input>
|
|
1409
|
-
textStart = j;
|
|
1410
|
-
i = j;
|
|
1058
|
+
i = textStart = j;
|
|
1411
1059
|
continue;
|
|
1412
1060
|
}
|
|
1413
|
-
|
|
1061
|
+
emit({
|
|
1414
1062
|
type: 'html',
|
|
1415
1063
|
tag,
|
|
1416
1064
|
attributes: {},
|
|
@@ -1419,8 +1067,6 @@ export function tokenize(
|
|
|
1419
1067
|
start,
|
|
1420
1068
|
end: j,
|
|
1421
1069
|
});
|
|
1422
|
-
textStart = j;
|
|
1423
|
-
i = j;
|
|
1424
1070
|
continue;
|
|
1425
1071
|
}
|
|
1426
1072
|
} else {
|
|
@@ -1438,7 +1084,7 @@ export function tokenize(
|
|
|
1438
1084
|
if (input[j] === '>') {
|
|
1439
1085
|
j++;
|
|
1440
1086
|
flushText(start);
|
|
1441
|
-
|
|
1087
|
+
emit({
|
|
1442
1088
|
type: 'html',
|
|
1443
1089
|
tag,
|
|
1444
1090
|
attributes: parseHtmlAttributes(input, attrsStart, memo)
|
|
@@ -1448,8 +1094,6 @@ export function tokenize(
|
|
|
1448
1094
|
start,
|
|
1449
1095
|
end: j,
|
|
1450
1096
|
});
|
|
1451
|
-
textStart = j;
|
|
1452
|
-
i = j;
|
|
1453
1097
|
continue;
|
|
1454
1098
|
}
|
|
1455
1099
|
}
|