@excom/quark-parser 0.1.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.
Files changed (32) hide show
  1. package/.rush/temp/chunked-rush-logs/quark-parser.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/quark-parser.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +5 -0
  11. package/index.ts +12 -0
  12. package/package.json +39 -0
  13. package/rush-logs/quark-parser.apply-exports.cache.log +1 -0
  14. package/rush-logs/quark-parser.apply-exports.log +1 -0
  15. package/rush-logs/quark-parser.build_package-metas.cache.log +1 -0
  16. package/rush-logs/quark-parser.build_package-metas.log +1 -0
  17. package/src/error.ts +24 -0
  18. package/src/parser.ts +1482 -0
  19. package/src/tables.ts +77 -0
  20. package/src/tokenizer.ts +443 -0
  21. package/src/types.ts +497 -0
  22. package/support/docs/README.md +443 -0
  23. package/support/package-meta.json +33 -0
  24. package/support/tests/grammar-docs.test.ts +109 -0
  25. package/support/tests/parser-at-rules.test.ts +430 -0
  26. package/support/tests/parser-declarations.test.ts +152 -0
  27. package/support/tests/parser-edge-cases.test.ts +296 -0
  28. package/support/tests/parser-expressions.test.ts +413 -0
  29. package/support/tests/parser-real-world.test.ts +429 -0
  30. package/support/tests/parser-selectors.test.ts +169 -0
  31. package/support/tests/tokenizer.test.ts +268 -0
  32. package/tsconfig.json +5 -0
