@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,867 @@
1
+ import type { ListenerOptionSource } from "./ast";
2
+ import type { DiagnosticLevel } from "./ast";
3
+ import { isNoop, isWipe, SYMBOL_NOOP } from "./constants";
4
+ import { getDevtoolsHook, publicize } from "./devtools-hook";
5
+ import { evaluateExpression, getExpressionAst } from "./evaluator";
6
+ import { isFormControlAttribute, syncTextControl } from "./form-controls";
7
+ import { type PaintEntry, type PaintTransition, schedulePaint } from "./paint";
8
+ import {
9
+ DYNAMIC_LISTENER_OPTIONS,
10
+ type Listener,
11
+ listenerHost,
12
+ type ResolvedListenerOptions,
13
+ } from "./properties";
14
+ import { getQuarkInternal } from "./quark-internal";
15
+ import { type SettleUntil, trackPending } from "./settle";
16
+ import type { ContextField, ExpressionResult, TransitionSpec } from "./types";
17
+ import { QuarkLogger } from "./utils";
18
+ import { createScope } from "./variables";
19
+ import {
20
+ getChildren,
21
+ isPojo,
22
+ LoopGuard,
23
+ objToAttrs,
24
+ Queue,
25
+ replaceNonTemplateChildren,
26
+ tc,
27
+ } from "@excom/kit-utils";
28
+
29
+ /**
30
+ * Write text only when it differs from what's painted: one text node
31
+ * holding `text` (or no children for `""`). Same-string re-runs skip
32
+ * the DOM. Anything else in the target (elements, several nodes) is
33
+ * replaced as before.
34
+ */
35
+ const paintText = (target: Node, text: string) => {
36
+ const first = target.firstChild;
37
+ if (!(first === null ? text === "" : isSoleText(first, text))) {
38
+ target.textContent = text;
39
+ }
40
+ // a <textarea>'s text is only its default value: mirror it to .value
41
+ syncTextControl(target, text);
42
+ };
43
+ const isSoleText = (node: ChildNode, text: string) =>
44
+ node.nodeType === Node.TEXT_NODE &&
45
+ node.nextSibling === null &&
46
+ (node as Text).data === text;
47
+
48
+ /** Clear rendered content. `null` from expressions (e.g. `none`) lands here. */
49
+ const wipeContent = (element: Element) => {
50
+ const { templateChild } = getChildren(element);
51
+ if (templateChild) {
52
+ // Keep the source <template>; drop siblings (elements + text).
53
+ replaceNonTemplateChildren(element, []);
54
+ } else {
55
+ element.textContent = "";
56
+ }
57
+ syncTextControl(element, "");
58
+ };
59
+
60
+ export const resolveExpression = ({
61
+ element,
62
+ options,
63
+ value,
64
+ key,
65
+ }: ContextField) => {
66
+ if (!value) return undefined;
67
+ try {
68
+ const ast = getExpressionAst(value);
69
+ // names resolve lazily as the evaluator reaches them
70
+ const scope = createScope({ element, options });
71
+ return evaluateExpression(ast, { scope, element });
72
+ } catch (error) {
73
+ QuarkLogger.error({
74
+ method: "resolveExpression",
75
+ message: "Could not resolve expression",
76
+ element,
77
+ script: value,
78
+ error: [error],
79
+ });
80
+ const err = error as { message?: string; name?: string } | null;
81
+ publicize(["quark", "error"], {
82
+ weakElement: new WeakRef(element),
83
+ tag: element.localName,
84
+ selector: options.rule?.selector ?? null,
85
+ ruleId: options.rule?.id ?? null,
86
+ sheetId: options.rule?.quarkInstance.id ?? null,
87
+ runId: options.runId ?? null,
88
+ key,
89
+ expression: value,
90
+ errorMessage: err?.message ? String(err.message) : String(error),
91
+ errorName: err?.name ? String(err.name) : undefined,
92
+ });
93
+ // failed evaluations never destroy state, they no-op
94
+ return SYMBOL_NOOP;
95
+ }
96
+ };
97
+
98
+ /** Bare-flag `@on` options, resolved once per match. */
99
+ const LISTENER_FLAGS: Record<string, keyof ResolvedListenerOptions> = {
100
+ once: "once",
101
+ self: "self",
102
+ passive: "passive",
103
+ capture: "capture",
104
+ "prevent-default": "preventDefault",
105
+ "stop-propagation": "stopPropagation",
106
+ "stop-immediate-propagation": "stopImmediatePropagation",
107
+ };
108
+
109
+ const warnOption = (args: ContextField, message: string) =>
110
+ QuarkLogger.warn({
111
+ method: "listener",
112
+ message: `Quark: ${args.key} — ${message}`,
113
+ element: args.element,
114
+ });
115
+
116
+ /**
117
+ * Split an `@on` options group for one matched element: flags and the
118
+ * `host` word resolve now (they configure the registration or act on the
119
+ * event); `target` / `key` / `debounce` / `throttle` / `handle` are kept
120
+ * as sources and evaluated by the wrapper when the event fires. Unknown
121
+ * options warn and are ignored.
122
+ */
123
+ export const resolveListenerOptions = (
124
+ args: ContextField,
125
+ sources: ListenerOptionSource[]
126
+ ): { opts: ResolvedListenerOptions; dynamic: ListenerOptionSource[] } => {
127
+ const opts: ResolvedListenerOptions = {};
128
+ let dynamic: ListenerOptionSource[] = [];
129
+ for (const source of sources) {
130
+ const { name, text, ident } = source;
131
+ if (name in LISTENER_FLAGS) {
132
+ if (text !== null) warnOption(args, `option "${name}" takes no value`);
133
+ opts[LISTENER_FLAGS[name]] = true as never;
134
+ } else if ((DYNAMIC_LISTENER_OPTIONS as readonly string[]).includes(name)) {
135
+ dynamic.push(source);
136
+ } else if (name === "host") {
137
+ if (ident === "window" || ident === "document") {
138
+ opts.host = ident;
139
+ } else {
140
+ warnOption(args, `option "host" must be window or document`);
141
+ }
142
+ } else {
143
+ warnOption(args, `unknown option "${name}"`);
144
+ }
145
+ }
146
+ if (
147
+ dynamic.some((o) => o.name === "debounce") &&
148
+ dynamic.some((o) => o.name === "throttle")
149
+ ) {
150
+ warnOption(args, "debounce and throttle are exclusive; using debounce");
151
+ dynamic = dynamic.filter((o) => o.name !== "throttle");
152
+ }
153
+ return { opts, dynamic };
154
+ };
155
+
156
+ /** `@view-transition` options after evaluation (see `resolveTransitionOptions`). */
157
+ export interface ResolvedTransitionOptions {
158
+ types: string[];
159
+ timeout?: number;
160
+ delay?: number;
161
+ firstRender?: boolean;
162
+ ifActive?: "skip" | "replace";
163
+ until?: { selector: string } | { thenable: PromiseLike<unknown> };
164
+ }
165
+
166
+ /** `"a b"` / `("a", "b c")` → `["a", "b", "c"]`; `undefined` when not strings. */
167
+ const typeNames = (value: unknown): string[] | undefined => {
168
+ const items = Array.isArray(value) ? value.flat(Infinity) : [value];
169
+ if (!items.every((item) => typeof item === "string")) return undefined;
170
+ return (items as string[]).flatMap((item) =>
171
+ item.split(/\s+/).filter(Boolean)
172
+ );
173
+ };
174
+
175
+ /** Settle cap of a block's `update`: longer when it waits on `until`. */
176
+ export const DEFAULT_TRANSITION_TIMEOUT = 300;
177
+ export const DEFAULT_UNTIL_TIMEOUT = 1000;
178
+
179
+ /**
180
+ * Evaluate a `@view-transition` options group for one resolve, in the
181
+ * scope of the block's element (`args.element` is the owner, see
182
+ * `paintTransition`). `types` → names; `timeout` / `delay` →
183
+ * milliseconds; `first-render` flag; `if-active` → the bare word `skip` /
184
+ * `replace`; `until` → a selector string or a thenable. `scope` is
185
+ * reserved. Unknown or malformed options warn once per block and are
186
+ * ignored.
187
+ */
188
+ export const resolveTransitionOptions = (
189
+ args: ContextField,
190
+ spec: TransitionSpec
191
+ ): ResolvedTransitionOptions => {
192
+ const opts: ResolvedTransitionOptions = { types: [] };
193
+ const warn = (name: string, message: string) => {
194
+ if (spec.warned.has(name)) return;
195
+ spec.warned.add(name);
196
+ QuarkLogger.warn({
197
+ method: "viewTransition",
198
+ message: `Quark: ${spec.source} — ${message}`,
199
+ element: args.element,
200
+ });
201
+ };
202
+ const evaluate = (text: string | null) =>
203
+ text === null
204
+ ? undefined
205
+ : resolveExpression({
206
+ ...args,
207
+ key: `${spec.source} option`,
208
+ value: text,
209
+ });
210
+ for (const { name, text, ident } of spec.optionSources) {
211
+ if (name === "types") {
212
+ const types = typeNames(evaluate(text));
213
+ if (types) opts.types = types;
214
+ else warn(name, `option "types" needs strings`);
215
+ } else if (name === "timeout" || name === "delay") {
216
+ const value = evaluate(text);
217
+ const ms = typeof value === "string" ? parseFloat(value) : value;
218
+ if (typeof ms === "number" && ms > 0) opts[name] = ms;
219
+ else
220
+ warn(name, `option "${name}" needs a positive number of milliseconds`);
221
+ } else if (name === "first-render") {
222
+ if (text !== null) warn(name, `option "first-render" takes no value`);
223
+ opts.firstRender = true;
224
+ } else if (name === "if-active") {
225
+ if (ident === "skip" || ident === "replace") opts.ifActive = ident;
226
+ else warn(name, `option "if-active" must be skip or replace`);
227
+ } else if (name === "until") {
228
+ const value = evaluate(text);
229
+ if (typeof value === "string" && value.trim()) {
230
+ if (tc(() => args.element.matches(value)) === undefined) {
231
+ warn(name, `until: "${value}" is not a valid selector`);
232
+ } else {
233
+ opts.until = { selector: value };
234
+ }
235
+ } else if (isThenable(value)) {
236
+ opts.until = { thenable: value };
237
+ } else if (value != null && !isNoop(value)) {
238
+ warn(name, `option "until" needs a selector or a promise`);
239
+ }
240
+ } else if (name === "scope") {
241
+ warn(name, `option "scope" is not supported yet`);
242
+ } else {
243
+ warn(name, `unknown option "${name}"`);
244
+ }
245
+ }
246
+ return opts;
247
+ };
248
+
249
+ /**
250
+ * The block's element: what the rule it is written in matches (an
251
+ * ancestor-or-self of the painted element), or the host for a sheet-level
252
+ * or `:scope` block. Options are evaluated on it and `until: "<selector>"`
253
+ * is checked on it.
254
+ */
255
+ const transitionOwner = (
256
+ element: HTMLElement,
257
+ spec: TransitionSpec,
258
+ host: HTMLElement | undefined
259
+ ): Element | null => {
260
+ const owner = spec.ownerRule;
261
+ if (!owner || !owner.matchSelector) return host ?? null;
262
+ const selector = owner.isScoped
263
+ ? owner.scopedSelector()
264
+ : owner.matchSelector;
265
+ return tc(() => element.closest(selector)) ?? null;
266
+ };
267
+
268
+ /**
269
+ * How this resolve's paints commit when the declaration sits inside a
270
+ * `@view-transition` block; `undefined` otherwise, and during the sheet's
271
+ * first render unless the block says `first-render`.
272
+ */
273
+ const paintTransition = (args: ContextField): PaintTransition | undefined => {
274
+ const property = args.options.property;
275
+ const spec = property?.transition;
276
+ if (!spec) return undefined;
277
+ const sheet = property.parent.quarkInstance;
278
+ // the first render costs nothing unless the block animates it
279
+ if ((args.options.isFirstRun || sheet.isFirstRender) && !spec.firstRender) {
280
+ return undefined;
281
+ }
282
+ // options belong to the block: `attr()` / `prop()` / `item` read its element
283
+ const owner = spec.optionSources.length
284
+ ? ((transitionOwner(args.element, spec, sheet.host?.deref()) ??
285
+ args.element) as typeof args.element)
286
+ : args.element;
287
+ const opts: ResolvedTransitionOptions = spec.optionSources.length
288
+ ? resolveTransitionOptions({ ...args, element: owner }, spec)
289
+ : { types: [] };
290
+ const until: SettleUntil | undefined =
291
+ opts.until && "selector" in opts.until
292
+ ? { selector: opts.until.selector, owner: new WeakRef(owner) }
293
+ : opts.until;
294
+ return {
295
+ types: opts.types,
296
+ timeout:
297
+ opts.timeout ??
298
+ (until ? DEFAULT_UNTIL_TIMEOUT : DEFAULT_TRANSITION_TIMEOUT),
299
+ delay: opts.delay,
300
+ ifActive: opts.ifActive ?? "skip",
301
+ until,
302
+ source: spec.source,
303
+ };
304
+ };
305
+
306
+ /** Paint extras for a flagged declaration, with its commit-time change check. */
307
+ const withTransition = (
308
+ transition: PaintTransition | undefined,
309
+ willChange: () => boolean
310
+ ): Pick<PaintEntry, "transition" | "willChange"> | undefined =>
311
+ transition && { transition, willChange };
312
+
313
+ /** Would `QuarkInternal.setAttr(name, value)` change what is rendered? */
314
+ const attrWillChange = (
315
+ element: Element,
316
+ name: string,
317
+ value: unknown
318
+ ): boolean => {
319
+ if (value === undefined) return false;
320
+ const next = typeof value === "boolean" ? (value ? "" : null) : value;
321
+ if (isFormControlAttribute(element, name)) {
322
+ // the live property may have drifted from the attribute (form-controls.ts)
323
+ const control = element as HTMLInputElement & Record<string, unknown>;
324
+ const drifted =
325
+ name === "value"
326
+ ? control.value !== (next === null ? "" : String(next))
327
+ : control[name] !== (next !== null);
328
+ if (drifted) return true;
329
+ }
330
+ return next === null
331
+ ? element.hasAttribute(name)
332
+ : element.getAttribute(name) !== String(next);
333
+ };
334
+
335
+ /** Would replacing `element`'s non-template children with `nodes` change them? */
336
+ const childrenWillChange = (element: Element, nodes: Node[]): boolean => {
337
+ const { otherChildren } = getChildren(element);
338
+ return (
339
+ otherChildren.length !== nodes.length ||
340
+ nodes.some((node, index) => otherChildren[index] !== node)
341
+ );
342
+ };
343
+
344
+ /** Would `wipeContent(element)` change it? */
345
+ const wipeWillChange = (element: Element): boolean =>
346
+ getChildren(element).templateChild
347
+ ? getChildren(element).otherChildren.length > 0
348
+ : element.firstChild !== null;
349
+
350
+ /** Would `paintText(target, text)` change it? */
351
+ const textWillChange = (target: Node, text: string): boolean => {
352
+ const first = target.firstChild;
353
+ return !(first === null ? text === "" : isSoleText(first, text));
354
+ };
355
+
356
+ export const resolveField = (args: ContextField) => {
357
+ let key: string;
358
+ if (args.listener) {
359
+ key = "listener";
360
+ } else if (args.key.startsWith("$")) {
361
+ key = "variable";
362
+ } else if (args.key.startsWith("--")) {
363
+ key = "styleProperty";
364
+ } else if (args.key in FIELD_RESOLVERS) {
365
+ key = args.key;
366
+ } else {
367
+ key = "attribute";
368
+ }
369
+ const result = FIELD_RESOLVERS[key](args);
370
+ publishApply(args, key, result);
371
+ return result;
372
+ };
373
+
374
+ /**
375
+ * DevTools: one publication per property resolve, Quark's version of a
376
+ * Neutron `effect`. Fires at resolve time (before the batched paint),
377
+ * same moment Neutron publishes before running an effect. No-op without
378
+ * a hook.
379
+ *
380
+ * Published as the author meant: render descriptors (`{ type: "nodes" |
381
+ * "html", value }` from `template()` / `iterate()` / `dangerous-html()`)
382
+ * unwrap to `value`, fragments / NodeLists expand to nodes, promises
383
+ * publish once settled, never as `Promise`.
384
+ */
385
+ const publishApply = (
386
+ { element, key, value, options }: ContextField,
387
+ kind: string,
388
+ result: unknown
389
+ ) => {
390
+ if (!getDevtoolsHook()?.publicize) return;
391
+ const rule = options.rule;
392
+ const emit = (resolved: unknown, noop: boolean) =>
393
+ publicize(["quark", "apply"], {
394
+ weakElement: new WeakRef(element),
395
+ tag: element.localName,
396
+ selector: rule?.selector ?? null,
397
+ ruleId: rule?.id ?? null,
398
+ sheetId: rule?.quarkInstance.id ?? null,
399
+ runId: options.runId ?? null,
400
+ kind,
401
+ key,
402
+ expression: value,
403
+ result: noop ? undefined : presentResult(resolved),
404
+ isNoop: noop,
405
+ isWipe: !noop && isWipe(resolved),
406
+ isFirstRun: !!options.isFirstRun,
407
+ });
408
+ if (isNoop(result)) return emit(result, true);
409
+ if (isThenable(result)) {
410
+ result.then(
411
+ (settled) => emit(settled, isNoop(settled)),
412
+ () => emit(undefined, false)
413
+ );
414
+ return;
415
+ }
416
+ emit(result, false);
417
+ };
418
+
419
+ /**
420
+ * Report a `@warn` / `@debug` / `@error` statement: the console at the
421
+ * matching logger level (`@debug` is silent below debug level), and the
422
+ * DevTools hook as `quark/diagnostic` with the values presented the way
423
+ * `quark/apply` presents results.
424
+ */
425
+ export const reportDiagnostic = (
426
+ { element, key, value, options }: ContextField,
427
+ level: DiagnosticLevel,
428
+ values: unknown[]
429
+ ) => {
430
+ const rule = options.rule;
431
+ const text = values
432
+ .map((v) => (typeof v === "string" ? v : presentResult(v)))
433
+ .map((v) =>
434
+ typeof v === "string"
435
+ ? v
436
+ : v instanceof Node
437
+ ? `<${v.nodeName.toLowerCase()}>`
438
+ : (JSON.stringify(v) ?? String(v))
439
+ )
440
+ .join(" ");
441
+ QuarkLogger[level]({
442
+ method: key,
443
+ message: `Quark ${key} (${rule?.selector ?? "?"}): ${text}`,
444
+ element,
445
+ values,
446
+ });
447
+ publicize(["quark", "diagnostic"], {
448
+ weakElement: new WeakRef(element),
449
+ tag: element.localName,
450
+ selector: rule?.selector ?? null,
451
+ ruleId: rule?.id ?? null,
452
+ sheetId: rule?.quarkInstance.id ?? null,
453
+ runId: options.runId ?? null,
454
+ level,
455
+ key,
456
+ expression: value,
457
+ values: values.map(presentResult),
458
+ message: text,
459
+ });
460
+ };
461
+
462
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
463
+ !!value &&
464
+ typeof value === "object" &&
465
+ typeof (value as PromiseLike<unknown>).then === "function";
466
+
467
+ /** The value an author intended, stripped of Quark's render plumbing. */
468
+ const presentResult = (result: unknown): unknown => {
469
+ if (typeof NodeList !== "undefined" && result instanceof NodeList) {
470
+ return Array.from(result).map(presentResult);
471
+ }
472
+ if (
473
+ typeof DocumentFragment !== "undefined" &&
474
+ result instanceof DocumentFragment
475
+ ) {
476
+ return Array.from(result.childNodes)
477
+ .filter((n) => n.nodeType !== Node.TEXT_NODE || n.textContent?.trim())
478
+ .map(presentResult);
479
+ }
480
+ if (Array.isArray(result)) return result.map(presentResult);
481
+ if (
482
+ result &&
483
+ typeof result === "object" &&
484
+ "value" in result &&
485
+ ((result as ExpressionResult)?.type === "nodes" ||
486
+ (result as ExpressionResult)?.type === "html")
487
+ ) {
488
+ return presentResult((result as { value: unknown }).value);
489
+ }
490
+ return result;
491
+ };
492
+
493
+ export const FIELD_RESOLVERS = {
494
+ // Listeners and variables set sync
495
+ listener: (args: ContextField) => {
496
+ const { element, hash } = args;
497
+ const { eventTypes, slot, block, optionSources } = args.listener!;
498
+ const listener = args.options.property as Listener;
499
+ const ruleId = args.options.rule!.id;
500
+ const elementInternal = getQuarkInternal(element);
501
+ let fns: Array<(e: Event) => unknown>;
502
+ let target: EventTarget = element;
503
+ const addOptions: AddEventListenerOptions = {};
504
+ if (listener.hasOptions) {
505
+ const { opts, dynamic } = resolveListenerOptions(args, optionSources);
506
+ // one wrapper carries filters, flags, timing, `once`, `handle` and the block
507
+ fns = [
508
+ listener.wrapper(element, {
509
+ opts,
510
+ dynamic,
511
+ options: args.options,
512
+ hash,
513
+ ruleId,
514
+ internal: elementInternal,
515
+ }),
516
+ ];
517
+ target = listenerHost(element, opts.host);
518
+ if (opts.capture) addOptions.capture = true;
519
+ if (opts.passive) addOptions.passive = true;
520
+ } else {
521
+ // `@on … { }` without options: the block listener, registered directly
522
+ fns = block ? [listener.blockListener(element, args.options)] : [];
523
+ }
524
+ elementInternal.setOrderedListeners(hash, ruleId, slot, fns, {
525
+ eventTypes,
526
+ target,
527
+ options: addOptions,
528
+ });
529
+ return fns;
530
+ },
531
+ variable: (args: ContextField) => {
532
+ const result = resolveExpression(args);
533
+ return result;
534
+ },
535
+ // all attrs and content set async in schedulePaint()
536
+ dataset: (args: ContextField) => {
537
+ const { element, hash } = args;
538
+ const elementInternal = getQuarkInternal(element);
539
+ const resolved = resolveExpression(args);
540
+ if (isNoop(resolved)) {
541
+ return resolved;
542
+ }
543
+ // wipe values (and other falsy) clear prior data-* attrs
544
+ const result = objToAttrs((isWipe(resolved) ? {} : resolved) || {}, {
545
+ prefix: "data-",
546
+ convertNonPrimitives: true,
547
+ preserveKey: false,
548
+ });
549
+ const entries = () =>
550
+ prefixedAttrEntries(elementInternal, hash, "data-", result);
551
+ schedulePaint(
552
+ () => {
553
+ entries().forEach(([k, val]) => {
554
+ elementInternal.setAttr(hash, k, val);
555
+ });
556
+ },
557
+ 1,
558
+ withTransition(paintTransition(args), () =>
559
+ entries().some(([k, val]) => attrWillChange(element, k, val))
560
+ )
561
+ );
562
+ return result;
563
+ },
564
+ ariaset: (args: ContextField) => {
565
+ const { element, hash } = args;
566
+ const elementInternal = getQuarkInternal(element);
567
+ const resolved = resolveExpression(args);
568
+ if (isNoop(resolved)) {
569
+ return resolved;
570
+ }
571
+ const result = objToAttrs((isWipe(resolved) ? {} : resolved) || {}, {
572
+ prefix: "aria-",
573
+ convertNonPrimitives: true,
574
+ preserveKey: false,
575
+ });
576
+ const entries = () =>
577
+ prefixedAttrEntries(elementInternal, hash, "aria-", result);
578
+ schedulePaint(
579
+ () => {
580
+ entries().forEach(([k, val]) => {
581
+ elementInternal.setAttr(hash, k, val);
582
+ });
583
+ },
584
+ 1,
585
+ withTransition(paintTransition(args), () =>
586
+ entries().some(([k, val]) => attrWillChange(element, k, val))
587
+ )
588
+ );
589
+ return result;
590
+ },
591
+ class: (args: ContextField) => {
592
+ const { element, hash, key } = args;
593
+ const elementInternal = getQuarkInternal(element);
594
+ const result = resolveExpression(args);
595
+ if (isNoop(result)) {
596
+ return result;
597
+ }
598
+ // a string, a list (joined) or a wipe is one attribute write; a map
599
+ // toggles single classes (read at commit time: class changes are
600
+ // observed, so only real flips may write, as one causal hop)
601
+ const value = Array.isArray(result)
602
+ ? result.join(" ")
603
+ : isWipe(result)
604
+ ? null
605
+ : typeof result === "string"
606
+ ? result
607
+ : undefined;
608
+ const flips = (): [string, unknown][] =>
609
+ isPojo(result)
610
+ ? Object.entries(result).filter(
611
+ ([k, v]) => element.classList.contains(k) !== !!v
612
+ )
613
+ : [];
614
+ schedulePaint(
615
+ () => {
616
+ if (value !== undefined) {
617
+ elementInternal.setAttr(hash, key, value);
618
+ return;
619
+ }
620
+ const toggles = flips();
621
+ if (toggles.length) {
622
+ LoopGuard.write(element, key, () =>
623
+ toggles.forEach(([k, v]) => element.classList.toggle(k, !!v))
624
+ );
625
+ }
626
+ },
627
+ 1,
628
+ withTransition(paintTransition(args), () =>
629
+ value !== undefined
630
+ ? attrWillChange(element, key, value)
631
+ : flips().length > 0
632
+ )
633
+ );
634
+ return result;
635
+ },
636
+ /*
637
+ * CSS custom properties (`--x:`). Inline-style writes so stylesheets
638
+ * can consume Quark state via `var()` (colors, progress, theming).
639
+ * Values are Quark expressions, not CSS value grammar: quote literals
640
+ * (`"red !important"`, `"#ccc"`). A trailing `!important` in the
641
+ * string maps to the priority arg. Wipe removes the property. Quark
642
+ * never reads CSS variables back.
643
+ */
644
+ styleProperty: (args: ContextField) => {
645
+ const { element, hash, key } = args;
646
+ const elementInternal = getQuarkInternal(element);
647
+ const resolved = resolveExpression(args);
648
+ if (isNoop(resolved)) {
649
+ return resolved;
650
+ }
651
+ const wiped = isWipe(resolved);
652
+ if (
653
+ !wiped &&
654
+ typeof resolved !== "string" &&
655
+ typeof resolved !== "number"
656
+ ) {
657
+ QuarkLogger.error({
658
+ method: "styleProperty",
659
+ message: `Quark: "${key}" must resolve to a string — quote CSS values (e.g. "#ccc")`,
660
+ element,
661
+ script: args.value,
662
+ });
663
+ return SYMBOL_NOOP;
664
+ }
665
+ const value = wiped ? null : String(resolved);
666
+ schedulePaint(
667
+ () => {
668
+ elementInternal.setStyleProperty(hash, key, value);
669
+ },
670
+ 1,
671
+ withTransition(paintTransition(args), () => {
672
+ const style = element.style;
673
+ if (value === null) return style.getPropertyValue(key) !== "";
674
+ const important = IMPORTANT.test(value);
675
+ return (
676
+ style.getPropertyValue(key).trim() !==
677
+ value.replace(IMPORTANT, "").trim() ||
678
+ style.getPropertyPriority(key) !== (important ? "important" : "")
679
+ );
680
+ })
681
+ );
682
+ return resolved;
683
+ },
684
+ attribute: (args: ContextField) => {
685
+ const { element, hash, key } = args;
686
+ const elementInternal = getQuarkInternal(element);
687
+ const result = resolveExpression(args);
688
+ if (isNoop(result)) {
689
+ return result;
690
+ }
691
+ const value = isWipe(result) ? null : result;
692
+ schedulePaint(
693
+ () => {
694
+ elementInternal.setAttr(hash, key, value);
695
+ },
696
+ 1,
697
+ withTransition(paintTransition(args), () =>
698
+ attrWillChange(element, key, value)
699
+ )
700
+ );
701
+ return result;
702
+ },
703
+ content: (args: ContextField) => {
704
+ const { element, hash, value } = args;
705
+ const elementInternal = getQuarkInternal(element);
706
+ const result = resolveExpression(args);
707
+ // `preserve` leaves rendered content alone; wipe values clear it
708
+ if (isNoop(result)) {
709
+ return result;
710
+ }
711
+ // `@view-transition`: evaluated now, in this run's scope, for any branch
712
+ const transition = paintTransition(args);
713
+ if (isWipe(result)) {
714
+ elementInternal.setContentAttr(hash);
715
+ schedulePaint(
716
+ () => {
717
+ wipeContent(element);
718
+ },
719
+ 0,
720
+ withTransition(transition, () => wipeWillChange(element))
721
+ );
722
+ return result;
723
+ }
724
+ // a template's fragment is outside the observed tree and its nodes are
725
+ // never connected, so paints into it must not requeue rules either
726
+ const willRenderIntoTemplate = element instanceof HTMLTemplateElement;
727
+ const textTarget = willRenderIntoTemplate ? element.content : element;
728
+ if (typeof result === "object") {
729
+ /*
730
+ * Mark content applied before any async work so parallel
731
+ * calls still collapse to one render.
732
+ */
733
+ elementInternal.setContentAttr(hash);
734
+ // a settle wait (view transitions, `Quark.whenSettled`) covers the
735
+ // paint this promise will schedule
736
+ if (isThenable(result)) trackPending(result, element);
737
+ new Queue()
738
+ .settle(result)
739
+ /*
740
+ * If this settles sync, dependents see the new value. If async,
741
+ * a dependent with a sync dep that changed at the same time
742
+ * renders once with stale / undefined, then again when this
743
+ * resolves. Fix would be: dependents check `item` / `index` for
744
+ * a pending async op and wait.
745
+ */
746
+ .onResolved((state) => {
747
+ const res = state.value as ExpressionResult | Node;
748
+ // iterate() / template() / dangerous-html() settle to
749
+ // {type, value, after?}, or a wipe / no-op value
750
+ if (isNoop(res)) return;
751
+ if (isWipe(res)) {
752
+ schedulePaint(
753
+ () => {
754
+ wipeContent(element);
755
+ },
756
+ 0,
757
+ withTransition(transition, () => wipeWillChange(element))
758
+ );
759
+ return;
760
+ }
761
+ if (res instanceof Node || res instanceof NodeList) {
762
+ // inserted elements reach rules through the childList observer;
763
+ // a NodeList may be live, so read it at commit time
764
+ const nodes = () => (res instanceof Node ? [res] : Array.from(res));
765
+ schedulePaint(
766
+ () => {
767
+ replaceNonTemplateChildren(element, nodes());
768
+ },
769
+ 0,
770
+ withTransition(transition, () =>
771
+ childrenWillChange(element, nodes())
772
+ )
773
+ );
774
+ } else if (res?.type === "nodes") {
775
+ const nodeArray = res.value;
776
+ schedulePaint(
777
+ () => {
778
+ replaceNonTemplateChildren(element, nodeArray);
779
+ if (nodeArray.length && !willRenderIntoTemplate) {
780
+ res?.after?.();
781
+ }
782
+ },
783
+ 0,
784
+ withTransition(transition, () =>
785
+ childrenWillChange(element, nodeArray)
786
+ )
787
+ );
788
+ } else if (res?.type === "html") {
789
+ schedulePaint(
790
+ () => {
791
+ // element insertions are one causal hop (see LoopGuard)
792
+ LoopGuard.write(element, "content", () => {
793
+ element.innerHTML = res.value as string;
794
+ if (!willRenderIntoTemplate) {
795
+ res?.after?.();
796
+ }
797
+ });
798
+ },
799
+ 0,
800
+ withTransition(
801
+ transition,
802
+ () => element.innerHTML !== (res.value as string)
803
+ )
804
+ );
805
+ } else {
806
+ const text = res + "";
807
+ schedulePaint(
808
+ () => {
809
+ /*
810
+ * `textContent` / `innerText` do not write a template's
811
+ * document fragment (unlike `innerHTML`), so templates go
812
+ * through `.content`.
813
+ */
814
+ paintText(textTarget, text);
815
+ },
816
+ 0,
817
+ withTransition(transition, () => textWillChange(textTarget, text))
818
+ );
819
+ }
820
+ })
821
+ .onRejected((state) => {
822
+ QuarkLogger.error({
823
+ method: "content",
824
+ message: "Could not render content",
825
+ element,
826
+ expression: value,
827
+ value: [state.value],
828
+ });
829
+ });
830
+ } else {
831
+ // record that content was applied so re-run gating (propertyIsNew /
832
+ // nearness) works for plain string results too
833
+ elementInternal.setContentAttr(hash);
834
+ const text = result + "";
835
+ schedulePaint(
836
+ () => {
837
+ paintText(textTarget, text);
838
+ },
839
+ 0,
840
+ withTransition(transition, () => textWillChange(textTarget, text))
841
+ );
842
+ }
843
+ return result;
844
+ },
845
+ };
846
+
847
+ const IMPORTANT = /\s*!important\s*$/i;
848
+
849
+ /**
850
+ * The attribute writes of a `dataset` / `ariaset` paint: every prefixed
851
+ * attribute this sheet set before is removed unless the new object sets
852
+ * it again. Read at commit time.
853
+ */
854
+ const prefixedAttrEntries = (
855
+ elementInternal: ReturnType<typeof getQuarkInternal>,
856
+ hash: string,
857
+ prefix: string,
858
+ result: Record<string, unknown>
859
+ ): [string, unknown][] =>
860
+ Object.entries({
861
+ ...Object.fromEntries(
862
+ Object.keys(elementInternal.getAllAttrs(hash))
863
+ .map((k) => k.startsWith(prefix) && [k, null])
864
+ .filter(Boolean) as [string, null][]
865
+ ),
866
+ ...result,
867
+ });