@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,429 @@
1
+ import { parse } from "../../index";
2
+ import type { BaseNode, Rule, Stylesheet } from "../../index";
3
+ import {
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+
9
+ /**
10
+ * Adapted from real Quark sheets in ws, fb, and the docs-site
11
+ * (JS-style syntax like `?.`, ternaries, `??`, and `===` removed, those are
12
+ * intentionally unsupported).
13
+ */
14
+ const WS_STYLE = `
15
+ provider-geolocation {
16
+ @on provider-geolocation-success (handle: submitCoordsForm);
17
+ @on toggle-mapbox-centering (handle: toggleMapboxCentering);
18
+
19
+ $currentUserId: prop("provision").body.user_id;
20
+ #coordinate-form form {
21
+ action: "/api/v1/users/" + $currentUserId;
22
+ }
23
+ }
24
+
25
+ provider-fetch[api-url*='settings'][is-success] data-me {
26
+ $userCoords: prop("provision").coords;
27
+ $users: prop("provision").body;
28
+ $settings: prop("provision").body;
29
+ $currentUser: find($users, "id", $currentUserId);
30
+ $currentCoords: getUserCoords($userCoords, $settings.demo_mode_enabled, $settings.demo_center_lat, $settings.demo_center_lng);
31
+
32
+ dataset: $currentUser;
33
+
34
+ mapbox-view {
35
+ cam-offset: $settings.map_offset_lng + " 0";
36
+ center-zoom: $settings.map_zoom_out_max;
37
+ center-coords: $currentCoords.longitude + " " + $currentCoords.latitude;
38
+ &[data-take-bearing]:not([is-moved]) {
39
+ /* only set this attr when desired */
40
+ center-bearing: $currentOrientation.bearing;
41
+ }
42
+ &[is-loaded] {
43
+ is-animated: "";
44
+ }
45
+ }
46
+
47
+ #user-list {
48
+ content: iterate(sortUsers(withoutCurrentUser($users, $currentUserId), $currentCoords));
49
+
50
+ li {
51
+ $userStatusClass: getUserStatusClass(item.status);
52
+
53
+ mapbox-view-marker {
54
+ pin-coords: item.coordinate.lng + " " + item.coordinate.lat;
55
+ pin-class: "user-icon " + $userStatusClass;
56
+ pin-data-popover-text: item.name + " (" + item.status + ")";
57
+ }
58
+ [bind-status] {
59
+ content: item.status.toLowerCase();
60
+ style: "text-transform: capitalize";
61
+ }
62
+ [bind-distance] {
63
+ content: calcDistanceKm(item.coordinate, $currentCoords) + " km";
64
+ }
65
+ }
66
+ }
67
+
68
+ #sighting-list {
69
+ content: iterate(sortSightings($sightings, $currentCoords, $currentUser.sighting.id), "", "id");
70
+
71
+ data-sighting {
72
+ $specie: find($species, "id", item.species_id);
73
+ sighting-status: item.status;
74
+ checked: $maxUsersPerSighting == 1;
75
+
76
+ [bind-max-users] {
77
+ content: $usersInSighting.length + " / " + item.max_users_per_sighting + " 👤";
78
+ }
79
+ .animal-icon {
80
+ class: "animal-icon " + $animalIconClass;
81
+ data-text: $specie.symbol;
82
+ }
83
+ }
84
+ }
85
+
86
+ .sighting-navigator {
87
+ #update-sighting {
88
+ &[is-error] {
89
+ $errorMessage: prop("provision").body.message.coordinate.timestamp[0];
90
+ .error-message {
91
+ content: $errorMessage;
92
+ }
93
+ }
94
+ &:not([is-error]) .error-message {
95
+ content: "";
96
+ }
97
+ }
98
+ }
99
+
100
+ #tracking-list ul.w-micro-radios {
101
+ content: iterate(filterBroadcastSpecies($species));
102
+ li {
103
+ [bind-specie-name] { content: item.name_display; }
104
+ [bind-specie-id] { value: item.id; }
105
+ }
106
+ }
107
+ }
108
+
109
+ #refresh-btn {
110
+ @on click (handle: refreshData);
111
+ }
112
+
113
+ provider-fetch {
114
+ &:not([is-error]) #right-panel .error-message {
115
+ content: "";
116
+ }
117
+ &[is-error] #right-panel .error-message {
118
+ content: "Failed to fetch data. Please try again later.";
119
+ }
120
+ }
121
+
122
+ .tags-output {
123
+ $tags: prop("provision").body;
124
+ content: iterate(sortTags($tags), "#tags-template");
125
+
126
+ fieldset {
127
+ $tagIndex: index;
128
+ legend { content: index; }
129
+ div {
130
+ content: iterate(item);
131
+ span {
132
+ input {
133
+ id: "tag-" + $tagIndex + "-" + index;
134
+ value: item;
135
+ checked: includes($selectedTags[$tagIndex], item);
136
+ name: "tags." + $tagIndex;
137
+ }
138
+ label {
139
+ for: "tag-" + $tagIndex + "-" + index;
140
+ content: item;
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ @on super-form-error (handle: (checkUnauth, showErrorSheet));
148
+ @on provider-fetch-error (handle: (checkUnauth, showErrorSheet));
149
+
150
+ service-worker {
151
+ &[is-ready],
152
+ &[is-mounted]:not([is-supported]) {
153
+ & ~ #app provider-fetch[api-url*="checksession"] {
154
+ is-paused: none;
155
+ }
156
+ }
157
+ }
158
+
159
+ network-status {
160
+ $networkData: prop("provision");
161
+ $networkNode: elementOf(&);
162
+ $isOnline: $networkData.isOnline;
163
+
164
+ &[network-quality="3"] {
165
+ .network-icon { content: "signal_wifi_4_bar"; }
166
+ [bind-connection-status] { content: "Online - Great"; }
167
+ }
168
+ &[network-quality="0"] {
169
+ .network-icon { content: "signal_wifi_off"; }
170
+ [bind-connection-status] { content: "No connection"; }
171
+ }
172
+ }
173
+ `;
174
+
175
+ const FB_STYLE = `
176
+ @on super-form-error (handle: showErrorSheet);
177
+
178
+ provider-fetch[api-url="/api/sessions"] {
179
+ $sessionData: prop("provision").body.data;
180
+ object-me {
181
+ dataset: $sessionData;
182
+ }
183
+ }
184
+
185
+ details[open] include-content.details-scroll {
186
+ is-active: "";
187
+ }
188
+ details:not([open]) include-content.details-scroll {
189
+ is-active: none;
190
+ }
191
+
192
+ $routeData: prop("provision");
193
+
194
+ provider-fetch {
195
+ api-url: "/api/plans/" + $routeData.params.planId;
196
+ &[is-success] {
197
+ $plan: prop("provision").body.data;
198
+ $totalCalories: sumMacro($plan.meals, "calories");
199
+ [data-average] {
200
+ content: round($totalCalories / 7);
201
+ }
202
+ [bind-current-day] {
203
+ content: getDayOfWeek();
204
+ }
205
+ ul.today-meal-container {
206
+ $dayMeals: filterMealsByDay($plan.meals);
207
+ content: iterate($dayMeals);
208
+ > li {
209
+ spa-a[bind-meal-href] {
210
+ data-current-meal: index == $currentMealIndex;
211
+ route-href: "/plans/" + $plan.id + "/meals/" + item.id;
212
+ }
213
+ [bind-meal-calories] {
214
+ content: sumMacro(item, "calories") + " calories";
215
+ }
216
+ }
217
+ }
218
+ /* pick the system from the plan, rather than the session */
219
+ input[name="system"] {
220
+ value: $plan.system;
221
+ }
222
+ [bind-vendors] {
223
+ content: iterate(getVendors());
224
+ super-form {
225
+ data-vendor: item;
226
+ @on super-form-success (handle: followRedirect);
227
+ img {
228
+ class: "ignore-color-scheme shop-" + item;
229
+ src: "/img/" + item + ".png";
230
+ alt: item;
231
+ }
232
+ }
233
+ }
234
+ [bind-plan-dietary-restrictions] {
235
+ content: fieldToHuman($plan.dietaryRestrictions.join(", "));
236
+ }
237
+ }
238
+ [bind-day-list] {
239
+ content: iterate(getAllDayIndexes());
240
+ [bind-day] { content: getDayOfWeek(item); }
241
+ }
242
+ }
243
+ `;
244
+
245
+ const DOCS_SITE_STYLE = `
246
+ $packageName: prop("provision").params.packageName;
247
+ provider-fetch.doc-page {
248
+ api-url: "/package-metas/" + $packageName + ".json";
249
+ }
250
+ provider-fetch.doc-page[is-success] {
251
+ $packageMeta: prop("provision").body;
252
+ $install: $packageMeta.installation;
253
+ data-package-type: $packageMeta.package.excom.packageType;
254
+ #install-section {
255
+ [bind-cdn] { content: dangerous-html(renderLangCopy($install.cdn, "html")); }
256
+ [bind-peers-length] { content: " (" + $install.peerDependencies.length + ")"; }
257
+ [bind-export-files] {
258
+ content: iterate($packageMeta.exportedFiles);
259
+ [bind-export-file-key] { content: index; }
260
+ [bind-export-file-value] { content: item; }
261
+ }
262
+ }
263
+ #api-reference {
264
+ content: iterate($packageMeta.elementApis);
265
+ [bind-tag] { content: item.tag; }
266
+ /* details 1 */
267
+ [bind-attributes] tbody {
268
+ content: iterate(item.attributes);
269
+ [bind-name] { content: item.name; }
270
+ [bind-default] { content: unescapeHtml(pickDefault(item)); }
271
+ }
272
+ }
273
+ [data-demo] {
274
+ $demoRef: attr("data-demo");
275
+ template-ref: "/views/live-demo.html";
276
+ lazy-load: "";
277
+ .live-demo {
278
+ $src: formatCode(getDemoSource($packageMeta, $demoRef));
279
+ [aria-label="preview"] {
280
+ /* immediate child selector is crucial here */
281
+ > template { content: dangerous-html($src); }
282
+ > include-content {
283
+ is-active: "";
284
+ id: getDemoId($packageName, $demoRef);
285
+ }
286
+ }
287
+ [aria-label="reset"] {
288
+ @on click (handle: resetDemo($src));
289
+ }
290
+ footer {
291
+ content-tabs-header[is-open] button {
292
+ class: "outline tag-small";
293
+ }
294
+ .edit-code textarea {
295
+ content: $src;
296
+ @on input (handle: (updateTemplate, renderPre));
297
+ }
298
+ }
299
+ }
300
+ }
301
+ }
302
+ [bind-copy-button] {
303
+ content: template("#copy-source-button");
304
+ }
305
+ `;
306
+
307
+ /** Walks every node, asserting spans are sane. Returns node-type counts. */
308
+ const walk = (node: any, source: string, counts: Record<string, number>) => {
309
+ if (Array.isArray(node)) {
310
+ for (const item of node) walk(item, source, counts);
311
+ return;
312
+ }
313
+ if (!node || typeof node !== "object") return;
314
+ if (typeof node.type === "string" && typeof node.start === "number") {
315
+ const n = node as BaseNode;
316
+ expect(n.start).toBeGreaterThanOrEqual(0);
317
+ expect(n.end).toBeGreaterThanOrEqual(n.start);
318
+ expect(n.end).toBeLessThanOrEqual(source.length);
319
+ counts[n.type] = (counts[n.type] ?? 0) + 1;
320
+ }
321
+ for (const key of Object.keys(node)) {
322
+ if (key === "start" || key === "end") continue;
323
+ walk(node[key], source, counts);
324
+ }
325
+ };
326
+
327
+ describe("real-world sheets", () => {
328
+ it("parses the ws main sheet", () => {
329
+ const sheet = parse(WS_STYLE);
330
+ const counts: Record<string, number> = {};
331
+ walk(sheet, WS_STYLE, counts);
332
+ expect(counts.rule).toBeGreaterThan(20);
333
+ expect(counts.declaration).toBeGreaterThan(30);
334
+ expect(counts.member).toBeGreaterThan(20);
335
+ expect(counts.function).toBeGreaterThan(10);
336
+ expect(counts.parent_reference).toBeGreaterThan(0);
337
+ expect(counts.index).toBeGreaterThan(0);
338
+ expect(counts.comment).toBeGreaterThan(0);
339
+ });
340
+
341
+ it("resolves the deep &[is-error] error-message chain", () => {
342
+ const sheet = parse(WS_STYLE) as any;
343
+ const dataMe = sheet.body.find(
344
+ (s: any) =>
345
+ s.type === "rule" &&
346
+ s.selector.selectors[0].parts.some(
347
+ (p: any) => p.type === "type_selector" && p.name === "data-me",
348
+ ),
349
+ );
350
+ expect(dataMe).toBeTruthy();
351
+ const navigator = dataMe.block.body.find(
352
+ (s: any) =>
353
+ s.type === "rule" &&
354
+ s.selector.selectors[0].parts[0].type === "class_selector" &&
355
+ s.selector.selectors[0].parts[0].name === "sighting-navigator",
356
+ );
357
+ const updateSighting = navigator.block.body[0];
358
+ const isError = updateSighting.block.body[0];
359
+ expect(isError.selector.selectors[0].parts[0].type).toBe(
360
+ "parent_selector",
361
+ );
362
+ const errVar = isError.block.body[0];
363
+ expect(errVar.property).toMatchObject({
364
+ type: "variable",
365
+ name: "errorMessage",
366
+ });
367
+ // prop("provision").body.message.coordinate.timestamp[0]
368
+ expect(errVar.value.type).toBe("index");
369
+ expect(errVar.value.object).toMatchObject({
370
+ type: "member",
371
+ property: "timestamp",
372
+ });
373
+ });
374
+
375
+ it("parses the fb plan sheet", () => {
376
+ const sheet = parse(FB_STYLE) as any;
377
+ const counts: Record<string, number> = {};
378
+ walk(sheet, FB_STYLE, counts);
379
+ expect(counts.rule).toBeGreaterThan(10);
380
+ // top-level listener at-rule
381
+ expect(sheet.body[0]).toMatchObject({
382
+ type: "atrule",
383
+ name: "on",
384
+ events: [{ name: "super-form-error", quoted: false }],
385
+ });
386
+ });
387
+
388
+ it("parses the docs-site package sheet", () => {
389
+ const sheet = parse(DOCS_SITE_STYLE) as any;
390
+ expect(sheet.body[0].property).toMatchObject({
391
+ type: "variable",
392
+ name: "packageName",
393
+ });
394
+ const counts: Record<string, number> = {};
395
+ walk(sheet, DOCS_SITE_STYLE, counts);
396
+ expect(counts.rule).toBeGreaterThan(15);
397
+ expect(counts.comment).toBe(2);
398
+ });
399
+
400
+ it("round-trip spans reproduce the source for every rule selector", () => {
401
+ const sheet = parse(WS_STYLE) as Stylesheet;
402
+ const checkRules = (body: any[]) => {
403
+ for (const stmt of body) {
404
+ if (stmt.type !== "rule") continue;
405
+ const rule = stmt as Rule;
406
+ const sliced = WS_STYLE.slice(
407
+ rule.selector.start,
408
+ rule.selector.end,
409
+ );
410
+ // The span should cover the selector text exactly (modulo whitespace).
411
+ expect(sliced.trim().length).toBeGreaterThan(0);
412
+ expect(rule.block.start).toBeGreaterThanOrEqual(rule.selector.end);
413
+ checkRules(rule.block.body);
414
+ }
415
+ };
416
+ checkRules(sheet.body);
417
+ });
418
+
419
+ it("parses quickly enough for render-blocking use", () => {
420
+ const big = Array.from({ length: 50 }, () => WS_STYLE).join("\n");
421
+ // Warm up.
422
+ parse(big);
423
+ const started = performance.now();
424
+ parse(big);
425
+ const elapsed = performance.now() - started;
426
+ // ~250KB of Quark; generous bound to avoid CI flake (typically < 30ms).
427
+ expect(elapsed).toBeLessThan(500);
428
+ });
429
+ });
@@ -0,0 +1,169 @@
1
+ import { parse, parseSelectorList, QuarkParseError } from "../../index";
2
+ import type { Rule, Stylesheet } from "../../index";
3
+ import {
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+
9
+ /** Returns the parts of the first selector of the first rule. */
10
+ const sel = (selector: string): any[] => {
11
+ const sheet: Stylesheet = parse(`${selector} { x: y; }`);
12
+ return (sheet.body[0] as Rule).selector.selectors[0].parts as any[];
13
+ };
14
+
15
+ describe("selectors", () => {
16
+ it("parses compound selectors with attributes", () => {
17
+ const parts = sel("provider-fetch[api-url*='settings'][is-success]");
18
+ expect(parts.map((p) => p.type)).toEqual([
19
+ "type_selector",
20
+ "attribute_selector",
21
+ "attribute_selector",
22
+ ]);
23
+ expect(parts[0].name).toBe("provider-fetch");
24
+ expect(parts[1]).toMatchObject({
25
+ name: "api-url",
26
+ operator: "*=",
27
+ });
28
+ expect(parts[1].value.value).toBe("settings");
29
+ expect(parts[2]).toMatchObject({ name: "is-success", operator: null });
30
+ });
31
+
32
+ it("parses descendant combinators from whitespace", () => {
33
+ const parts = sel("dialog[open] input");
34
+ expect(parts.map((p) => p.type)).toEqual([
35
+ "type_selector",
36
+ "attribute_selector",
37
+ "combinator",
38
+ "type_selector",
39
+ ]);
40
+ expect(parts[2].value).toBe(" ");
41
+ });
42
+
43
+ it("parses explicit combinators", () => {
44
+ expect(sel("ul > li").map((p: any) => p.value ?? p.name)).toEqual([
45
+ "ul",
46
+ ">",
47
+ "li",
48
+ ]);
49
+ expect(sel("a ~ b")[1].value).toBe("~");
50
+ expect(sel("a + b")[1].value).toBe("+");
51
+ expect(sel("> li")[0]).toMatchObject({ type: "combinator", value: ">" });
52
+ });
53
+
54
+ it("parses all attribute operators and modifiers", () => {
55
+ expect(sel('[a="v"]')[0].operator).toBe("=");
56
+ expect(sel('[a*="v"]')[0].operator).toBe("*=");
57
+ expect(sel('[a^="v"]')[0].operator).toBe("^=");
58
+ expect(sel('[a$="v"]')[0].operator).toBe("$=");
59
+ expect(sel('[a|="v"]')[0].operator).toBe("|=");
60
+ expect(sel('[a~="v"]')[0].operator).toBe("~=");
61
+ expect(sel('[a="v" i]')[0].modifier).toBe("i");
62
+ expect(sel("[a=v]")[0].value).toMatchObject({
63
+ type: "identifier",
64
+ name: "v",
65
+ });
66
+ });
67
+
68
+ it("parses :not() with a nested selector list", () => {
69
+ const parts = sel("details:not([open])");
70
+ const pseudo = parts[1];
71
+ expect(pseudo.type).toBe("pseudo_class_selector");
72
+ expect(pseudo.name).toBe("not");
73
+ expect(pseudo.argument.type).toBe("selector_list");
74
+ expect(pseudo.argument.selectors[0].parts[0].type).toBe(
75
+ "attribute_selector",
76
+ );
77
+ });
78
+
79
+ it("parses non-selector pseudo arguments as raw", () => {
80
+ const parts = sel("li:nth-child(2n+1)");
81
+ expect(parts[1].argument).toMatchObject({ type: "raw", value: "2n+1" });
82
+ });
83
+
84
+ it("parses pseudo-elements", () => {
85
+ const parts = sel("a::before");
86
+ expect(parts[1]).toMatchObject({
87
+ type: "pseudo_element_selector",
88
+ name: "before",
89
+ });
90
+ });
91
+
92
+ it("parses classes, ids, and universal", () => {
93
+ expect(sel(".user-icon-small")[0]).toMatchObject({
94
+ type: "class_selector",
95
+ name: "user-icon-small",
96
+ });
97
+ expect(sel("#refresh-btn")[0]).toMatchObject({
98
+ type: "id_selector",
99
+ name: "refresh-btn",
100
+ });
101
+ expect(sel("*")[0]).toMatchObject({ type: "type_selector", name: "*" });
102
+ });
103
+
104
+ it("parses parent selectors with and without suffixes", () => {
105
+ expect(sel("&[is-loaded]").map((p: any) => p.type)).toEqual([
106
+ "parent_selector",
107
+ "attribute_selector",
108
+ ]);
109
+ expect(sel("&-modifier")[0]).toMatchObject({
110
+ type: "parent_selector",
111
+ suffix: "-modifier",
112
+ });
113
+ });
114
+
115
+ it("parses selector lists", () => {
116
+ const sheet = parse("a, b[open], .c { x: y; }") as any;
117
+ const list = sheet.body[0].selector;
118
+ expect(list.selectors).toHaveLength(3);
119
+ expect(list.selectors[1].parts[0].name).toBe("b");
120
+ });
121
+
122
+ it("parses nested rules and resolves statements correctly", () => {
123
+ const sheet = parse(`
124
+ main {
125
+ $msg: "nested-ok";
126
+ section {
127
+ [bind-msg] { content: $msg; }
128
+ }
129
+ }
130
+ `) as any;
131
+ const main = sheet.body[0];
132
+ expect(main.type).toBe("rule");
133
+ expect(main.block.body[0].type).toBe("declaration");
134
+ const section = main.block.body[1];
135
+ expect(section.type).toBe("rule");
136
+ expect(section.block.body[0].selector.selectors[0].parts[0].type).toBe(
137
+ "attribute_selector",
138
+ );
139
+ });
140
+
141
+ it("parses multi-line grouped selectors with parent references", () => {
142
+ const sheet = parse(`
143
+ service-worker {
144
+ &[is-ready],
145
+ &[is-mounted]:not([is-supported]) {
146
+ x: y;
147
+ }
148
+ }
149
+ `) as any;
150
+ const inner = sheet.body[0].block.body[0];
151
+ expect(inner.selector.selectors).toHaveLength(2);
152
+ expect(inner.selector.selectors[1].parts.map((p: any) => p.type)).toEqual([
153
+ "parent_selector",
154
+ "attribute_selector",
155
+ "pseudo_class_selector",
156
+ ]);
157
+ });
158
+
159
+ it("parses standalone selector lists via parseSelectorList", () => {
160
+ const src = `div:not(.red) span, [bind-title]`;
161
+ const list = parseSelectorList(src);
162
+ expect(list.selectors).toHaveLength(2);
163
+ expect(src.slice(list.selectors[0].start, list.selectors[0].end)).toBe(
164
+ "div:not(.red) span",
165
+ );
166
+ expect(list.selectors[1].parts[0].type).toBe("attribute_selector");
167
+ expect(() => parseSelectorList("div { x: y; }")).toThrow(QuarkParseError);
168
+ });
169
+ });