@rohal12/spindle 0.52.3 → 0.52.5

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.
@@ -1,61 +1,91 @@
1
- import { createJsScanCache, findCodeEnd, type JsScanCache } from '../js-lexer';
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
- export interface TextToken {
5
- type: 'text';
6
- value: string;
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 LinkToken {
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: 'variable' | 'temporary' | 'local' | 'transient';
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
- // Pipe syntax: display|target
110
- const pipeIdx = inner.indexOf('|');
111
- if (pipeIdx !== -1) {
112
- return {
113
- display: inner.slice(0, pipeIdx).trim(),
114
- target: inner.slice(pipeIdx + 1).trim(),
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, and position after last segment.
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
- ): { className: string; id: string; endIdx: number } {
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] === '{' && SIGIL_CHARS.has(input[i + 1]!)) {
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
- return { className: classes.join(' '), id, endIdx: i };
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
- : SIGIL_CHARS.has(input[j + 1]!)
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
- let at = open + 1;
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
- tokens.push({
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
- * when `flush`. Returns false, consuming nothing, if the block is unclosed.
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
- flush: boolean,
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
- if (flush) flushText(start);
843
- const token: ExpressionToken = {
844
- type: 'expression',
845
- expression: input.slice(exprStart, closeIdx),
846
- start,
847
- end: closeIdx + 1,
848
- };
849
- if (className) token.className = className;
850
- if (id) token.id = id;
851
- tokens.push(token);
852
- i = textStart = closeIdx + 1;
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
- tokens.push({ type: 'text', value: next, start: k - 1, end: k + 1 });
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 += 2;
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
- const inner = input.slice(innerStart, closeIdx);
915
- i = closeIdx + 2; // skip ]]
916
-
917
- const { display, target } = parseLink(inner);
918
- const linkToken: LinkToken = {
919
- type: 'link',
920
- display,
921
- target,
922
- start,
923
- end: i,
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 { — macro or variable (with optional .class prefix)
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
- let nextChar = input[i + 1];
936
-
937
- // Check for .class/#id prefix: {.foo#bar $var} or {#id.foo macroName ...}
938
- let className: string | undefined;
939
- let id: string | undefined;
940
- if (nextChar === '.' || nextChar === '#') {
941
- flushText(i);
942
- const parsed = parseSelectors(input, i + 1);
943
- className = parsed.className || undefined;
944
- id = parsed.id || undefined;
945
- // After selectors, check what follows (space then $ or _ or letter)
946
- let afterSelectors = parsed.endIdx;
947
- if (input[afterSelectors] === ' ') afterSelectors++;
948
- const charAfter = input[afterSelectors];
949
-
950
- if (charAfter === '$') {
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
- flushText(i);
1349
-
1350
- // Scan to closing }, tracking brace nesting (object literals)
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
- // Just a bare { — treat as regular text
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
- tokens.push({
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
- tokens.push({
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
  }