@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.
- package/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/quark.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/index.ts +4 -0
- package/language.ts +7 -0
- package/package.json +45 -0
- package/rush-logs/quark.apply-exports.cache.log +1 -0
- package/rush-logs/quark.apply-exports.log +1 -0
- package/rush-logs/quark.build_package-metas.cache.log +1 -0
- package/rush-logs/quark.build_package-metas.log +1 -0
- package/src/actions.ts +445 -0
- package/src/ast.ts +187 -0
- package/src/bindings.ts +115 -0
- package/src/builtin-modules.ts +401 -0
- package/src/constants.ts +47 -0
- package/src/cycle-check.ts +121 -0
- package/src/devtools-hook.ts +303 -0
- package/src/element-api.ts +109 -0
- package/src/evaluator.ts +317 -0
- package/src/form-controls.ts +69 -0
- package/src/language-tables.ts +137 -0
- package/src/language.ts +1225 -0
- package/src/observer.ts +110 -0
- package/src/paint.ts +305 -0
- package/src/properties.ts +818 -0
- package/src/props.ts +94 -0
- package/src/quark-element.ts +7 -0
- package/src/quark-internal.ts +365 -0
- package/src/quark.ts +817 -0
- package/src/resolvers.ts +867 -0
- package/src/rule.ts +800 -0
- package/src/scope-id.ts +47 -0
- package/src/selector-utils.ts +430 -0
- package/src/settle.ts +117 -0
- package/src/types.ts +155 -0
- package/src/utils.ts +79 -0
- package/src/variables.ts +359 -0
- package/support/demos/css-variables.html +18 -0
- package/support/demos/dispatch.html +20 -0
- package/support/demos/events.html +9 -0
- package/support/demos/iterate.html +13 -0
- package/support/demos/js-api.html +17 -0
- package/support/demos/provider-list.html +16 -0
- package/support/demos/toggle-content.html +14 -0
- package/support/demos/unset.html +12 -0
- package/support/demos/view-transition.html +36 -0
- package/support/docs/ATTRIBUTES.md +37 -0
- package/support/docs/AT_RULES.md +45 -0
- package/support/docs/BUILTINS.md +57 -0
- package/support/docs/CONTENT.md +40 -0
- package/support/docs/CSS_VARIABLES.md +16 -0
- package/support/docs/DECLARATIONS.md +26 -0
- package/support/docs/DELAY.md +37 -0
- package/support/docs/DIAGNOSTICS.md +32 -0
- package/support/docs/DISPATCH.md +81 -0
- package/support/docs/ELEMENT_PROPERTIES.md +34 -0
- package/support/docs/EXPRESSIONS.md +31 -0
- package/support/docs/INTERNAL.md +8 -0
- package/support/docs/JS_API.md +53 -0
- package/support/docs/JS_WRITES.md +36 -0
- package/support/docs/LIMITATIONS.md +26 -0
- package/support/docs/LOOP_GUARD.md +30 -0
- package/support/docs/METHODS.md +43 -0
- package/support/docs/MODULES.md +111 -0
- package/support/docs/NO_REVERSION.md +18 -0
- package/support/docs/ON.md +87 -0
- package/support/docs/REACTIVITY.md +24 -0
- package/support/docs/README.md +143 -0
- package/support/docs/SELECTORS.md +87 -0
- package/support/docs/SHEETS.md +67 -0
- package/support/docs/SYNTAX.md +36 -0
- package/support/docs/USE.md +24 -0
- package/support/docs/VALUES.md +27 -0
- package/support/docs/VARIABLES.md +47 -0
- package/support/docs/VIEW_TRANSITION.md +64 -0
- package/support/docs-sections.json +65 -0
- package/support/package-meta.json +208 -0
- package/support/scripts/build-language-docs.mjs +196 -0
- package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
- package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
- package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
- package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
- package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
- package/support/tests/actions.test.ts +437 -0
- package/support/tests/builtin-modules.test.ts +337 -0
- package/support/tests/builtins.test.ts +189 -0
- package/support/tests/css-variables.view.test.ts +35 -0
- package/support/tests/delay.test.ts +244 -0
- package/support/tests/devtools-hook.test.ts +496 -0
- package/support/tests/diagnostics.test.ts +224 -0
- package/support/tests/dispatch.view.test.ts +43 -0
- package/support/tests/element-api.test.ts +202 -0
- package/support/tests/evaluator.test.ts +237 -0
- package/support/tests/events.view.test.ts +40 -0
- package/support/tests/form-controls.test.ts +452 -0
- package/support/tests/helpers.ts +354 -0
- package/support/tests/internals.test.ts +568 -0
- package/support/tests/iterate.view.test.ts +40 -0
- package/support/tests/js-api.view.test.ts +45 -0
- package/support/tests/language-docs.test.ts +147 -0
- package/support/tests/lifecycle.test.ts +440 -0
- package/support/tests/loop-guard.test.ts +323 -0
- package/support/tests/provider-list.view.test.ts +47 -0
- package/support/tests/quark-features.test.ts +2887 -0
- package/support/tests/quark-with-providers.test.ts +169 -0
- package/support/tests/quark.test.ts +359 -0
- package/support/tests/resolvers.test.ts +346 -0
- package/support/tests/selector-analysis.test.ts +281 -0
- package/support/tests/selectors.test.ts +670 -0
- package/support/tests/toggle-content.view.test.ts +40 -0
- package/support/tests/unset.view.test.ts +42 -0
- package/support/tests/view-helpers.ts +81 -0
- package/support/tests/view-transition.test.ts +1069 -0
- package/support/tests/view-transition.view.test.ts +76 -0
- package/tsconfig.json +5 -0
package/src/ast.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-contract helpers.
|
|
3
|
+
*
|
|
4
|
+
* Quark consumes `@excom/quark-parser` statements directly. These
|
|
5
|
+
* keep AST walks in one place so `Rule` and `Quark` stay small. They
|
|
6
|
+
* slice the exact source for selectors / keys / values, so downstream
|
|
7
|
+
* string handling (expression cache keys, `&` expansion) is
|
|
8
|
+
* byte-identical to the old path.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
ActionRule,
|
|
12
|
+
Declaration,
|
|
13
|
+
DelayRule,
|
|
14
|
+
EventName,
|
|
15
|
+
ListenerOption,
|
|
16
|
+
ListenerRule,
|
|
17
|
+
Rule as RuleNode,
|
|
18
|
+
Statement,
|
|
19
|
+
TransitionRule,
|
|
20
|
+
UseRule,
|
|
21
|
+
ValueAtRule,
|
|
22
|
+
} from "@excom/quark-parser";
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
ActionRule,
|
|
26
|
+
Declaration,
|
|
27
|
+
DelayRule,
|
|
28
|
+
EventName,
|
|
29
|
+
ListenerRule,
|
|
30
|
+
RuleNode,
|
|
31
|
+
Statement,
|
|
32
|
+
TransitionRule,
|
|
33
|
+
UseRule,
|
|
34
|
+
ValueAtRule,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The at-rule names `viewBlock` reports as diagnostics. */
|
|
38
|
+
export const DIAGNOSTIC_LEVELS = ["warn", "debug", "error"] as const;
|
|
39
|
+
export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number];
|
|
40
|
+
|
|
41
|
+
export interface SpanNode {
|
|
42
|
+
start: number;
|
|
43
|
+
end: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const sliceNode = (source: string, node: SpanNode): string =>
|
|
47
|
+
source.slice(node.start, node.end);
|
|
48
|
+
|
|
49
|
+
/** Comma-separated selector texts of a rule, in source order. */
|
|
50
|
+
export const ruleSelectorTexts = (rule: RuleNode, source: string): string[] =>
|
|
51
|
+
rule.selector.selectors.map((sel) => sliceNode(source, sel).trim());
|
|
52
|
+
|
|
53
|
+
/** `key` / `value` strings of a declaration, matching legacy processing. */
|
|
54
|
+
export const declarationStrings = (
|
|
55
|
+
declaration: Declaration,
|
|
56
|
+
source: string
|
|
57
|
+
): { key: string; value: string } => ({
|
|
58
|
+
key: sliceNode(source, declaration.property).trim(),
|
|
59
|
+
value: (declaration.value ? sliceNode(source, declaration.value) : "").trim(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Event / command names of an `@on` / `@dispatch` / `@command` at-rule as
|
|
64
|
+
* written: `click, "my:evt"`.
|
|
65
|
+
*/
|
|
66
|
+
export const eventNamesText = (names: EventName[]): string =>
|
|
67
|
+
names.map((n) => (n.quoted ? `"${n.name}"` : n.name)).join(", ");
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One at-rule option (`@on`, `@view-transition`) as the runtime sees it:
|
|
71
|
+
* its name plus the value source.
|
|
72
|
+
*/
|
|
73
|
+
export interface ListenerOptionSource {
|
|
74
|
+
name: string;
|
|
75
|
+
/** Value expression source, or `null` for a bare flag (`once`). */
|
|
76
|
+
text: string | null;
|
|
77
|
+
/** The bare identifier name when the value is one (`host: window`). */
|
|
78
|
+
ident: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** At-rule options as `{ name, text, ident }` records, in source order. */
|
|
82
|
+
export const listenerOptionSources = (
|
|
83
|
+
node: { options: ListenerOption[] },
|
|
84
|
+
source: string
|
|
85
|
+
): ListenerOptionSource[] =>
|
|
86
|
+
node.options.map((option) => ({
|
|
87
|
+
name: option.name,
|
|
88
|
+
text: option.value ? sliceNode(source, option.value).trim() : null,
|
|
89
|
+
ident:
|
|
90
|
+
option.value?.type === "identifier"
|
|
91
|
+
? (option.value as { name: string }).name
|
|
92
|
+
: null,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
/** Display form of an options group: ` (target: "li", once)`; `""` when absent. */
|
|
96
|
+
export const listenerOptionsText = (sources: ListenerOptionSource[]): string =>
|
|
97
|
+
sources.length
|
|
98
|
+
? ` (${sources
|
|
99
|
+
.map((o) => (o.text === null ? o.name : `${o.name}: ${o.text}`))
|
|
100
|
+
.join(", ")})`
|
|
101
|
+
: "";
|
|
102
|
+
|
|
103
|
+
export interface BlockView {
|
|
104
|
+
declarations: Declaration[];
|
|
105
|
+
/** `@on` at-rules, in source order. */
|
|
106
|
+
listeners: ListenerRule[];
|
|
107
|
+
/** `@dispatch` / `@command` statements, in source order. */
|
|
108
|
+
actions: ActionRule[];
|
|
109
|
+
/** `@warn` / `@debug` / `@error` statements, in source order. */
|
|
110
|
+
diagnostics: ValueAtRule[];
|
|
111
|
+
/** `@delay` blocks, in source order. */
|
|
112
|
+
delays: DelayRule[];
|
|
113
|
+
/** Nested rules and `@view-transition` blocks, in source order. */
|
|
114
|
+
children: Array<RuleNode | TransitionRule>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Split a block body into the statements Quark executes. `@use` is
|
|
119
|
+
* handled at sheet level (see `collectUseRules`); `@on` becomes a
|
|
120
|
+
* listener; `@dispatch` / `@command` become actions (event blocks only,
|
|
121
|
+
* see `Rule`); `@warn` / `@debug` / `@error` become diagnostics; `@delay`
|
|
122
|
+
* becomes a deferred block; nested rules and `@view-transition` blocks
|
|
123
|
+
* become rules.
|
|
124
|
+
*/
|
|
125
|
+
export const viewBlock = (body: Statement[]): BlockView => {
|
|
126
|
+
const declarations: Declaration[] = [];
|
|
127
|
+
const listeners: ListenerRule[] = [];
|
|
128
|
+
const actions: ActionRule[] = [];
|
|
129
|
+
const diagnostics: ValueAtRule[] = [];
|
|
130
|
+
const delays: DelayRule[] = [];
|
|
131
|
+
const children: Array<RuleNode | TransitionRule> = [];
|
|
132
|
+
for (const statement of body) {
|
|
133
|
+
if (statement.type === "rule") {
|
|
134
|
+
children.push(statement);
|
|
135
|
+
} else if (statement.type === "atrule" && statement.name === "on") {
|
|
136
|
+
listeners.push(statement as ListenerRule);
|
|
137
|
+
} else if (
|
|
138
|
+
statement.type === "atrule" &&
|
|
139
|
+
(statement.name === "dispatch" || statement.name === "command")
|
|
140
|
+
) {
|
|
141
|
+
actions.push(statement as ActionRule);
|
|
142
|
+
} else if (
|
|
143
|
+
statement.type === "atrule" &&
|
|
144
|
+
(DIAGNOSTIC_LEVELS as readonly string[]).includes(statement.name)
|
|
145
|
+
) {
|
|
146
|
+
diagnostics.push(statement as ValueAtRule);
|
|
147
|
+
} else if (statement.type === "atrule" && statement.name === "delay") {
|
|
148
|
+
delays.push(statement as DelayRule);
|
|
149
|
+
} else if (
|
|
150
|
+
statement.type === "atrule" &&
|
|
151
|
+
statement.name === "view-transition"
|
|
152
|
+
) {
|
|
153
|
+
children.push(statement as TransitionRule);
|
|
154
|
+
} else if (statement.type === "declaration") {
|
|
155
|
+
declarations.push(statement);
|
|
156
|
+
}
|
|
157
|
+
// comments, @use and @scope are intentionally skipped here
|
|
158
|
+
}
|
|
159
|
+
return { declarations, listeners, actions, diagnostics, delays, children };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Display form of a `@view-transition` block: `@view-transition (types: "t")`. */
|
|
163
|
+
export const transitionSourceText = (
|
|
164
|
+
node: TransitionRule,
|
|
165
|
+
source: string
|
|
166
|
+
): string =>
|
|
167
|
+
`@view-transition${listenerOptionsText(listenerOptionSources(node, source))}`;
|
|
168
|
+
|
|
169
|
+
/** Collect `@use` rules from anywhere in the statement tree. */
|
|
170
|
+
export const collectUseRules = (body: Statement[]): UseRule[] =>
|
|
171
|
+
body.flatMap((statement) => {
|
|
172
|
+
if (statement.type === "atrule" && statement.name === "use") {
|
|
173
|
+
return [statement as UseRule];
|
|
174
|
+
}
|
|
175
|
+
if (statement.type === "rule") {
|
|
176
|
+
return collectUseRules(statement.block.body);
|
|
177
|
+
}
|
|
178
|
+
// other block-bearing at-rules (notably the `@scope` sheet wrapper)
|
|
179
|
+
if (
|
|
180
|
+
statement.type === "atrule" &&
|
|
181
|
+
"block" in statement &&
|
|
182
|
+
statement.block
|
|
183
|
+
) {
|
|
184
|
+
return collectUseRules(statement.block.body);
|
|
185
|
+
}
|
|
186
|
+
return [];
|
|
187
|
+
});
|
package/src/bindings.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bindings: named `$variable` values stored on an element's `_q_` when a
|
|
3
|
+
* `$variable:` declaration runs. They behave like CSS custom properties:
|
|
4
|
+
*
|
|
5
|
+
* - Tree-scoped, not sheet-scoped. Any sheet (or JS via
|
|
6
|
+
* `element.quark.setProperty()`, see element-api.ts) can
|
|
7
|
+
* read a binding another set. Walk ancestors, self first; nearest
|
|
8
|
+
* owner wins.
|
|
9
|
+
* - `unset` deletes it so descendants fall through (CSS `unset` /
|
|
10
|
+
* inherit).
|
|
11
|
+
* - A real change fires bubbling `quark-binding-change` from the owner.
|
|
12
|
+
* Sheets listen at their root (same bus as `quark-prop-change`) and
|
|
13
|
+
* re-run readers in the owner's subtree.
|
|
14
|
+
*
|
|
15
|
+
* `prop()` is the property-side twin: value on the matched element,
|
|
16
|
+
* `quark-prop-change` in props.ts.
|
|
17
|
+
*/
|
|
18
|
+
import { SYMBOL_UNSET } from "./constants";
|
|
19
|
+
import { getQuarkInternal, type TQuarkElement } from "./quark-internal";
|
|
20
|
+
import type { RunTrace } from "./types";
|
|
21
|
+
import { LoopGuard } from "@excom/kit-utils";
|
|
22
|
+
|
|
23
|
+
export const BINDING_CHANGE_EVENT = "quark-binding-change";
|
|
24
|
+
|
|
25
|
+
export interface BindingChangeDetail {
|
|
26
|
+
name: string;
|
|
27
|
+
/** Writing sheet; `null` for a JS write through `element.quark`. */
|
|
28
|
+
sheetId?: number | null;
|
|
29
|
+
runId?: string;
|
|
30
|
+
isFirstRun?: boolean;
|
|
31
|
+
/** Deferred writes (see `Quark.queueBindingChange`), see QuarkOptions. */
|
|
32
|
+
sinceSeq?: number;
|
|
33
|
+
trace?: RunTrace;
|
|
34
|
+
/**
|
|
35
|
+
* This sheet's run already hit every reader of this write after it
|
|
36
|
+
* happened; only other sheets need to react.
|
|
37
|
+
*/
|
|
38
|
+
selfCovered?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How a binding write announces itself (`writeBinding`'s `notify`). */
|
|
42
|
+
export type BindingChangeNotifier = (
|
|
43
|
+
element: Element,
|
|
44
|
+
detail: BindingChangeDetail
|
|
45
|
+
) => void;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Nearest element (self first) that holds a binding for `name`. `null`
|
|
49
|
+
* if nothing up the tree has it.
|
|
50
|
+
*/
|
|
51
|
+
export const findBindingOwner = (
|
|
52
|
+
element: Element | null,
|
|
53
|
+
name: string
|
|
54
|
+
): TQuarkElement | null => {
|
|
55
|
+
for (let el = element; el; el = el.parentElement) {
|
|
56
|
+
if ((el as TQuarkElement)._q_?.hasVar(name)) return el as TQuarkElement;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a binding by walking ancestors. `undefined` if none holds it.
|
|
63
|
+
* The owner is recorded on the consumer so change-event fan-out can skip
|
|
64
|
+
* shadowed (farther) owners.
|
|
65
|
+
*/
|
|
66
|
+
export const readBinding = (element: Element, name: string) => {
|
|
67
|
+
const owner = findBindingOwner(element, name);
|
|
68
|
+
const consumerInternal = getQuarkInternal(element);
|
|
69
|
+
if (!owner) {
|
|
70
|
+
delete consumerInternal.varOwners[name];
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
consumerInternal.varOwners[name] = new WeakRef(owner as HTMLElement);
|
|
74
|
+
return getQuarkInternal(owner).getVar(name);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Fire the bubbling change event all sheets listen for. */
|
|
78
|
+
export const dispatchBindingChange = (
|
|
79
|
+
element: Element,
|
|
80
|
+
detail: BindingChangeDetail
|
|
81
|
+
): void => {
|
|
82
|
+
element.dispatchEvent(
|
|
83
|
+
new CustomEvent<BindingChangeDetail>(BINDING_CHANGE_EVENT, {
|
|
84
|
+
bubbles: true,
|
|
85
|
+
detail,
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Write a binding (`unset` deletes it). A real change fires bubbling
|
|
92
|
+
* `quark-binding-change` from the owner so sheets re-run readers. Pass
|
|
93
|
+
* `notify` to defer (a sheet mid-run).
|
|
94
|
+
*/
|
|
95
|
+
export const writeBinding = (
|
|
96
|
+
element: Element,
|
|
97
|
+
name: string,
|
|
98
|
+
value: unknown,
|
|
99
|
+
detail: Omit<BindingChangeDetail, "name"> = {},
|
|
100
|
+
notify: BindingChangeNotifier = dispatchBindingChange
|
|
101
|
+
): boolean => {
|
|
102
|
+
const elementInternal = getQuarkInternal(element);
|
|
103
|
+
// one causal hop for the loop guard (a `$a` ↔ `$b` cycle across sheets
|
|
104
|
+
// recurses synchronously); past the limit the write is dropped
|
|
105
|
+
const changed =
|
|
106
|
+
LoopGuard.write(element, name, () =>
|
|
107
|
+
value === SYMBOL_UNSET
|
|
108
|
+
? elementInternal.deleteVar(name)
|
|
109
|
+
: elementInternal.setVar(name, value)
|
|
110
|
+
) === true;
|
|
111
|
+
if (changed) {
|
|
112
|
+
notify(element, { name, ...detail });
|
|
113
|
+
}
|
|
114
|
+
return changed;
|
|
115
|
+
};
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in `@use "quark:<name>"` modules: pure helpers grouped the way
|
|
3
|
+
* Sass groups `sass:math` / `sass:list` / `sass:map` / `sass:string`.
|
|
4
|
+
* Nothing here is global — a sheet imports what it needs
|
|
5
|
+
* (`@use "quark:math" as math;` → `math.clamp(0, $x, 1)`; `as *` merges
|
|
6
|
+
* the exports bare). Every function is pure, null-tolerant (a missing
|
|
7
|
+
* collection reads as empty, a missing value passes through) and returns
|
|
8
|
+
* copies, never mutating its arguments. Dashed names (`sort-by`) are
|
|
9
|
+
* plain object keys: the evaluator calls own-property functions by name.
|
|
10
|
+
*
|
|
11
|
+
* Docs live in `language.ts` (`BUILTIN_MODULES`); a test keeps the
|
|
12
|
+
* export lists in step.
|
|
13
|
+
*/
|
|
14
|
+
import type { Vars } from "./types";
|
|
15
|
+
|
|
16
|
+
type Path = string | null | undefined;
|
|
17
|
+
|
|
18
|
+
/** Dot-path read (`"user.name"`); `undefined` for a missing segment. */
|
|
19
|
+
const pathval = (obj: unknown, path: Path): unknown =>
|
|
20
|
+
path == null || path === ""
|
|
21
|
+
? obj
|
|
22
|
+
: String(path)
|
|
23
|
+
.split(".")
|
|
24
|
+
.reduce<unknown>(
|
|
25
|
+
(acc, key) =>
|
|
26
|
+
acc == null ? undefined : (acc as Record<string, unknown>)[key],
|
|
27
|
+
obj
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** Arrays as-is, objects as their values, everything else empty. */
|
|
31
|
+
const toList = (value: unknown): unknown[] =>
|
|
32
|
+
Array.isArray(value)
|
|
33
|
+
? value
|
|
34
|
+
: value && typeof value === "object"
|
|
35
|
+
? Object.values(value as Record<string, unknown>)
|
|
36
|
+
: [];
|
|
37
|
+
|
|
38
|
+
const toNumber = (value: unknown): number => {
|
|
39
|
+
const n = typeof value === "number" ? value : parseFloat(String(value));
|
|
40
|
+
return Number.isFinite(n) ? n : 0;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const isMap = (value: unknown): value is Record<string, unknown> =>
|
|
44
|
+
!!value && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
|
|
46
|
+
const isEmptyValue = (value: unknown): boolean =>
|
|
47
|
+
value == null ||
|
|
48
|
+
value === "" ||
|
|
49
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
50
|
+
(isMap(value) && Object.keys(value).length === 0);
|
|
51
|
+
|
|
52
|
+
/** Loose equality, the way `find()` and `==` behave in expressions. */
|
|
53
|
+
const same = (a: unknown, b: unknown): boolean => a == b;
|
|
54
|
+
|
|
55
|
+
const math: Vars = {
|
|
56
|
+
$pi: Math.PI,
|
|
57
|
+
$e: Math.E,
|
|
58
|
+
min: (...values: unknown[]) => Math.min(...values.flat().map(toNumber)),
|
|
59
|
+
max: (...values: unknown[]) => Math.max(...values.flat().map(toNumber)),
|
|
60
|
+
// CSS / Sass argument order: clamp(min, value, max)
|
|
61
|
+
clamp: (min: unknown, value: unknown, max: unknown) =>
|
|
62
|
+
Math.min(Math.max(toNumber(value), toNumber(min)), toNumber(max)),
|
|
63
|
+
round: (value: unknown, digits: unknown = 0) => {
|
|
64
|
+
const factor = 10 ** toNumber(digits);
|
|
65
|
+
return Math.round(toNumber(value) * factor) / factor;
|
|
66
|
+
},
|
|
67
|
+
floor: (value: unknown) => Math.floor(toNumber(value)),
|
|
68
|
+
ceil: (value: unknown) => Math.ceil(toNumber(value)),
|
|
69
|
+
abs: (value: unknown) => Math.abs(toNumber(value)),
|
|
70
|
+
// wrapping modulo: mod(-1, 3) === 2 (the `%` operator keeps the sign)
|
|
71
|
+
mod: (value: unknown, divisor: unknown) => {
|
|
72
|
+
const d = toNumber(divisor);
|
|
73
|
+
if (d === 0) return NaN;
|
|
74
|
+
return ((toNumber(value) % d) + d) % d;
|
|
75
|
+
},
|
|
76
|
+
pow: (base: unknown, exponent: unknown) =>
|
|
77
|
+
toNumber(base) ** toNumber(exponent),
|
|
78
|
+
sqrt: (value: unknown) => Math.sqrt(toNumber(value)),
|
|
79
|
+
// percentage(0.25) → "25%"
|
|
80
|
+
percentage: (fraction: unknown) => `${toNumber(fraction) * 100}%`,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const list: Vars = {
|
|
84
|
+
count: (items: unknown, path?: Path, value?: unknown) => {
|
|
85
|
+
const all = toList(items);
|
|
86
|
+
if (path == null) return all.length;
|
|
87
|
+
return all.filter((item) =>
|
|
88
|
+
value === undefined
|
|
89
|
+
? !isEmptyValue(pathval(item, path))
|
|
90
|
+
: same(pathval(item, path), value)
|
|
91
|
+
).length;
|
|
92
|
+
},
|
|
93
|
+
find: (items: unknown, path: Path, value: unknown) =>
|
|
94
|
+
toList(items).find((item) => same(pathval(item, path), value)),
|
|
95
|
+
filter: (items: unknown, path: Path, value?: unknown) =>
|
|
96
|
+
toList(items).filter((item) =>
|
|
97
|
+
value === undefined
|
|
98
|
+
? !isEmptyValue(pathval(item, path))
|
|
99
|
+
: same(pathval(item, path), value)
|
|
100
|
+
),
|
|
101
|
+
reject: (items: unknown, path: Path, value?: unknown) =>
|
|
102
|
+
toList(items).filter((item) =>
|
|
103
|
+
value === undefined
|
|
104
|
+
? isEmptyValue(pathval(item, path))
|
|
105
|
+
: !same(pathval(item, path), value)
|
|
106
|
+
),
|
|
107
|
+
pluck: (items: unknown, path: Path) =>
|
|
108
|
+
toList(items).map((item) => pathval(item, path)),
|
|
109
|
+
"sort-by": (items: unknown, path?: Path, direction: unknown = "asc") => {
|
|
110
|
+
const desc = String(direction).toLowerCase() === "desc";
|
|
111
|
+
const collator = new Intl.Collator(undefined, { numeric: true });
|
|
112
|
+
return toList(items)
|
|
113
|
+
.slice()
|
|
114
|
+
.sort((a, b) => {
|
|
115
|
+
const x = pathval(a, path);
|
|
116
|
+
const y = pathval(b, path);
|
|
117
|
+
const order =
|
|
118
|
+
typeof x === "number" && typeof y === "number"
|
|
119
|
+
? x - y
|
|
120
|
+
: x == null || y == null
|
|
121
|
+
? (x == null ? 1 : 0) - (y == null ? 1 : 0)
|
|
122
|
+
: collator.compare(String(x), String(y));
|
|
123
|
+
return desc ? -order : order;
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
sum: (items: unknown, path?: Path) =>
|
|
127
|
+
toList(items).reduce<number>(
|
|
128
|
+
(total, item) => total + toNumber(pathval(item, path)),
|
|
129
|
+
0
|
|
130
|
+
),
|
|
131
|
+
// range(3) → [0, 1, 2]; range(1, 4) → [1, 2, 3]; range(0, 10, 5) → [0, 5]
|
|
132
|
+
range: (start: unknown, end?: unknown, step: unknown = 1) => {
|
|
133
|
+
const from = end === undefined ? 0 : toNumber(start);
|
|
134
|
+
const to = end === undefined ? toNumber(start) : toNumber(end);
|
|
135
|
+
const by = Math.abs(toNumber(step)) || 1;
|
|
136
|
+
const out: number[] = [];
|
|
137
|
+
if (from <= to) for (let n = from; n < to; n += by) out.push(n);
|
|
138
|
+
else for (let n = from; n > to; n -= by) out.push(n);
|
|
139
|
+
return out;
|
|
140
|
+
},
|
|
141
|
+
unique: (items: unknown, path?: Path) => {
|
|
142
|
+
const seen = new Set<unknown>();
|
|
143
|
+
return toList(items).filter((item) => {
|
|
144
|
+
const key = path == null ? item : pathval(item, path);
|
|
145
|
+
if (seen.has(key)) return false;
|
|
146
|
+
seen.add(key);
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
// group-by(list, "status") → { open: [...], done: [...] }
|
|
151
|
+
"group-by": (items: unknown, path: Path) => {
|
|
152
|
+
const groups: Record<string, unknown[]> = {};
|
|
153
|
+
toList(items).forEach((item) => {
|
|
154
|
+
const key = String(pathval(item, path) ?? "");
|
|
155
|
+
(groups[key] ??= []).push(item);
|
|
156
|
+
});
|
|
157
|
+
return groups;
|
|
158
|
+
},
|
|
159
|
+
first: (items: unknown) => toList(items)[0],
|
|
160
|
+
last: (items: unknown) => toList(items).at(-1),
|
|
161
|
+
reverse: (items: unknown) => toList(items).slice().reverse(),
|
|
162
|
+
// drops null / undefined / "" / [] / {}
|
|
163
|
+
compact: (items: unknown) => toList(items).filter((v) => !isEmptyValue(v)),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const map: Vars = {
|
|
167
|
+
get: (source: unknown, path: Path, fallback?: unknown) => {
|
|
168
|
+
const value = pathval(source, path);
|
|
169
|
+
return value === undefined ? fallback : value;
|
|
170
|
+
},
|
|
171
|
+
"has-key": (source: unknown, path: Path) =>
|
|
172
|
+
pathval(source, path) !== undefined,
|
|
173
|
+
keys: (source: unknown) => (isMap(source) ? Object.keys(source) : []),
|
|
174
|
+
values: (source: unknown) => (isMap(source) ? Object.values(source) : []),
|
|
175
|
+
// entries(map) → [(key: "a", value: 1), …] — iterate() rows with `item.key` / `item.value`
|
|
176
|
+
entries: (source: unknown) =>
|
|
177
|
+
isMap(source)
|
|
178
|
+
? Object.entries(source).map(([key, value]) => ({ key, value }))
|
|
179
|
+
: [],
|
|
180
|
+
merge: (...sources: unknown[]) => Object.assign({}, ...sources.filter(isMap)),
|
|
181
|
+
pick: (source: unknown, ...keys: unknown[]) => {
|
|
182
|
+
if (!isMap(source)) return {};
|
|
183
|
+
const wanted = keys.flat().map(String);
|
|
184
|
+
return Object.fromEntries(
|
|
185
|
+
Object.entries(source).filter(([key]) => wanted.includes(key))
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
omit: (source: unknown, ...keys: unknown[]) => {
|
|
189
|
+
if (!isMap(source)) return {};
|
|
190
|
+
const dropped = keys.flat().map(String);
|
|
191
|
+
return Object.fromEntries(
|
|
192
|
+
Object.entries(source).filter(([key]) => !dropped.includes(key))
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const HTML_ESCAPES: Record<string, string> = {
|
|
198
|
+
"&": "&",
|
|
199
|
+
"<": "<",
|
|
200
|
+
">": ">",
|
|
201
|
+
'"': """,
|
|
202
|
+
"'": "'",
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const string: Vars = {
|
|
206
|
+
// plural(2, (one: "item", other: "items")) → "items"; Intl.PluralRules categories
|
|
207
|
+
plural: (count: unknown, forms: unknown, locale?: unknown) => {
|
|
208
|
+
if (!isMap(forms)) return "";
|
|
209
|
+
const n = toNumber(count);
|
|
210
|
+
const category = new Intl.PluralRules(
|
|
211
|
+
typeof locale === "string" ? locale : undefined
|
|
212
|
+
).select(n);
|
|
213
|
+
const form = forms[category] ?? forms.other ?? forms.one ?? "";
|
|
214
|
+
return String(form).replace(/#/g, String(n));
|
|
215
|
+
},
|
|
216
|
+
"escape-html": (value: unknown) =>
|
|
217
|
+
value == null
|
|
218
|
+
? ""
|
|
219
|
+
: String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]),
|
|
220
|
+
truncate: (value: unknown, max: unknown, suffix: unknown = "…") => {
|
|
221
|
+
const text = value == null ? "" : String(value);
|
|
222
|
+
const limit = Math.max(0, Math.floor(toNumber(max)));
|
|
223
|
+
if (text.length <= limit) return text;
|
|
224
|
+
const tail = String(suffix);
|
|
225
|
+
return text.slice(0, Math.max(0, limit - tail.length)) + tail;
|
|
226
|
+
},
|
|
227
|
+
capitalize: (value: unknown) => {
|
|
228
|
+
const text = value == null ? "" : String(value);
|
|
229
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
230
|
+
},
|
|
231
|
+
slugify: (value: unknown) =>
|
|
232
|
+
(value == null ? "" : String(value))
|
|
233
|
+
.normalize("NFKD")
|
|
234
|
+
.replace(/[̀-ͯ]/g, "")
|
|
235
|
+
.toLowerCase()
|
|
236
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
237
|
+
.replace(/^-+|-+$/g, ""),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const UNIT_MS: Record<string, number> = {
|
|
241
|
+
second: 1000,
|
|
242
|
+
seconds: 1000,
|
|
243
|
+
minute: 60_000,
|
|
244
|
+
minutes: 60_000,
|
|
245
|
+
hour: 3_600_000,
|
|
246
|
+
hours: 3_600_000,
|
|
247
|
+
day: 86_400_000,
|
|
248
|
+
days: 86_400_000,
|
|
249
|
+
week: 604_800_000,
|
|
250
|
+
weeks: 604_800_000,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const toDate = (value: unknown): Date | null => {
|
|
254
|
+
if (value instanceof Date)
|
|
255
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
256
|
+
if (value == null || value === "") return null;
|
|
257
|
+
const parsed = new Date(value as string | number);
|
|
258
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const date: Vars = {
|
|
262
|
+
// parse("2026-09-13") → Date, or null when unparseable
|
|
263
|
+
parse: (value: unknown) => toDate(value),
|
|
264
|
+
"is-valid": (value: unknown) => toDate(value) !== null,
|
|
265
|
+
// format(value, "en-GB", (dateStyle: "medium")) → "13 Sept 2026"
|
|
266
|
+
format: (value: unknown, locale?: unknown, options?: unknown) => {
|
|
267
|
+
const parsed = toDate(value);
|
|
268
|
+
if (!parsed) return "";
|
|
269
|
+
return new Intl.DateTimeFormat(
|
|
270
|
+
typeof locale === "string" ? locale : undefined,
|
|
271
|
+
isMap(options) ? (options as Intl.DateTimeFormatOptions) : undefined
|
|
272
|
+
).format(parsed);
|
|
273
|
+
},
|
|
274
|
+
// add(value, 3, "days") → Date; months / years step the calendar
|
|
275
|
+
add: (value: unknown, amount: unknown, unit: unknown = "days") => {
|
|
276
|
+
const parsed = toDate(value);
|
|
277
|
+
if (!parsed) return null;
|
|
278
|
+
const n = toNumber(amount);
|
|
279
|
+
const name = String(unit).toLowerCase();
|
|
280
|
+
const next = new Date(parsed.getTime());
|
|
281
|
+
if (name.startsWith("month")) next.setMonth(next.getMonth() + n);
|
|
282
|
+
else if (name.startsWith("year")) next.setFullYear(next.getFullYear() + n);
|
|
283
|
+
else next.setTime(next.getTime() + n * (UNIT_MS[name] ?? UNIT_MS.days));
|
|
284
|
+
return next;
|
|
285
|
+
},
|
|
286
|
+
// diff(later, earlier, "days") → whole units between two dates
|
|
287
|
+
diff: (a: unknown, b: unknown, unit: unknown = "days") => {
|
|
288
|
+
const x = toDate(a);
|
|
289
|
+
const y = toDate(b);
|
|
290
|
+
if (!x || !y) return null;
|
|
291
|
+
const name = String(unit).toLowerCase();
|
|
292
|
+
if (name.startsWith("month") || name.startsWith("year")) {
|
|
293
|
+
const months =
|
|
294
|
+
(x.getFullYear() - y.getFullYear()) * 12 +
|
|
295
|
+
(x.getMonth() - y.getMonth());
|
|
296
|
+
return name.startsWith("year") ? Math.trunc(months / 12) : months;
|
|
297
|
+
}
|
|
298
|
+
return Math.trunc(
|
|
299
|
+
(x.getTime() - y.getTime()) / (UNIT_MS[name] ?? UNIT_MS.days)
|
|
300
|
+
);
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const url: Vars = {
|
|
305
|
+
// query((q: "a b", page: 2, empty: none)) → "q=a+b&page=2"
|
|
306
|
+
query: (params: unknown) => {
|
|
307
|
+
const search = new URLSearchParams();
|
|
308
|
+
if (isMap(params)) {
|
|
309
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
310
|
+
if (value == null || value === "") return;
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
value.forEach((v) => v != null && search.append(key, String(v)));
|
|
313
|
+
} else {
|
|
314
|
+
search.set(key, String(value));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return search.toString();
|
|
319
|
+
},
|
|
320
|
+
// params("/search?q=a&page=2") → (q: "a", page: "2"); repeated keys become arrays
|
|
321
|
+
params: (source: unknown) => {
|
|
322
|
+
const text = source == null ? "" : String(source);
|
|
323
|
+
const index = text.indexOf("?");
|
|
324
|
+
const search = new URLSearchParams(
|
|
325
|
+
index >= 0 ? text.slice(index + 1) : text.replace(/^#/, "")
|
|
326
|
+
);
|
|
327
|
+
const out: Record<string, string | string[]> = {};
|
|
328
|
+
search.forEach((value, key) => {
|
|
329
|
+
const existing = out[key];
|
|
330
|
+
out[key] =
|
|
331
|
+
existing === undefined
|
|
332
|
+
? value
|
|
333
|
+
: Array.isArray(existing)
|
|
334
|
+
? [...existing, value]
|
|
335
|
+
: [existing, value];
|
|
336
|
+
});
|
|
337
|
+
return out;
|
|
338
|
+
},
|
|
339
|
+
encode: (value: unknown) =>
|
|
340
|
+
value == null ? "" : encodeURIComponent(String(value)),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const util: Vars = {
|
|
344
|
+
// the first value that is not null / undefined (`or` also skips 0, "" and false)
|
|
345
|
+
coalesce: (...values: unknown[]) => values.find((v) => v != null),
|
|
346
|
+
"is-empty": (value: unknown) => isEmptyValue(value),
|
|
347
|
+
"type-of": (value: unknown) =>
|
|
348
|
+
value === null
|
|
349
|
+
? "null"
|
|
350
|
+
: Array.isArray(value)
|
|
351
|
+
? "list"
|
|
352
|
+
: value instanceof Date
|
|
353
|
+
? "date"
|
|
354
|
+
: typeof value === "object"
|
|
355
|
+
? "map"
|
|
356
|
+
: typeof value,
|
|
357
|
+
"to-json": (value: unknown, indent?: unknown) =>
|
|
358
|
+
value === undefined
|
|
359
|
+
? ""
|
|
360
|
+
: JSON.stringify(
|
|
361
|
+
value,
|
|
362
|
+
null,
|
|
363
|
+
indent == null ? undefined : toNumber(indent)
|
|
364
|
+
),
|
|
365
|
+
"from-json": (text: unknown) => {
|
|
366
|
+
if (typeof text !== "string") return null;
|
|
367
|
+
try {
|
|
368
|
+
return JSON.parse(text);
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/** `quark:` module name → its exports (documented in `language.ts` `BUILTIN_MODULES`). */
|
|
376
|
+
export const QUARK_MODULES: Readonly<Record<string, Vars>> = {
|
|
377
|
+
math,
|
|
378
|
+
list,
|
|
379
|
+
map,
|
|
380
|
+
string,
|
|
381
|
+
date,
|
|
382
|
+
url,
|
|
383
|
+
util,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
export const BUILTIN_MODULE_SCHEME = "quark:";
|
|
387
|
+
|
|
388
|
+
/** `"quark:math"` → `"math"`; `null` for other urls. */
|
|
389
|
+
export const builtinModuleName = (url: string): string | null =>
|
|
390
|
+
url.startsWith(BUILTIN_MODULE_SCHEME)
|
|
391
|
+
? url.slice(BUILTIN_MODULE_SCHEME.length)
|
|
392
|
+
: null;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The exports of a `quark:` url, or `undefined` when the name is unknown
|
|
396
|
+
* (`loadUseModules` logs and skips it like a failed import).
|
|
397
|
+
*/
|
|
398
|
+
export const resolveBuiltinModule = (url: string): Vars | undefined => {
|
|
399
|
+
const name = builtinModuleName(url);
|
|
400
|
+
return name === null ? undefined : QUARK_MODULES[name];
|
|
401
|
+
};
|