@excom/quark 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 (130) hide show
  1. package/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/quark.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 +6 -0
  11. package/index.ts +4 -0
  12. package/language.ts +7 -0
  13. package/package.json +45 -0
  14. package/rush-logs/quark.apply-exports.cache.log +1 -0
  15. package/rush-logs/quark.apply-exports.log +1 -0
  16. package/rush-logs/quark.build_package-metas.cache.log +1 -0
  17. package/rush-logs/quark.build_package-metas.log +1 -0
  18. package/src/actions.ts +445 -0
  19. package/src/ast.ts +187 -0
  20. package/src/bindings.ts +115 -0
  21. package/src/builtin-modules.ts +401 -0
  22. package/src/constants.ts +47 -0
  23. package/src/cycle-check.ts +121 -0
  24. package/src/devtools-hook.ts +303 -0
  25. package/src/element-api.ts +109 -0
  26. package/src/evaluator.ts +317 -0
  27. package/src/form-controls.ts +69 -0
  28. package/src/language-tables.ts +137 -0
  29. package/src/language.ts +1225 -0
  30. package/src/observer.ts +110 -0
  31. package/src/paint.ts +305 -0
  32. package/src/properties.ts +818 -0
  33. package/src/props.ts +94 -0
  34. package/src/quark-element.ts +7 -0
  35. package/src/quark-internal.ts +365 -0
  36. package/src/quark.ts +817 -0
  37. package/src/resolvers.ts +867 -0
  38. package/src/rule.ts +800 -0
  39. package/src/scope-id.ts +47 -0
  40. package/src/selector-utils.ts +430 -0
  41. package/src/settle.ts +117 -0
  42. package/src/types.ts +155 -0
  43. package/src/utils.ts +79 -0
  44. package/src/variables.ts +359 -0
  45. package/support/demos/css-variables.html +18 -0
  46. package/support/demos/dispatch.html +20 -0
  47. package/support/demos/events.html +9 -0
  48. package/support/demos/iterate.html +13 -0
  49. package/support/demos/js-api.html +17 -0
  50. package/support/demos/provider-list.html +16 -0
  51. package/support/demos/toggle-content.html +14 -0
  52. package/support/demos/unset.html +12 -0
  53. package/support/demos/view-transition.html +36 -0
  54. package/support/docs/ATTRIBUTES.md +37 -0
  55. package/support/docs/AT_RULES.md +45 -0
  56. package/support/docs/BUILTINS.md +57 -0
  57. package/support/docs/CONTENT.md +40 -0
  58. package/support/docs/CSS_VARIABLES.md +16 -0
  59. package/support/docs/DECLARATIONS.md +26 -0
  60. package/support/docs/DELAY.md +37 -0
  61. package/support/docs/DIAGNOSTICS.md +32 -0
  62. package/support/docs/DISPATCH.md +81 -0
  63. package/support/docs/ELEMENT_PROPERTIES.md +34 -0
  64. package/support/docs/EXPRESSIONS.md +31 -0
  65. package/support/docs/INTERNAL.md +8 -0
  66. package/support/docs/JS_API.md +53 -0
  67. package/support/docs/JS_WRITES.md +36 -0
  68. package/support/docs/LIMITATIONS.md +26 -0
  69. package/support/docs/LOOP_GUARD.md +30 -0
  70. package/support/docs/METHODS.md +43 -0
  71. package/support/docs/MODULES.md +111 -0
  72. package/support/docs/NO_REVERSION.md +18 -0
  73. package/support/docs/ON.md +87 -0
  74. package/support/docs/REACTIVITY.md +24 -0
  75. package/support/docs/README.md +143 -0
  76. package/support/docs/SELECTORS.md +87 -0
  77. package/support/docs/SHEETS.md +67 -0
  78. package/support/docs/SYNTAX.md +36 -0
  79. package/support/docs/USE.md +24 -0
  80. package/support/docs/VALUES.md +27 -0
  81. package/support/docs/VARIABLES.md +47 -0
  82. package/support/docs/VIEW_TRANSITION.md +64 -0
  83. package/support/docs-sections.json +65 -0
  84. package/support/package-meta.json +208 -0
  85. package/support/scripts/build-language-docs.mjs +196 -0
  86. package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
  87. package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
  88. package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
  89. package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
  90. package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
  91. package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
  92. package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
  93. package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
  94. package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
  95. package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
  96. package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
  97. package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
  98. package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
  99. package/support/tests/actions.test.ts +437 -0
  100. package/support/tests/builtin-modules.test.ts +337 -0
  101. package/support/tests/builtins.test.ts +189 -0
  102. package/support/tests/css-variables.view.test.ts +35 -0
  103. package/support/tests/delay.test.ts +244 -0
  104. package/support/tests/devtools-hook.test.ts +496 -0
  105. package/support/tests/diagnostics.test.ts +224 -0
  106. package/support/tests/dispatch.view.test.ts +43 -0
  107. package/support/tests/element-api.test.ts +202 -0
  108. package/support/tests/evaluator.test.ts +237 -0
  109. package/support/tests/events.view.test.ts +40 -0
  110. package/support/tests/form-controls.test.ts +452 -0
  111. package/support/tests/helpers.ts +354 -0
  112. package/support/tests/internals.test.ts +568 -0
  113. package/support/tests/iterate.view.test.ts +40 -0
  114. package/support/tests/js-api.view.test.ts +45 -0
  115. package/support/tests/language-docs.test.ts +147 -0
  116. package/support/tests/lifecycle.test.ts +440 -0
  117. package/support/tests/loop-guard.test.ts +323 -0
  118. package/support/tests/provider-list.view.test.ts +47 -0
  119. package/support/tests/quark-features.test.ts +2887 -0
  120. package/support/tests/quark-with-providers.test.ts +169 -0
  121. package/support/tests/quark.test.ts +359 -0
  122. package/support/tests/resolvers.test.ts +346 -0
  123. package/support/tests/selector-analysis.test.ts +281 -0
  124. package/support/tests/selectors.test.ts +670 -0
  125. package/support/tests/toggle-content.view.test.ts +40 -0
  126. package/support/tests/unset.view.test.ts +42 -0
  127. package/support/tests/view-helpers.ts +81 -0
  128. package/support/tests/view-transition.test.ts +1069 -0
  129. package/support/tests/view-transition.view.test.ts +76 -0
  130. package/tsconfig.json +5 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Host scope markers. Each registered host gets `q-scope="<id>"`, and