@@ -0,0 +1,430 @@
1
+ import { parse, QUARK_AT_RULES } from "../../index";
2
+ import type { QuarkAtRuleName } from "../../index";
3
+ import {
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+
9
+ const first = (src: string): any => parse(src).body[0];
10
+
11
+ describe("at-rules", () => {
12
+ it("parses @use with a namespace", () => {
13
+ expect(first('@use "/api-client.js" as api;')).toMatchObject({
14
+ type: "atrule",
15
+ name: "use",
16
+ url: "/api-client.js",
17
+ namespace: "api",
18
+ });
19
+ });
20
+
21
+ it("parses @use with a global namespace", () => {
22
+ expect(first('@use "src/corners" as *;').namespace).toBe("*");
23
+ expect(first('@use "src/corners";').namespace).toBeNull();
24
+ });
25
+
26
+ it("parses @debug, @warn, and @error", () => {
27
+ expect(first('@debug "value: " + $v;').value.operator).toBe("+");
28
+ expect(first("@warn $w;").name).toBe("warn");
29
+ expect(first('@error "boom";').name).toBe("error");
30
+ });
31
+
32
+ it("parses @on with one or more events, options and a block", () => {
33
+ const one = first('@on click (handle: save($draft)) { is-saved: ""; }');
34
+ expect(one).toMatchObject({ type: "atrule", name: "on" });
35
+ expect(one.events.map((e: any) => [e.name, e.quoted])).toEqual([
36
+ ["click", false],
37
+ ]);
38
+ expect(one.options.map((o: any) => [o.name, o.value.type])).toEqual([
39
+ ["handle", "function"],
40
+ ]);
41
+ expect(one.block.body).toHaveLength(1);
42
+ const many = first(
43
+ '@on input, change, "my:evt" (debounce: 300) { data-draft: event.target.value; }'
44
+ );
45
+ expect(many.events.map((e: any) => [e.name, e.quoted])).toEqual([
46
+ ["input", false],
47
+ ["change", false],
48
+ ["my:evt", true],
49
+ ]);
50
+ expect(many.block.body.map((s: any) => s.type)).toEqual(["declaration"]);
51
+ // a bare block needs no options
52
+ const bare = first(
53
+ "@on click { data-count: $n + 1; span { content: $n; } }"
54
+ );
55
+ expect(bare.options).toEqual([]);
56
+ expect(bare.block.body.map((s: any) => s.type)).toEqual([
57
+ "declaration",
58
+ "rule",
59
+ ]);
60
+ // spans cover the whole statement including the closing brace
61
+ const src = "form { @on submit, reset (prevent-default) { x: 1; } }";
62
+ const nested = (parse(src).body[0] as any).block.body[0];
63
+ expect(src.slice(nested.start, nested.end)).toBe(
64
+ "@on submit, reset (prevent-default) { x: 1; }"
65
+ );
66
+ expect(src.slice(nested.events[1].start, nested.events[1].end)).toBe(
67
+ "reset"
68
+ );
69
+ });
70
+
71
+ it("parses the @on statement form: options without a block", () => {
72
+ const stmt = first("@on submit (prevent-default);");
73
+ expect(stmt.block).toBeNull();
74
+ expect(stmt.options.map((o: any) => [o.name, o.value])).toEqual([
75
+ ["prevent-default", null],
76
+ ]);
77
+ const handle = first(
78
+ "@on click, submit (debounce: 200, handle: (track, saveDraft($draft)));"
79
+ );
80
+ expect(handle.events.map((e: any) => e.name)).toEqual(["click", "submit"]);
81
+ expect(handle.options.map((o: any) => o.name)).toEqual([
82
+ "debounce",
83
+ "handle",
84
+ ]);
85
+ expect(handle.options[1].value.type).toBe("list");
86
+ expect(handle.options[1].value.parens).toBe(true);
87
+ expect(handle.options[1].value.items).toHaveLength(2);
88
+ // the `;` is optional before `}`; spans stop at the group
89
+ const src = "form { @on submit (prevent-default) }";
90
+ const nested = (parse(src).body[0] as any).block.body[0];
91
+ expect(src.slice(nested.start, nested.end)).toBe(
92
+ "@on submit (prevent-default)"
93
+ );
94
+ });
95
+
96
+ it("parses @on options: flags, keyed values, expression values", () => {
97
+ const flags = first(
98
+ "@on click (once, self, passive, capture, prevent-default, stop-propagation, stop-immediate-propagation) { x: 1; }"
99
+ );
100
+ expect(flags.options.map((o: any) => [o.name, o.value])).toEqual([
101
+ ["once", null],
102
+ ["self", null],
103
+ ["passive", null],
104
+ ["capture", null],
105
+ ["prevent-default", null],
106
+ ["stop-propagation", null],
107
+ ["stop-immediate-propagation", null],
108
+ ]);
109
+ const keyed = first(
110
+ '@on keydown (key: "Shift+K", target: "li[data-id]", debounce: 300, host: window, handle: a) { x: 1; }'
111
+ );
112
+ expect(keyed.options.map((o: any) => [o.name, o.value.type])).toEqual([
113
+ ["key", "string"],
114
+ ["target", "string"],
115
+ ["debounce", "number"],
116
+ ["host", "identifier"],
117
+ ["handle", "identifier"],
118
+ ]);
119
+ expect(keyed.options[3].value.name).toBe("window");
120
+ // values may be expressions
121
+ const block = first(
122
+ '@on click (target: $row-selector, throttle: 100 * 2) { is-open: ""; }'
123
+ );
124
+ expect(block.options.map((o: any) => o.value.type)).toEqual([
125
+ "variable",
126
+ "binary",
127
+ ]);
128
+ // option spans
129
+ const src = 'ul { @on click (target: "li", handle: pick); }';
130
+ const nested = (parse(src).body[0] as any).block.body[0];
131
+ expect(src.slice(nested.options[0].start, nested.options[0].end)).toBe(
132
+ 'target: "li"'
133
+ );
134
+ expect(src.slice(nested.start, nested.end)).toBe(
135
+ '@on click (target: "li", handle: pick);'
136
+ );
137
+ });
138
+
139
+ it("rejects malformed @on options", () => {
140
+ expect(() => parse("@on click (once once) { }")).toThrow(/"," or "\)"/);
141
+ expect(() => parse("@on click (once, once) { }")).toThrow(/Duplicate/);
142
+ expect(() => parse("@on click (key:) { }")).toThrow(/value after/);
143
+ expect(() => parse('@on click ("x") { }')).toThrow(/option name/);
144
+ expect(() => parse("@on click (once { }")).toThrow();
145
+ });
146
+
147
+ it("rejects @on without events or anything to do, the removed handler list, and @off", () => {
148
+ expect(() => parse("@on;")).toThrow(/event name/);
149
+ expect(() => parse("@on click,;")).toThrow(/after ","/);
150
+ expect(() => parse("@on click;")).toThrow(/nothing to do/);
151
+ expect(() => parse("@on click ();")).toThrow(/nothing to do/);
152
+ expect(() => parse("@on click prevent-default;")).toThrow(/handle:/);
153
+ expect(() => parse("@on click (once) go;")).toThrow(/handle:/);
154
+ expect(() => parse("@on click a, b { x: 1; }")).toThrow(/handle:/);
155
+ expect(() => parse("@off click a;")).toThrow(/@off is not supported/);
156
+ expect(() => parse("form { @off submit save; }")).toThrow(
157
+ /@off is not supported/
158
+ );
159
+ });
160
+
161
+ it("parses @dispatch and @command statements", () => {
162
+ const d = first(
163
+ '@dispatch cart-add (detail: (sku: $sku), target: "cart-view", bubbles: false);'
164
+ );
165
+ expect(d).toMatchObject({ type: "atrule", name: "dispatch" });
166
+ expect(d.names.map((n: any) => [n.name, n.quoted])).toEqual([
167
+ ["cart-add", false],
168
+ ]);
169
+ expect(
170
+ d.options.map((o: any) => [o.name, o.value?.type ?? null])
171
+ ).toEqual([
172
+ ["detail", "map"],
173
+ ["target", "string"],
174
+ ["bubbles", "boolean"],
175
+ ]);
176
+ const many = first('@dispatch a, "b:c";');
177
+ expect(many.names.map((n: any) => n.name)).toEqual(["a", "b:c"]);
178
+ expect(many.options).toEqual([]);
179
+ const c = first('@command --refresh, show-modal (target: "#feed");');
180
+ expect(c).toMatchObject({ type: "atrule", name: "command" });
181
+ expect(c.names.map((n: any) => n.name)).toEqual(["--refresh", "show-modal"]);
182
+ // nested in an @on block; `;` optional before `}`; spans
183
+ const src = 'button { @on click { @dispatch ping (target: "#out") } }';
184
+ const on = (parse(src).body[0] as any).block.body[0];
185
+ const action = on.block.body[0];
186
+ expect(action.name).toBe("dispatch");
187
+ expect(src.slice(action.start, action.end)).toBe(
188
+ '@dispatch ping (target: "#out")'
189
+ );
190
+ });
191
+
192
+ it("rejects malformed @dispatch / @command", () => {
193
+ expect(() => parse("@dispatch;")).toThrow(/event name/);
194
+ expect(() => parse("@dispatch ping { }")).toThrow(/statement/);
195
+ expect(() => parse("@command --x go;")).toThrow(/"\(" or ";"/);
196
+ expect(() => parse("@dispatch a (detail: 1, detail: 2);")).toThrow(
197
+ /Duplicate/
198
+ );
199
+ });
200
+
201
+ it("parses @view-transition blocks with and without options", () => {
202
+ const bare = first(
203
+ "@view-transition { data-count: $n + 1; ul { content: $n; } }"
204
+ );
205
+ expect(bare).toMatchObject({
206
+ type: "atrule",
207
+ name: "view-transition",
208
+ options: [],
209
+ });
210
+ expect(bare.block.type).toBe("block");
211
+ expect(bare.block.body.map((s: any) => s.type)).toEqual([
212
+ "declaration",
213
+ "rule",
214
+ ]);
215
+ const opts = first(
216
+ '@view-transition (types: "a b", timeout: 1500, delay: 200, first-render, if-active: replace, until: "[is-x]") { x: 1; }'
217
+ );
218
+ expect(
219
+ opts.options.map((o: any) => [o.name, o.value?.type ?? null])
220
+ ).toEqual([
221
+ ["types", "string"],
222
+ ["timeout", "number"],
223
+ ["delay", "number"],
224
+ ["first-render", null],
225
+ ["if-active", "identifier"],
226
+ ["until", "string"],
227
+ ]);
228
+ // values are expressions: lists, calls, interpolated strings
229
+ const exprs = first(
230
+ '@view-transition (types: ("a", "b"), until: prop("load"), types-extra: "todo-#{$op}") { x: 1; }'
231
+ );
232
+ expect(exprs.options.map((o: any) => o.value.type)).toEqual([
233
+ "list",
234
+ "function",
235
+ "string",
236
+ ]);
237
+ expect(first("@view-transition () { x: 1; }").options).toEqual([]);
238
+ });
239
+
240
+ it("parses @view-transition inside rules, @on blocks, @scope and another @view-transition", () => {
241
+ const src =
242
+ 'ul { @view-transition (types: "t") { content: $n; li { x: 1; } } }';
243
+ const nested = (parse(src).body[0] as any).block.body[0];
244
+ expect(nested.name).toBe("view-transition");
245
+ expect(src.slice(nested.start, nested.end)).toBe(
246
+ '@view-transition (types: "t") { content: $n; li { x: 1; } }'
247
+ );
248
+ expect(src.slice(nested.options[0].start, nested.options[0].end)).toBe(
249
+ 'types: "t"'
250
+ );
251
+ const inOn = (
252
+ parse('form { @on submit { @view-transition { is-saved: ""; } } }')
253
+ .body[0] as any
254
+ ).block.body[0].block.body[0];
255
+ expect(inOn.name).toBe("view-transition");
256
+ const inScope = (
257
+ parse("@scope { @view-transition { ul { x: 1; } } }").body[0] as any
258
+ ).block.body[0];
259
+ expect(inScope.name).toBe("view-transition");
260
+ const inner = first(
261
+ '@view-transition { @view-transition (types: "in") { x: 1; } }'
262
+ ).block.body[0];
263
+ expect(inner).toMatchObject({ name: "view-transition" });
264
+ expect(inner.options[0].name).toBe("types");
265
+ expect(
266
+ first("@view-transition { @on click { x: 1; } }").block.body[0].name
267
+ ).toBe("on");
268
+ });
269
+
270
+ it("rejects @view-transition without a block and malformed options", () => {
271
+ expect(() => parse("@view-transition;")).toThrow(/needs a block/);
272
+ expect(() => parse('ul { @view-transition (types: "a"); }')).toThrow(
273
+ /needs a block/
274
+ );
275
+ expect(() => parse("@view-transition (types types) { x: 1; }")).toThrow(
276
+ /"," or "\)" in @view-transition/
277
+ );
278
+ expect(() =>
279
+ parse('@view-transition (types: "a", types: "b") { x: 1; }')
280
+ ).toThrow(/Duplicate @view-transition option "types"/);
281
+ expect(() => parse("@view-transition (timeout:) { x: 1; }")).toThrow(
282
+ /value after @view-transition option "timeout:"/
283
+ );
284
+ expect(() => parse('@view-transition ("x") { x: 1; }')).toThrow(
285
+ /option name inside @view-transition/
286
+ );
287
+ expect(() => parse("@view-transition (types: 1 { x: 1; }")).toThrow();
288
+ expect(() => parse("@view-transition { x: 1;")).toThrow(/Unclosed block/);
289
+ });
290
+
291
+ it("parses @delay blocks: one duration expression, then a block", () => {
292
+ const literal = first(
293
+ "@delay 2000 { data-copied: none; span { content: none; } }"
294
+ );
295
+ expect(literal).toMatchObject({ type: "atrule", name: "delay" });
296
+ expect(literal.duration).toMatchObject({ type: "number", value: 2000 });
297
+ expect(literal.block.body.map((s: any) => s.type)).toEqual([
298
+ "declaration",
299
+ "rule",
300
+ ]);
301
+ // the duration is any expression: bindings, calls, arithmetic, `or` fallbacks
302
+ expect(first("@delay $ms * 2 { x: 1; }").duration.type).toBe("binary");
303
+ expect(
304
+ first('@delay +attr("data-ms") or 1500 { x: 1; }').duration.type
305
+ ).toBe("binary");
306
+ expect(
307
+ first("@delay math.clamp(100, $ms, 5000) { x: 1; }").duration.type
308
+ ).toBe("function");
309
+ // spans cover the whole at-rule
310
+ const src =
311
+ 'button { @on click { data-copied: ""; @delay 2000 { data-copied: none; } } }';
312
+ const on = (parse(src).body[0] as any).block.body[0];
313
+ const delay = on.block.body[1];
314
+ expect(delay.name).toBe("delay");
315
+ expect(src.slice(delay.start, delay.end)).toBe(
316
+ "@delay 2000 { data-copied: none; }"
317
+ );
318
+ // nests: inside rules, @on blocks, @view-transition and another @delay
319
+ const nested = first(
320
+ "@delay 100 { @delay 200 { x: 1; } @view-transition { y: 2; } }"
321
+ );
322
+ expect(nested.block.body.map((s: any) => s.name)).toEqual([
323
+ "delay",
324
+ "view-transition",
325
+ ]);
326
+ expect(
327
+ first("@scope { @delay 5 { a { x: 1; } } }").block.body[0].name
328
+ ).toBe("delay");
329
+ });
330
+
331
+ it("rejects @delay without a duration or a block", () => {
332
+ expect(() => parse("@delay { x: 1; }")).toThrow(/duration after @delay/);
333
+ expect(() => parse("@delay;")).toThrow(/duration after @delay/);
334
+ expect(() => parse("@delay 2000;")).toThrow(/needs a block/);
335
+ expect(() => parse("@delay 2000 x: 1;")).toThrow(/needs a block/);
336
+ expect(() => parse("@delay 2000 { x: 1;")).toThrow(/Unclosed block/);
337
+ });
338
+
339
+ it("parses @warn / @debug / @error inside rules and blocks with any expression", () => {
340
+ const src =
341
+ 'img:not([alt]) { @warn "img needs alt"; @on load { @debug "size", event.target.naturalWidth; } }';
342
+ const rule = parse(src).body[0] as any;
343
+ const warn = rule.block.body[0];
344
+ expect(warn).toMatchObject({ type: "atrule", name: "warn" });
345
+ expect(warn.value).toMatchObject({
346
+ type: "string",
347
+ value: "img needs alt",
348
+ });
349
+ expect(src.slice(warn.start, warn.end)).toBe('@warn "img needs alt";');
350
+ const debug = rule.block.body[1].block.body[0];
351
+ expect(debug.name).toBe("debug");
352
+ expect(debug.value.type).toBe("list");
353
+ expect(debug.value.items.map((i: any) => i.type)).toEqual([
354
+ "string",
355
+ "member",
356
+ ]);
357
+ expect(first("@error $msg;").name).toBe("error");
358
+ });
359
+
360
+ it("parses @scope blocks and rejects a prelude", () => {
361
+ const r = first("@scope { a { b: c; } }");
362
+ expect(r).toMatchObject({ type: "atrule", name: "scope" });
363
+ expect(r.block.body[0].type).toBe("rule");
364
+ expect(() => parse("@scope (.card) to (.inner) { a { b: c; } }")).toThrow(
365
+ /@scope does not take a prelude.*\(1:8\)/
366
+ );
367
+ });
368
+
369
+ it("parses every Quark at-rule", () => {
370
+ const minimal: Record<QuarkAtRuleName, string> = {
371
+ use: '@use "/x.js";',
372
+ scope: "@scope { a { b: c; } }",
373
+ on: "a { @on click { b: c; } }",
374
+ dispatch: "a { @on click { @dispatch ping; } }",
375
+ command: "a { @on click { @command --refresh; } }",
376
+ "view-transition": "@view-transition { a { b: c; } }",
377
+ delay: "a { @delay 1 { b: c; } }",
378
+ warn: '@warn "x";',
379
+ debug: "@debug $x;",
380
+ error: '@error "x";',
381
+ };
382
+ for (const name of QUARK_AT_RULES) {
383
+ expect(() => parse(minimal[name])).not.toThrow();
384
+ }
385
+ });
386
+
387
+ it("rejects every at-rule that is not Quark's own", () => {
388
+ const rejected = [
389
+ // plain CSS
390
+ "media",
391
+ "supports",
392
+ "keyframes",
393
+ "font-face",
394
+ "charset",
395
+ "page",
396
+ "layer",
397
+ // SCSS
398
+ "if",
399
+ "else",
400
+ "each",
401
+ "for",
402
+ "while",
403
+ "mixin",
404
+ "include",
405
+ "content",
406
+ "function",
407
+ "return",
408
+ "forward",
409
+ "import",
410
+ "extend",
411
+ "at-root",
412
+ // unknown
413
+ "nope",
414
+ ];
415
+ for (const name of rejected) {
416
+ expect(() => parse(`@${name} x { a: b; }`)).toThrow(
417
+ new RegExp(`@${name} is not a Quark at-rule \\(1:1\\)`)
418
+ );
419
+ expect(() => parse(`a { @${name} x { b: c; } }`)).toThrow(
420
+ /is not a Quark at-rule \(1:5\)/
421
+ );
422
+ }
423
+ });
424
+
425
+ it("rejects a @use with clause", () => {
426
+ expect(() => parse('@use "x" with ($a: 1);')).toThrow(
427
+ /@use does not take a with clause \(1:10\)/
428
+ );
429
+ });
430
+ });
@@ -0,0 +1,152 @@
1
+ import { parse } from "../../index";
2
+ import type { Declaration, Rule, Stylesheet } from "../../index";
3
+ import {
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+
9
+ /** Parses `src` and returns the first declaration of the first rule. */
10
+ const firstDecl = (src: string): any => {
11
+ const sheet: Stylesheet = parse(src);
12
+ const rule = sheet.body[0] as Rule;
13
+ return rule.block.body[0] as Declaration;
14
+ };
15
+
16
+ describe("declarations", () => {
17
+ it("parses a simple declaration", () => {
18
+ const d = firstDecl("span { data-name: \"my-span\"; }");
19
+ expect(d.type).toBe("declaration");
20
+ expect(d.property.name).toBe("data-name");
21
+ expect(d.value).toMatchObject({ type: "string", value: "my-span" });
22
+ });
23
+
24
+ it("parses identifier values (none keyword stays an identifier)", () => {
25
+ const d = firstDecl("p { content: none; }");
26
+ expect(d.value).toMatchObject({ type: "identifier", name: "none" });
27
+ });
28
+
29
+ it("parses empty-string values", () => {
30
+ const d = firstDecl('input { autofocus: ""; }');
31
+ expect(d.value).toMatchObject({ type: "string", value: "" });
32
+ });
33
+
34
+ it("parses variable declarations", () => {
35
+ const d = firstDecl("main { $fooBar: \"test-string\"; }");
36
+ expect(d.property).toMatchObject({ type: "variable", name: "fooBar" });
37
+ expect(d.value.type).toBe("string");
38
+ });
39
+
40
+ it("rejects member keys on variables ($sig.value:) with guidance", () => {
41
+ expect(() => parse("li { $count.value: $count.value + 1; }")).toThrow(
42
+ /Member keys \(\$count\.…:\) are not supported.*element\.quark\.setProperty/
43
+ );
44
+ expect(() => parse('li { $state.user.name: "Ada"; }')).toThrow(/not supported/);
45
+ // a member chain inside a value is still an expression
46
+ const d = firstDecl("li { $next: $count.value + 1; }");
47
+ expect(d.property).toMatchObject({ type: "variable", name: "next" });
48
+ expect(d.value.type).toBe("binary");
49
+ });
50
+
51
+ it("does not treat a whitespace-separated dot as a member key", () => {
52
+ /* `$a .value: x` is not a member write key: the chain must be
53
+ * whitespace-free, so this fails as a declaration. */
54
+ expect(() => parse("li { $a .value: x; }")).toThrow();
55
+ });
56
+
57
+ it("parses top-level declarations (Quark extension)", () => {
58
+ const sheet = parse("data-handlers: checkUnauth, showErrorSheet;");
59
+ const d = sheet.body[0] as any;
60
+ expect(d.type).toBe("declaration");
61
+ expect(d.property.name).toBe("data-handlers");
62
+ expect(d.value.type).toBe("list");
63
+ expect(d.value.separator).toBe(",");
64
+ expect(d.value.items.map((i: any) => i.name)).toEqual([
65
+ "checkUnauth",
66
+ "showErrorSheet",
67
+ ]);
68
+ });
69
+
70
+ it("parses top-level variable declarations", () => {
71
+ const sheet = parse('$user: prop("provision");');
72
+ const d = sheet.body[0] as any;
73
+ expect(d.property).toMatchObject({ type: "variable", name: "user" });
74
+ expect(d.value.type).toBe("function");
75
+ });
76
+
77
+ it("rejects ! flags", () => {
78
+ for (const flag of ["important", "default", "global"]) {
79
+ const src = `a { $x: 1 !${flag}; }`;
80
+ expect(() => parse(src)).toThrow(
81
+ new RegExp(`!${flag} is not supported \\(1:${src.indexOf("!") + 1}\\)`)
82
+ );
83
+ }
84
+ });
85
+
86
+ it("parses custom properties", () => {
87
+ const d = firstDecl("a { --primary: #fff; }");
88
+ expect(d.property.name).toBe("--primary");
89
+ expect(d.value).toMatchObject({ type: "color", value: "#fff" });
90
+ });
91
+
92
+ it("rejects interpolated property names", () => {
93
+ const src = "a { border-#{$side}-radius: 3px; }";
94
+ expect(() => parse(src)).toThrow(
95
+ new RegExp(
96
+ `Interpolation is only supported inside strings \\(1:${src.indexOf("#{") + 1}\\)`
97
+ )
98
+ );
99
+ });
100
+
101
+ it("rejects nested property blocks, keeping `a:hover {}` a rule", () => {
102
+ for (const src of [
103
+ "a { font: bold { family: serif; } }",
104
+ "a { font: { family: serif; } }",
105
+ ]) {
106
+ expect(() => parse(src)).toThrow(
107
+ /Nested property blocks are not supported \(1:5\)/
108
+ );
109
+ }
110
+ const rule = parse("x { a:hover { b: c; } }") as any;
111
+ expect(rule.body[0].block.body[0].type).toBe("rule");
112
+ });
113
+
114
+ it("allows a missing semicolon before a closing brace", () => {
115
+ const d = firstDecl("a { content: getTitle($status) }");
116
+ expect(d.value.type).toBe("function");
117
+ });
118
+
119
+ it("tolerates stray semicolons", () => {
120
+ const sheet = parse("a { ; content: none;; } ;");
121
+ expect((sheet.body[0] as Rule).block.body).toHaveLength(1);
122
+ });
123
+
124
+ it("preserves comments as nodes", () => {
125
+ const sheet = parse(`
126
+ /* header comment */
127
+ a {
128
+ /* inner */
129
+ content: none;
130
+ }
131
+ `) as any;
132
+ expect(sheet.body[0]).toMatchObject({
133
+ type: "comment",
134
+ text: " header comment ",
135
+ });
136
+ expect(sheet.body[1].block.body[0]).toMatchObject({
137
+ type: "comment",
138
+ text: " inner ",
139
+ });
140
+ });
141
+
142
+ it("keeps colons inside string values intact", () => {
143
+ const d = firstDecl('a { style: "text-transform: capitalize"; }');
144
+ expect(d.value.value).toBe("text-transform: capitalize");
145
+ });
146
+
147
+ it("records spans covering the full declaration", () => {
148
+ const src = "a { content: none; }";
149
+ const d = firstDecl(src);
150
+ expect(src.slice(d.start, d.end)).toBe("content: none;");
151
+ });
152
+ });