3
+ * scoped rules prefix selectors with `[q-scope="<id>"] ` so the native
4
+ * engine enforces CSS `@scope`: every compound matches strict
5
+ * descendants; the host itself only via explicit `:scope`. No JS
6
+ * ancestor walks (`q-` attrs are observer-blacklisted, so the marker
7
+ * never triggers a run).
8
+ *
9
+ * Ids are minted per host and refcounted across sheets; the attr comes
10
+ * off when the last sheet unregisters. This registry is the source of
11
+ * truth. A marker copied by cloneNode/innerHTML is overwritten on the
12
+ * next register, so two live hosts never share an id. Stale copies on
13
+ * unregistered trees stay inert: rules only look inside their own host
14
+ * (`host.contains()` + query-root clamp).
15
+ */
16
+ export const SCOPE_ATTR = "q-scope";
17
+
18
+ const hostState = new WeakMap<Element, { id: string; count: number }>();
19
+ let counter = 0;
20
+
21
+ export const acquireScopeId = (host: Element): string => {
22
+ const known = hostState.get(host);
23
+ if (known) {
24
+ known.count++;
25
+ // reassert in case app code stripped or overwrote the attribute
26
+ if (host.getAttribute(SCOPE_ATTR) !== known.id) {
27
+ host.setAttribute(SCOPE_ATTR, known.id);
28
+ }
29
+ return known.id;
30
+ }
31
+ const id = (++counter).toString(36);
32
+ hostState.set(host, { id, count: 1 });
33
+ host.setAttribute(SCOPE_ATTR, id);
34
+ return id;
35
+ };
36
+
37
+ export const releaseScopeId = (host: Element): void => {
38
+ const known = hostState.get(host);
39
+ if (!known) return;
40
+ known.count--;
41
+ if (known.count <= 0) {
42
+ hostState.delete(host);
43
+ if (host.getAttribute(SCOPE_ATTR) === known.id) {
44
+ host.removeAttribute(SCOPE_ATTR);
45
+ }
46
+ }
47
+ };
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Selector string helpers for `Rule`. Uses the `@excom/quark-parser`
3
+ * AST internally (lexing is in quark.ts) so quotes, pseudo args, and
4
+ * interpolations stay structural. In/out are still strings.
5
+ */
6
+ import { ATTRIBUTE_BLACKLIST_REGEXES } from "./constants";
7
+ import { PSEUDO_CLASS_SUPPORT } from "./language-tables";
8
+ import { tc } from "@excom/kit-utils";
9
+ import type {
10
+ PseudoClassSelector,
11
+ Selector,
12
+ SelectorList,
13
+ SelectorPart,
14
+ Span,
15
+ } from "@excom/quark-parser";
16
+ import { parseSelectorList } from "@excom/quark-parser";
17
+
18
+ /** Spans of every `&` (parent selector), including inside `:not(...)` etc. */
19
+ function collectParentRefSpans(list: SelectorList, spans: Span[] = []): Span[] {
20
+ for (const selector of list.selectors) {
21
+ for (const part of selector.parts) {
22
+ if (part.type === "parent_selector") {
23
+ spans.push(part);
24
+ } else if (
25
+ part.type === "pseudo_class_selector" &&
26
+ part.argument?.type === "selector_list"
27
+ ) {
28
+ collectParentRefSpans(part.argument, spans);
29
+ }
30
+ }
31
+ }
32
+ return spans;
33
+ }
34
+
35
+ /**
36
+ * Expand one nested selector against its parent: replace each `&`, or
37
+ * prepend the parent as a descendant if there is none. `&` in attribute
38
+ * strings is left alone.
39
+ */
40
+ export function buildSelectorLine(
41
+ selectorLine: string,
42
+ parentSelector: string = ""
43
+ ) {
44
+ const selector = selectorLine.trim();
45
+ if (!parentSelector || !selector) return selectorLine;
46
+ const spans = collectParentRefSpans(parseSelectorList(selector));
47
+ if (!spans.length) return `${parentSelector} ${selector}`;
48
+ // Splice the parent over each `&` char; suffix text (`&-mod`) is preserved.
49
+ let out = "";
50
+ let prev = 0;
51
+ for (const span of spans) {
52
+ out += selector.slice(prev, span.start) + parentSelector;
53
+ prev = span.start + 1;
54
+ }
55
+ out += selector.slice(prev);
56
+ return out.trim();
57
+ }
58
+
59
+ /**
60
+ * Split a `:scope`-anchored selector into the host compound and the rest.
61
+ * `:scope` is the sheet host (`@scope` root); `matches()` cannot resolve
62
+ * it, so `Rule` matches the compound on the host and the rest on
63
+ * candidates.
64
+ *
65
+ * - `":scope"` → { hostCompound: "", rest: "" }
66
+ * - `":scope[is-on]"` → { hostCompound: "[is-on]", rest: "" }
67
+ * - `":scope main span"` → { hostCompound: "", rest: "main span" }
68
+ * - `"main span"` → { hostCompound: null, rest: "main span" }
69
+ */
70
+ export function splitScopePrefix(selector: string): {
71
+ hostCompound: string | null;
72
+ rest: string;
73
+ } {
74
+ const trimmed = selector.trim();
75
+ if (!trimmed.includes(":scope")) return { hostCompound: null, rest: trimmed };
76
+ const parts = parseSelectorList(trimmed).selectors[0].parts;
77
+ let scopeSpan: Span | null = null;
78
+ let compoundEnd = trimmed.length;
79
+ for (const part of parts) {
80
+ if (part.type === "combinator") {
81
+ compoundEnd = part.start;
82
+ break;
83
+ }
84
+ if (part.type === "pseudo_class_selector" && part.name === "scope") {
85
+ scopeSpan = part;
86
+ }
87
+ }
88
+ // `:scope` beyond the first compound is not supported, treat as plain text
89
+ if (!scopeSpan) return { hostCompound: null, rest: trimmed };
90
+ const hostCompound = (
91
+ trimmed.slice(0, scopeSpan.start) +
92
+ trimmed.slice(scopeSpan.end, compoundEnd)
93
+ ).trim();
94
+ return { hostCompound, rest: trimmed.slice(compoundEnd).trim() };
95
+ }
96
+
97
+ /**
98
+ * Where subjects sit relative to the element a dependency was found on:
99
+ * its subtree (`self`) or its parent's (`parent`, sibling combinator).
100
+ */
101
+ export type FanOutRoot = "self" | "parent";
102
+
103
+ /**
104
+ * One selector compound and what can change its match. Attrs are
105
+ * bucketed by *where the change happens* vs the compound's element,
106
+ * which decides where to look for subjects next:
107
+ *
108
+ * - `selfAttrs`: on the compound itself (`[x]`, `:not([x])`,
109
+ * `:is([x], [y])`, attribute-backed pseudos). Re-run it or fan out
110
+ * below.
111
+ * - `looseAttrs`: nearby. Earlier compound of a complex `:is()` /
112
+ * `:not()` (`:is(section[x] li)`), `S` in `:nth-child(… of S)`, or
113
+ * an ancestor a pseudo inherits from (`fieldset[disabled]` for
114
+ * `:disabled`). Fan out from the changed element (or its parent).
115
+ * - `hasAttrs`: on a descendant named in `:has()`. Walk up.
116
+ * - `broadAttrs`: anywhere in the host. Sibling `:has(+ …)` /
117
+ * `:has(~ …)`, earlier compounds of a complex `:has()`, or `:has()`
118
+ * nested in a complex logical arg. Fan out from the host.
119
+ */
120
+ export interface CompoundDependency {
121
+ /** Index of the compound in `path` (combinators are separate entries). */
122
+ pathIndex: number;
123
+ /**
124
+ * `path[0..pathIndex]` joined: what an element must match to be this
125
+ * compound. For the subject, the whole selector.
126
+ */
127
+ prefix: string;
128
+ /**
129
+ * Prefix starts with a combinator (`> span` after `:scope`); only
130
+ * valid once the host marker is prepended. Other prefixes match
131
+ * unscoped (cheaper); fan-out is clamped to the host anyway.
132
+ */
133
+ prefixNeedsScope: boolean;
134
+ isSubject: boolean;
135
+ /** Fan-out root for changes on the compound's element (or a `:has()` ancestor). */
136
+ root: FanOutRoot;
137
+ /** Fan-out root for `looseAttrs` changes. */
138
+ looseRoot: FanOutRoot;
139
+ selfAttrs: Set<string>;
140
+ looseAttrs: Set<string>;
141
+ hasAttrs: Set<string>;
142
+ broadAttrs: Set<string>;
143
+ /** `:has()` / `:empty`: insertions and removals below the element re-check it. */
144
+ reactsToChildren: boolean;
145
+ /** Child changes anywhere in the host re-run the rule from the host. */
146
+ broad: boolean;
147
+ /** Sibling position matters (structural pseudo-class or `of S`). */
148
+ positional: boolean;
149
+ /** An argument uses a sibling combinator. */
150
+ siblingInArgs: boolean;
151
+ /**
152
+ * Class tokens named by `.x` (arguments included), for the observer's
153
+ * `class` filter. `null` when the whole value matters (`[class~="x"]`,
154
+ * an escaped name).
155
+ */
156
+ classNames: Set<string> | null;
157
+ }
158
+
159
+ export interface SelectorAnalysis {
160
+ /** Compounds and combinators in order: `["div:not([x])", ">", "span"]`. */
161
+ path: string[];
162
+ /** Observable attrs the selector depends on (observer input). */
163
+ attrs: Set<string>;
164
+ compounds: CompoundDependency[];
165
+ /** A compound reacts to child insert/remove (`:has()`, `:empty`). */
166
+ usesHas: boolean;
167
+ /** Sibling position matters (structural pseudo, `+` / `~`). */
168
+ positional: boolean;
169
+ /** The sheet must observe child removals for this selector. */
170
+ reactsToRemovals: boolean;
171
+ /** Pseudos the observer cannot follow (`:hover`, `::before`). */
172
+ unobserved: string[];
173
+ /** Union of the compounds' `classNames`; `null` if any is `null`. */
174
+ classNames: Set<string> | null;
175
+ }
176
+
177
+ type Bucket = "self" | "loose" | "has" | "broad";
178
+
179
+ const BUCKET_FIELD: Record<Bucket, keyof CompoundDependency> = {
180
+ self: "selfAttrs",
181
+ loose: "looseAttrs",
182
+ has: "hasAttrs",
183
+ broad: "broadAttrs",
184
+ };
185
+
186
+ /** Bucket for a compound *before* the last one of an argument arm. */
187
+ const earlierBucket = (bucket: Bucket): Bucket =>
188
+ bucket === "self" || bucket === "loose" ? "loose" : "broad";
189
+
190
+ const newCompound = (
191
+ pathIndex: number,
192
+ prefix: string,
193
+ isSubject: boolean
194
+ ): CompoundDependency => ({
195
+ pathIndex,
196
+ prefix,
197
+ prefixNeedsScope: /^[>+~]/.test(prefix),
198
+ isSubject,
199
+ root: "self",
200
+ looseRoot: "self",
201
+ selfAttrs: new Set(),
202
+ looseAttrs: new Set(),
203
+ hasAttrs: new Set(),
204
+ broadAttrs: new Set(),
205
+ reactsToChildren: false,
206
+ broad: false,
207
+ positional: false,
208
+ siblingInArgs: false,
209
+ classNames: new Set(),
210
+ });
211
+
212
+ const isSiblingCombinator = (part: SelectorPart | undefined) =>
213
+ part?.type === "combinator" && (part.value === "+" || part.value === "~");
214
+
215
+ const addAttr = (c: CompoundDependency, bucket: Bucket, name: string) => {
216
+ if (!name || ATTRIBUTE_BLACKLIST_REGEXES.some((re) => re.test(name))) return;
217
+ (c[BUCKET_FIELD[bucket]] as Set<string>).add(name);
218
+ };
219
+
220
+ /** `.x` gates on `class`; the token feeds the observer's class filter. */
221
+ const addClassName = (c: CompoundDependency, bucket: Bucket, name: string) => {
222
+ addAttr(c, bucket, "class");
223
+ if (!c.classNames) return;
224
+ // escaped (`.sm\:hidden`): any class change may matter
225
+ if (name.includes("\\")) c.classNames = null;
226
+ else c.classNames.add(name);
227
+ };
228
+
229
+ /** Split an argument arm into its compounds; note sibling combinators. */
230
+ const visitArm = (
231
+ arm: Selector,
232
+ c: CompoundDependency,
233
+ bucket: Bucket,
234
+ unobserved: string[]
235
+ ) => {
236
+ const groups: SelectorPart[][] = [];
237
+ let current: SelectorPart[] = [];
238
+ for (const part of arm.parts) {
239
+ if (part.type === "combinator") {
240
+ if (isSiblingCombinator(part)) c.siblingInArgs = true;
241
+ if (current.length) groups.push(current);
242
+ current = [];
243
+ } else {
244
+ current.push(part);
245
+ }
246
+ }
247
+ if (current.length) groups.push(current);
248
+ groups.forEach((parts, i) => {
249
+ visitCompound(
250
+ parts,
251
+ c,
252
+ i === groups.length - 1 ? bucket : earlierBucket(bucket),
253
+ unobserved
254
+ );
255
+ });
256
+ };
257
+
258
+ const visitPseudo = (
259
+ part: PseudoClassSelector,
260
+ c: CompoundDependency,
261
+ bucket: Bucket,
262
+ unobserved: string[]
263
+ ) => {
264
+ const doc = PSEUDO_CLASS_SUPPORT[part.name];
265
+ const arms =
266
+ part.argument?.type === "selector_list" ? part.argument.selectors : [];
267
+ switch (doc?.kind) {
268
+ case "logical":
269
+ arms.forEach((arm) => visitArm(arm, c, bucket, unobserved));
270
+ break;
271
+ case "relational":
272
+ c.reactsToChildren = true;
273
+ arms.forEach((arm) => {
274
+ // `:has(+ x)` looks sideways, and a `:has()` nested in another
275
+ // argument has no prefix to walk up with: both fan out from the host
276
+ const armBucket: Bucket =
277
+ isSiblingCombinator(arm.parts[0]) || bucket !== "self"
278
+ ? "broad"
279
+ : "has";
280
+ if (armBucket === "broad") c.broad = true;
281
+ visitArm(arm, c, armBucket, unobserved);
282
+ });
283
+ break;
284
+ case "structural": {
285
+ c.positional = true;
286
+ if (part.name === "empty") c.reactsToChildren = true;
287
+ // `:nth-child(2n+1 of S)`, the attributes of `S` sit on siblings
288
+ const raw = part.argument?.type === "raw" ? part.argument.value : "";
289
+ const ofAt = raw.search(/\bof\b/);
290
+ if (ofAt >= 0) {
291
+ const list = tc(() => parseSelectorList(raw.slice(ofAt + 2).trim())) as
292
+ | SelectorList
293
+ | undefined;
294
+ list?.selectors.forEach((arm) => {
295
+ c.siblingInArgs = true;
296
+ visitArm(arm, c, earlierBucket(bucket), unobserved);
297
+ });
298
+ }
299
+ break;
300
+ }
301
+ case "attribute":
302
+ doc.attributes?.forEach((name) => {
303
+ addAttr(c, bucket, name);
304
+ // inherited state (`fieldset[disabled]`, `lang`) changes above
305
+ if (bucket === "self") addAttr(c, "loose", name);
306
+ });
307
+ break;
308
+ case "static":
309
+ break;
310
+ default:
311
+ unobserved.push(`:${part.name}`);
312
+ }
313
+ };
314
+
315
+ const visitCompound = (
316
+ parts: SelectorPart[],
317
+ c: CompoundDependency,
318
+ bucket: Bucket,
319
+ unobserved: string[]
320
+ ) => {
321
+ for (const part of parts) {
322
+ if (part.type === "attribute_selector") {
323
+ addAttr(c, bucket, part.name);
324
+ if (part.name === "class") c.classNames = null;
325
+ } else if (part.type === "class_selector") {
326
+ addClassName(c, bucket, part.name);
327
+ } else if (part.type === "id_selector") {
328
+ addAttr(c, bucket, "id");
329
+ } else if (part.type === "pseudo_class_selector") {
330
+ visitPseudo(part, c, bucket, unobserved);
331
+ } else if (part.type === "pseudo_element_selector") {
332
+ unobserved.push(`::${part.name}`);
333
+ }
334
+ }
335
+ };
336
+
337
+ /**
338
+ * What a selector depends on. `path` splits compounds (`>` / `+` / `~`
339
+ * stay as their own entries). `compounds` lists, per compound, which
340
+ * attrs can change its match and where the subjects sit (see
341
+ * `CompoundDependency`). Flags say which observer channels the rule
342
+ * needs. The native engine still matches; this only picks who to re-run
343
+ * after a change.
344
+ */
345
+ export function analyzeSelector(selector: string): SelectorAnalysis {
346
+ const path: string[] = [];
347
+ const compounds: CompoundDependency[] = [];
348
+ const unobserved: string[] = [];
349
+ let positional = false;
350
+ for (const sel of parseSelectorList(selector).selectors) {
351
+ const groups: { parts: SelectorPart[]; pathIndex: number }[] = [];
352
+ const nextCombinator: (string | null)[] = [];
353
+ let current: SelectorPart[] = [];
354
+ const flush = () => {
355
+ if (!current.length) return;
356
+ groups.push({ parts: current, pathIndex: path.length });
357
+ nextCombinator.push(null);
358
+ path.push(
359
+ selector.slice(current[0].start, current[current.length - 1].end)
360
+ );
361
+ current = [];
362
+ };
363
+ for (const part of sel.parts) {
364
+ if (part.type === "combinator") {
365
+ flush();
366
+ if (groups.length) nextCombinator[groups.length - 1] = part.value;
367
+ if (part.value !== " ") path.push(part.value);
368
+ if (isSiblingCombinator(part)) positional = true;
369
+ } else {
370
+ current.push(part);
371
+ }
372
+ }
373
+ flush();
374
+ groups.forEach(({ parts, pathIndex }, k) => {
375
+ const c = newCompound(
376
+ pathIndex,
377
+ path.slice(0, pathIndex + 1).join(" "),
378
+ k === groups.length - 1
379
+ );
380
+ visitCompound(parts, c, "self", unobserved);
381
+ const next = nextCombinator[k];
382
+ c.root = next === "+" || next === "~" ? "parent" : "self";
383
+ c.looseRoot = c.siblingInArgs ? "parent" : "self";
384
+ if (c.positional || c.siblingInArgs) positional = true;
385
+ compounds.push(c);
386
+ });
387
+ }
388
+ const attrs = new Set<string>();
389
+ let classNames: Set<string> | null = new Set<string>();
390
+ compounds.forEach((c) => {
391
+ [c.selfAttrs, c.looseAttrs, c.hasAttrs, c.broadAttrs].forEach((set) =>
392
+ set.forEach((a) => attrs.add(a))
393
+ );
394
+ if (!c.classNames) classNames = null;
395
+ else c.classNames.forEach((name) => classNames?.add(name));
396
+ });
397
+ const usesHas = compounds.some((c) => c.reactsToChildren);
398
+ return {
399
+ path,
400
+ attrs,
401
+ compounds,
402
+ usesHas,
403
+ positional,
404
+ reactsToRemovals: usesHas || positional,
405
+ unobserved,
406
+ classNames,
407
+ };
408
+ }
409
+
410
+ /** Analysis of the empty selector (a rule that targets the host itself). */
411
+ export const EMPTY_ANALYSIS: SelectorAnalysis = Object.freeze({
412
+ path: [],
413
+ attrs: new Set<string>(),
414
+ compounds: [],
415
+ usesHas: false,
416
+ positional: false,
417
+ reactsToRemovals: false,
418
+ unobserved: [],
419
+ classNames: new Set<string>(),
420
+ });
421
+
422
+ // example input: `div:not(.red) .my-section span`
423
+ // example output: { path: ["div:not(.red)", ".my-section", "span"], attrs: Set }
424
+ export function parseCssSelector(selector: string): {
425
+ path: string[];
426
+ attrs: Set<string>;
427
+ } {
428
+ const { path, attrs } = analyzeSelector(selector);
429
+ return { path, attrs };
430
+ }
package/src/settle.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Quiescence: whether Quark still has work queued, and a promise for the
3
+ * moment it has none.
4
+ *
5
+ * Busy sources register a predicate (`paint.ts`: queued paints or a
6
+ * pending commit; `quark.ts`: registered sheets with a queued / running
7
+ * rule pass or `@use` modules still loading). `trackPending` covers
8
+ * promises whose settlement schedules Quark work (async `content`), for
9
+ * as long as the element they render into is connected.
10
+ *
11
+ * `whenSettled` polls once per macrotask, the engine's own cadence (rule
12
+ * passes debounce on `setTimeout(0)`, paints commit on one): a write's
13
+ * MutationObserver records deliver as microtasks and mark the sheet busy
14
+ * before the next check, so "idle at a macrotask boundary" is settled.
15
+ * Used inside a view transition's `update` (see `paint.ts`) and exposed
16
+ * as `Quark.whenSettled()` for tests and tools; sheets have no
17
+ * after-render hook.
18
+ */
19
+ import { tc } from "@excom/kit-utils";
20
+
21
+ const busyChecks: Array<() => boolean> = [];
22
+ const pendingWork = new Set<{ ref?: WeakRef<Element> }>();
23
+
24
+ /** Register a predicate that reports queued Quark work. */
25
+ export const addBusyCheck = (check: () => boolean): void => {
26
+ busyChecks.push(check);
27
+ };
28
+
29
+ /**
30
+ * Count `promise` as pending Quark work until it settles. With `element`,
31
+ * only while that element is connected (a detached target cannot paint
32
+ * anything visible, and must not pin the engine busy forever).
33
+ */
34
+ export const trackPending = (
35
+ promise: PromiseLike<unknown>,
36
+ element?: Element
37
+ ): void => {
38
+ const entry = { ref: element ? new WeakRef(element) : undefined };
39
+ pendingWork.add(entry);
40
+ const done = () => {
41
+ pendingWork.delete(entry);
42
+ };
43
+ promise.then(done, done);
44
+ };
45
+
46
+ const hasPendingWork = (): boolean => {
47
+ for (const entry of pendingWork) {
48
+ if (!entry.ref) return true;
49
+ const element = entry.ref.deref();
50
+ if (!element) pendingWork.delete(entry);
51
+ else if (element.isConnected) return true;
52
+ }
53
+ return false;
54
+ };
55
+
56
+ /** True while any Quark work is queued, running or awaited. */
57
+ export const isQuarkBusy = (): boolean =>
58
+ hasPendingWork() || busyChecks.some((check) => check());
59
+
60
+ /** What a settle wait also waits for (`@view-transition (until: …)`). */
61
+ export type SettleUntil =
62
+ /** `owner` matches `selector` (or leaves the document). */
63
+ | { selector: string; owner: WeakRef<Element> }
64
+ /** The thenable settles, either way. */
65
+ | { thenable: PromiseLike<unknown> };
66
+
67
+ export interface SettleOptions {
68
+ /** Cap in milliseconds; the promise resolves `"timeout"` after it. */
69
+ timeout?: number;
70
+ until?: SettleUntil;
71
+ }
72
+
73
+ /** `"until"` when an `until` condition was met and Quark then settled. */
74
+ export type SettleResult = "settled" | "until" | "timeout";
75
+
76
+ export const DEFAULT_SETTLE_TIMEOUT = 1000;
77
+
78
+ /**
79
+ * Resolve once Quark is idle (and `until`, when given, is met), or when
80
+ * `timeout` elapses. Never rejects.
81
+ */
82
+ export const whenSettled = ({
83
+ timeout = DEFAULT_SETTLE_TIMEOUT,
84
+ until,
85
+ }: SettleOptions = {}): Promise<SettleResult> =>
86
+ new Promise((resolve) => {
87
+ let isMet = !until;
88
+ let isDone = false;
89
+ const finish = (result: SettleResult) => {
90
+ if (isDone) return;
91
+ isDone = true;
92
+ clearTimeout(cap);
93
+ resolve(result);
94
+ };
95
+ const cap = setTimeout(() => finish("timeout"), timeout);
96
+ if (until && "thenable" in until) {
97
+ const met = () => {
98
+ isMet = true;
99
+ };
100
+ Promise.resolve(until.thenable).then(met, met);
101
+ }
102
+ const check = () => {
103
+ if (isDone) return;
104
+ if (!isMet && until && "selector" in until) {
105
+ const owner = until.owner.deref();
106
+ isMet =
107
+ !owner?.isConnected ||
108
+ tc(() => owner.matches(until.selector)) === true;
109
+ }
110
+ if (isMet && !isQuarkBusy()) {
111
+ finish(until ? "until" : "settled");
112
+ return;
113
+ }
114
+ setTimeout(check, 0);
115
+ };
116
+ setTimeout(check, 0);
117
+ });