@rettangoli/fe 0.0.14 → 1.0.0-rc1
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/README.md +24 -8
- package/package.json +9 -3
- package/src/cli/blank/blank.handlers.js +5 -2
- package/src/cli/blank/blank.schema.yaml +11 -0
- package/src/cli/blank/blank.view.yaml +0 -11
- package/src/cli/build.js +55 -23
- package/src/cli/check.js +53 -0
- package/src/cli/contracts.js +143 -0
- package/src/cli/index.js +5 -11
- package/src/cli/scaffold.js +6 -0
- package/src/cli/watch.js +3 -2
- package/src/core/contracts/componentFiles.js +119 -0
- package/src/core/runtime/componentOrchestrator.js +156 -0
- package/src/core/runtime/componentRuntime.js +54 -0
- package/src/core/runtime/constants.js +27 -0
- package/src/core/runtime/events.js +191 -0
- package/src/core/runtime/globalListeners.js +87 -0
- package/src/core/runtime/lifecycle.js +124 -0
- package/src/core/runtime/methods.js +40 -0
- package/src/core/runtime/payload.js +3 -0
- package/src/core/runtime/props.js +79 -0
- package/src/core/runtime/refs.js +70 -0
- package/src/core/runtime/store.js +42 -0
- package/src/core/schema/validateSchemaContract.js +26 -0
- package/src/core/style/yamlToCss.js +44 -0
- package/src/core/view/bindings.js +189 -0
- package/src/core/view/refs.js +234 -0
- package/src/createComponent.js +37 -518
- package/src/parser.js +83 -249
- package/src/web/componentDom.js +49 -0
- package/src/web/componentUpdateHook.js +43 -0
- package/src/web/createWebComponentClass.js +150 -0
- package/src/web/scheduler.js +6 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const toKebabCase = (value) => {
|
|
2
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export const toCamelCase = (value) => {
|
|
6
|
+
return value.replace(/-([a-z0-9])/g, (_, chr) => chr.toUpperCase());
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const normalizeAttributeValue = (value) => {
|
|
10
|
+
if (value === null || value === undefined) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return value === "" ? true : value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const readPropFallbackFromAttributes = (source, propName) => {
|
|
17
|
+
const directAttrValue = source.getAttribute(propName);
|
|
18
|
+
if (directAttrValue !== null) {
|
|
19
|
+
return normalizeAttributeValue(directAttrValue);
|
|
20
|
+
}
|
|
21
|
+
const kebabPropName = toKebabCase(propName);
|
|
22
|
+
if (kebabPropName !== propName) {
|
|
23
|
+
const kebabAttrValue = source.getAttribute(kebabPropName);
|
|
24
|
+
if (kebabAttrValue !== null) {
|
|
25
|
+
return normalizeAttributeValue(kebabAttrValue);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const createPropsProxy = (source, allowedKeys) => {
|
|
32
|
+
const allowed = new Set(allowedKeys);
|
|
33
|
+
return new Proxy(
|
|
34
|
+
{},
|
|
35
|
+
{
|
|
36
|
+
get(_, prop) {
|
|
37
|
+
if (typeof prop === "string" && allowed.has(prop)) {
|
|
38
|
+
const propValue = source[prop];
|
|
39
|
+
if (propValue !== undefined) {
|
|
40
|
+
return propValue;
|
|
41
|
+
}
|
|
42
|
+
return readPropFallbackFromAttributes(source, prop);
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
},
|
|
46
|
+
set() {
|
|
47
|
+
throw new Error("Cannot assign to read-only proxy");
|
|
48
|
+
},
|
|
49
|
+
defineProperty() {
|
|
50
|
+
throw new Error("Cannot define properties on read-only proxy");
|
|
51
|
+
},
|
|
52
|
+
deleteProperty() {
|
|
53
|
+
throw new Error("Cannot delete properties from read-only proxy");
|
|
54
|
+
},
|
|
55
|
+
has(_, prop) {
|
|
56
|
+
return typeof prop === "string" && allowed.has(prop);
|
|
57
|
+
},
|
|
58
|
+
ownKeys() {
|
|
59
|
+
return [...allowed];
|
|
60
|
+
},
|
|
61
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
62
|
+
if (typeof prop === "string" && allowed.has(prop)) {
|
|
63
|
+
return {
|
|
64
|
+
configurable: true,
|
|
65
|
+
enumerable: true,
|
|
66
|
+
get: () => {
|
|
67
|
+
const propValue = source[prop];
|
|
68
|
+
if (propValue !== undefined) {
|
|
69
|
+
return propValue;
|
|
70
|
+
}
|
|
71
|
+
return readPropFallbackFromAttributes(source, prop);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createRefMatchers, resolveBestRefMatcher } from "../view/refs.js";
|
|
2
|
+
|
|
3
|
+
export const createRuntimeRefMatchers = (refs) => createRefMatchers(refs);
|
|
4
|
+
|
|
5
|
+
const getVNodeClassNames = (vNode) => {
|
|
6
|
+
const classNames = [];
|
|
7
|
+
|
|
8
|
+
const classObject = vNode?.data?.class;
|
|
9
|
+
if (classObject && typeof classObject === "object") {
|
|
10
|
+
Object.entries(classObject).forEach(([className, enabled]) => {
|
|
11
|
+
if (enabled) {
|
|
12
|
+
classNames.push(className);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const classAttr = vNode?.data?.attrs?.class;
|
|
18
|
+
if (typeof classAttr === "string") {
|
|
19
|
+
classAttr
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.forEach((className) => classNames.push(className));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return [...new Set(classNames)];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const matchesConfiguredRef = ({ id, classNames = [], refMatchers }) => {
|
|
29
|
+
if (refMatchers.length === 0) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return Boolean(resolveBestRefMatcher({
|
|
34
|
+
elementIdForRefs: id,
|
|
35
|
+
classNames,
|
|
36
|
+
refMatchers,
|
|
37
|
+
}));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const collectRefElements = ({ rootVNode, refs }) => {
|
|
41
|
+
const ids = {};
|
|
42
|
+
const refMatchers = createRuntimeRefMatchers(refs);
|
|
43
|
+
|
|
44
|
+
const findRefElements = (vNode) => {
|
|
45
|
+
if (!vNode || typeof vNode !== "object") {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const id = vNode?.data?.attrs?.id;
|
|
50
|
+
const classNames = getVNodeClassNames(vNode);
|
|
51
|
+
const bestMatchRef = resolveBestRefMatcher({
|
|
52
|
+
elementIdForRefs: id,
|
|
53
|
+
classNames,
|
|
54
|
+
refMatchers,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (vNode.elm && bestMatchRef) {
|
|
58
|
+
const key = id || bestMatchRef.refKey;
|
|
59
|
+
ids[key] = vNode.elm;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (Array.isArray(vNode.children)) {
|
|
63
|
+
vNode.children.forEach(findRefElements);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
findRefElements(rootVNode);
|
|
68
|
+
|
|
69
|
+
return ids;
|
|
70
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { produce } from "immer";
|
|
2
|
+
|
|
3
|
+
import { isObjectPayload } from "./payload.js";
|
|
4
|
+
|
|
5
|
+
export const bindStore = (store, props, constants) => {
|
|
6
|
+
const { createInitialState, ...selectorsAndActions } = store;
|
|
7
|
+
const selectors = {};
|
|
8
|
+
const actions = {};
|
|
9
|
+
let currentState = {};
|
|
10
|
+
|
|
11
|
+
if (createInitialState) {
|
|
12
|
+
currentState = createInitialState({ props, constants });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Object.entries(selectorsAndActions).forEach(([key, fn]) => {
|
|
16
|
+
if (key.startsWith("select")) {
|
|
17
|
+
selectors[key] = (...args) => {
|
|
18
|
+
return fn({ state: currentState, props, constants }, ...args);
|
|
19
|
+
};
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
actions[key] = (payload = {}) => {
|
|
24
|
+
const normalizedPayload = payload === undefined ? {} : payload;
|
|
25
|
+
if (!isObjectPayload(normalizedPayload)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`[Store] Action '${key}' expects payload to be an object.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
currentState = produce(currentState, (draft) => {
|
|
31
|
+
return fn({ state: draft, props, constants }, normalizedPayload);
|
|
32
|
+
});
|
|
33
|
+
return currentState;
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
getState: () => currentState,
|
|
39
|
+
...actions,
|
|
40
|
+
...selectors,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const validateSchemaContract = ({ schema, methodExports = [] }) => {
|
|
2
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
3
|
+
throw new Error("RTGL-SCHEMA-001: componentName is required.");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (typeof schema.componentName !== "string" || schema.componentName.trim() === "") {
|
|
7
|
+
throw new Error("RTGL-SCHEMA-001: componentName is required.");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (Object.prototype.hasOwnProperty.call(schema, "attrsSchema")) {
|
|
11
|
+
throw new Error("RTGL-SCHEMA-002: attrsSchema is not supported.");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (Array.isArray(schema.methods)) {
|
|
15
|
+
for (const method of schema.methods) {
|
|
16
|
+
if (!method || typeof method.name !== "string" || method.name.trim() === "") {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (!methodExports.includes(method.name)) {
|
|
20
|
+
throw new Error(`RTGL-SCHEMA-003: method '${method.name}' missing in .methods.js exports.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const yamlToCss = (_elementName, styleObject) => {
|
|
2
|
+
if (!styleObject || typeof styleObject !== "object") {
|
|
3
|
+
return "";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let css = ``;
|
|
7
|
+
const convertPropertiesToCss = (properties) => {
|
|
8
|
+
return Object.entries(properties)
|
|
9
|
+
.map(([property, value]) => ` ${property}: ${value};`)
|
|
10
|
+
.join("\n");
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const processSelector = (selector, rules) => {
|
|
14
|
+
if (typeof rules !== "object" || rules === null) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (selector.startsWith("@")) {
|
|
19
|
+
const nestedCss = Object.entries(rules)
|
|
20
|
+
.map(([nestedSelector, nestedRules]) => {
|
|
21
|
+
const nestedProperties = convertPropertiesToCss(nestedRules);
|
|
22
|
+
return ` ${nestedSelector} {\n${nestedProperties
|
|
23
|
+
.split("\n")
|
|
24
|
+
.map((line) => (line ? ` ${line}` : ""))
|
|
25
|
+
.join("\n")}\n }`;
|
|
26
|
+
})
|
|
27
|
+
.join("\n");
|
|
28
|
+
|
|
29
|
+
return `${selector} {\n${nestedCss}\n}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const properties = convertPropertiesToCss(rules);
|
|
33
|
+
return `${selector} {\n${properties}\n}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
Object.entries(styleObject).forEach(([selector, rules]) => {
|
|
37
|
+
const selectorCss = processSelector(selector, rules);
|
|
38
|
+
if (selectorCss) {
|
|
39
|
+
css += (css ? "\n\n" : "") + selectorCss;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return css;
|
|
44
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const PROP_PREFIX = ":";
|
|
2
|
+
|
|
3
|
+
const lodashGet = (obj, path) => {
|
|
4
|
+
if (!path) return obj;
|
|
5
|
+
|
|
6
|
+
const parts = [];
|
|
7
|
+
let current = "";
|
|
8
|
+
let inBrackets = false;
|
|
9
|
+
let quoteChar = null;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < path.length; i++) {
|
|
12
|
+
const char = path[i];
|
|
13
|
+
|
|
14
|
+
if (!inBrackets && char === ".") {
|
|
15
|
+
if (current) {
|
|
16
|
+
parts.push(current);
|
|
17
|
+
current = "";
|
|
18
|
+
}
|
|
19
|
+
} else if (!inBrackets && char === "[") {
|
|
20
|
+
if (current) {
|
|
21
|
+
parts.push(current);
|
|
22
|
+
current = "";
|
|
23
|
+
}
|
|
24
|
+
inBrackets = true;
|
|
25
|
+
} else if (inBrackets && char === "]") {
|
|
26
|
+
if (current) {
|
|
27
|
+
if (
|
|
28
|
+
(current.startsWith('"') && current.endsWith('"'))
|
|
29
|
+
|| (current.startsWith("'") && current.endsWith("'"))
|
|
30
|
+
) {
|
|
31
|
+
parts.push(current.slice(1, -1));
|
|
32
|
+
} else {
|
|
33
|
+
const numValue = Number(current);
|
|
34
|
+
parts.push(Number.isNaN(numValue) ? current : numValue);
|
|
35
|
+
}
|
|
36
|
+
current = "";
|
|
37
|
+
}
|
|
38
|
+
inBrackets = false;
|
|
39
|
+
quoteChar = null;
|
|
40
|
+
} else if (inBrackets && (char === '"' || char === "'")) {
|
|
41
|
+
if (!quoteChar) {
|
|
42
|
+
quoteChar = char;
|
|
43
|
+
} else if (char === quoteChar) {
|
|
44
|
+
quoteChar = null;
|
|
45
|
+
}
|
|
46
|
+
current += char;
|
|
47
|
+
} else {
|
|
48
|
+
current += char;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (current) {
|
|
53
|
+
parts.push(current);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return parts.reduce((acc, part) => acc && acc[part], obj);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const toCamelCase = (value) => {
|
|
60
|
+
return value.replace(/-([a-z0-9])/g, (_, chr) => chr.toUpperCase());
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const parseNodeBindings = ({
|
|
64
|
+
attrsString = "",
|
|
65
|
+
viewData = {},
|
|
66
|
+
tagName,
|
|
67
|
+
isWebComponent,
|
|
68
|
+
}) => {
|
|
69
|
+
const attrs = {};
|
|
70
|
+
const props = {};
|
|
71
|
+
const assertSupportedBooleanToggleAttr = (attrName) => {
|
|
72
|
+
if (
|
|
73
|
+
attrName === "role"
|
|
74
|
+
|| attrName.startsWith("aria-")
|
|
75
|
+
|| attrName.startsWith("data-")
|
|
76
|
+
) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`[Parser] Invalid boolean attribute '?${attrName}'. Use normal binding for value-carrying attributes such as aria-*, data-*, and role.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const setComponentProp = (rawPropName, propValue, sourceLabel) => {
|
|
84
|
+
const normalizedPropName = toCamelCase(rawPropName);
|
|
85
|
+
if (!normalizedPropName) {
|
|
86
|
+
throw new Error(`[Parser] Invalid ${sourceLabel} prop name on '${tagName}'.`);
|
|
87
|
+
}
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(props, normalizedPropName)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`[Parser] Duplicate prop binding '${normalizedPropName}' on '${tagName}'. Use only one of 'name=value' or ':name=value'.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
props[normalizedPropName] = propValue;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (!attrsString) {
|
|
97
|
+
return { attrs, props };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const attrRegex = /(\S+?)=(?:\"([^\"]*)\"|\'([^\']*)\'|([^\s]+))/g;
|
|
101
|
+
let match;
|
|
102
|
+
const processedAttrs = new Set();
|
|
103
|
+
|
|
104
|
+
while ((match = attrRegex.exec(attrsString)) !== null) {
|
|
105
|
+
const rawBindingName = match[1];
|
|
106
|
+
const rawValue = match[2] || match[3] || match[4];
|
|
107
|
+
processedAttrs.add(rawBindingName);
|
|
108
|
+
|
|
109
|
+
if (rawBindingName.startsWith(".")) {
|
|
110
|
+
attrs[rawBindingName] = rawValue;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (rawBindingName.startsWith(PROP_PREFIX)) {
|
|
115
|
+
const propName = rawBindingName.substring(1);
|
|
116
|
+
let propValue = rawValue;
|
|
117
|
+
if (match[4] !== undefined) {
|
|
118
|
+
const valuePathName = match[4];
|
|
119
|
+
const resolvedPathValue = lodashGet(viewData, valuePathName);
|
|
120
|
+
if (resolvedPathValue !== undefined) {
|
|
121
|
+
propValue = resolvedPathValue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
setComponentProp(propName, propValue, "property-form");
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (rawBindingName.startsWith("?")) {
|
|
129
|
+
const attrName = rawBindingName.substring(1);
|
|
130
|
+
const attrValue = rawValue;
|
|
131
|
+
assertSupportedBooleanToggleAttr(attrName);
|
|
132
|
+
|
|
133
|
+
let evalValue;
|
|
134
|
+
if (attrValue === "true") {
|
|
135
|
+
evalValue = true;
|
|
136
|
+
} else if (attrValue === "false") {
|
|
137
|
+
evalValue = false;
|
|
138
|
+
} else {
|
|
139
|
+
evalValue = lodashGet(viewData, attrValue);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (evalValue) {
|
|
143
|
+
attrs[attrName] = "";
|
|
144
|
+
}
|
|
145
|
+
if (isWebComponent && attrName !== "id") {
|
|
146
|
+
setComponentProp(attrName, !!evalValue, "boolean attribute-form");
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
attrs[rawBindingName] = rawValue;
|
|
152
|
+
if (isWebComponent && rawBindingName !== "id") {
|
|
153
|
+
setComponentProp(rawBindingName, rawValue, "attribute-form");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let remainingAttrsString = attrsString;
|
|
158
|
+
const processedMatches = [];
|
|
159
|
+
let tempMatch;
|
|
160
|
+
const tempAttrRegex = /(\S+?)=(?:\"([^\"]*)\"|\'([^\']*)\'|([^\s]+))/g;
|
|
161
|
+
while ((tempMatch = tempAttrRegex.exec(attrsString)) !== null) {
|
|
162
|
+
processedMatches.push(tempMatch[0]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
processedMatches.forEach((processedMatch) => {
|
|
166
|
+
remainingAttrsString = remainingAttrsString.replace(processedMatch, " ");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const booleanAttrRegex = /\b(\S+?)(?=\s|$)/g;
|
|
170
|
+
let boolMatch;
|
|
171
|
+
while ((boolMatch = booleanAttrRegex.exec(remainingAttrsString)) !== null) {
|
|
172
|
+
const attrName = boolMatch[1];
|
|
173
|
+
if (attrName.startsWith(".")) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
!processedAttrs.has(attrName)
|
|
178
|
+
&& !attrName.startsWith(PROP_PREFIX)
|
|
179
|
+
&& !attrName.includes("=")
|
|
180
|
+
) {
|
|
181
|
+
attrs[attrName] = "";
|
|
182
|
+
if (isWebComponent && attrName !== "id") {
|
|
183
|
+
setComponentProp(attrName, true, "boolean attribute-form");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { attrs, props };
|
|
189
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
export const REF_ID_KEY_REGEX = /^[a-z][a-zA-Z0-9]*\*?$/;
|
|
2
|
+
export const REF_CLASS_KEY_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*\*?$/;
|
|
3
|
+
export const REF_ID_REGEX = /^[a-z][a-zA-Z0-9]*$/;
|
|
4
|
+
export const GLOBAL_REF_KEYS = new Set(["window", "document"]);
|
|
5
|
+
|
|
6
|
+
export const createRefMatchers = (refs) => {
|
|
7
|
+
return Object.entries(refs || {}).map(([refKey, refConfig]) => {
|
|
8
|
+
if (GLOBAL_REF_KEYS.has(refKey)) {
|
|
9
|
+
return {
|
|
10
|
+
refKey,
|
|
11
|
+
refConfig,
|
|
12
|
+
targetType: "global",
|
|
13
|
+
isWildcard: false,
|
|
14
|
+
prefix: refKey,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let targetType = "id";
|
|
19
|
+
let rawKey = refKey;
|
|
20
|
+
if (refKey.startsWith(".")) {
|
|
21
|
+
targetType = "class";
|
|
22
|
+
rawKey = refKey.slice(1);
|
|
23
|
+
} else if (refKey.startsWith("#")) {
|
|
24
|
+
targetType = "id";
|
|
25
|
+
rawKey = refKey.slice(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const reservedBaseKey = rawKey.endsWith("*") ? rawKey.slice(0, -1) : rawKey;
|
|
29
|
+
if (GLOBAL_REF_KEYS.has(reservedBaseKey)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[Parser] Invalid ref key '${refKey}'. Reserved global keys must be exactly 'window' or 'document'.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (targetType === "id" && !REF_ID_KEY_REGEX.test(rawKey)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[Parser] Invalid ref key '${refKey}'. Use camelCase IDs (optional '#', optional '*') or class refs with '.' prefix.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (targetType === "class" && !REF_CLASS_KEY_REGEX.test(rawKey)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`[Parser] Invalid ref key '${refKey}'. Class refs must start with '.' and use class-compatible names (optional '*').`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isWildcard = rawKey.endsWith("*");
|
|
47
|
+
const prefix = isWildcard ? rawKey.slice(0, -1) : rawKey;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
refKey,
|
|
51
|
+
refConfig,
|
|
52
|
+
targetType,
|
|
53
|
+
isWildcard,
|
|
54
|
+
prefix,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const validateElementIdForRefs = (elementIdForRefs) => {
|
|
60
|
+
if (!REF_ID_REGEX.test(elementIdForRefs)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[Parser] Invalid element id '${elementIdForRefs}' for refs. Use camelCase ids only. Kebab-case ids are not supported.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const matchByPrefix = ({ value, prefix, isWildcard }) => {
|
|
68
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (isWildcard) {
|
|
72
|
+
return value.startsWith(prefix);
|
|
73
|
+
}
|
|
74
|
+
return value === prefix;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const resolveBestRefMatcher = ({
|
|
78
|
+
elementIdForRefs,
|
|
79
|
+
classNames = [],
|
|
80
|
+
refMatchers,
|
|
81
|
+
}) => {
|
|
82
|
+
const candidates = [];
|
|
83
|
+
const normalizedClassNames = Array.isArray(classNames) ? classNames : [];
|
|
84
|
+
|
|
85
|
+
refMatchers.forEach((refMatcher) => {
|
|
86
|
+
if (refMatcher.targetType === "global") {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (refMatcher.targetType === "id") {
|
|
91
|
+
if (matchByPrefix({
|
|
92
|
+
value: elementIdForRefs,
|
|
93
|
+
prefix: refMatcher.prefix,
|
|
94
|
+
isWildcard: refMatcher.isWildcard,
|
|
95
|
+
})) {
|
|
96
|
+
candidates.push({
|
|
97
|
+
...refMatcher,
|
|
98
|
+
matchedValue: elementIdForRefs,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const matchingClassName = normalizedClassNames.find((className) => {
|
|
105
|
+
return matchByPrefix({
|
|
106
|
+
value: className,
|
|
107
|
+
prefix: refMatcher.prefix,
|
|
108
|
+
isWildcard: refMatcher.isWildcard,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
if (matchingClassName) {
|
|
112
|
+
candidates.push({
|
|
113
|
+
...refMatcher,
|
|
114
|
+
matchedValue: matchingClassName,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (candidates.length === 0) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
candidates.sort((a, b) => {
|
|
124
|
+
const aTypeRank = a.targetType === "id" ? 2 : 1;
|
|
125
|
+
const bTypeRank = b.targetType === "id" ? 2 : 1;
|
|
126
|
+
if (aTypeRank !== bTypeRank) {
|
|
127
|
+
return bTypeRank - aTypeRank;
|
|
128
|
+
}
|
|
129
|
+
if (!a.isWildcard && b.isWildcard) return -1;
|
|
130
|
+
if (a.isWildcard && !b.isWildcard) return 1;
|
|
131
|
+
return b.prefix.length - a.prefix.length;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return candidates[0];
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const assertBooleanEventOption = ({ optionName, optionValue, eventType, refKey }) => {
|
|
138
|
+
if (optionValue === undefined) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (typeof optionValue !== "boolean") {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`[Parser] Invalid '${optionName}' for event '${eventType}' on ref '${refKey}'. Expected boolean.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const assertNumberEventOption = ({ optionName, optionValue, eventType, refKey }) => {
|
|
149
|
+
if (optionValue === undefined) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
typeof optionValue !== "number"
|
|
154
|
+
|| Number.isNaN(optionValue)
|
|
155
|
+
|| !Number.isFinite(optionValue)
|
|
156
|
+
|| optionValue < 0
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`[Parser] Invalid '${optionName}' for event '${eventType}' on ref '${refKey}'. Expected non-negative number.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const validateEventConfig = ({ eventType, eventConfig, refKey }) => {
|
|
165
|
+
if (typeof eventConfig !== "object" || eventConfig === null) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`[Parser] Invalid event config for event '${eventType}' on ref '${refKey}'.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const hasDebounce = Object.prototype.hasOwnProperty.call(eventConfig, "debounce");
|
|
172
|
+
const hasThrottle = Object.prototype.hasOwnProperty.call(eventConfig, "throttle");
|
|
173
|
+
|
|
174
|
+
assertBooleanEventOption({
|
|
175
|
+
optionName: "preventDefault",
|
|
176
|
+
optionValue: eventConfig.preventDefault,
|
|
177
|
+
eventType,
|
|
178
|
+
refKey,
|
|
179
|
+
});
|
|
180
|
+
assertBooleanEventOption({
|
|
181
|
+
optionName: "stopPropagation",
|
|
182
|
+
optionValue: eventConfig.stopPropagation,
|
|
183
|
+
eventType,
|
|
184
|
+
refKey,
|
|
185
|
+
});
|
|
186
|
+
assertBooleanEventOption({
|
|
187
|
+
optionName: "stopImmediatePropagation",
|
|
188
|
+
optionValue: eventConfig.stopImmediatePropagation,
|
|
189
|
+
eventType,
|
|
190
|
+
refKey,
|
|
191
|
+
});
|
|
192
|
+
assertBooleanEventOption({
|
|
193
|
+
optionName: "targetOnly",
|
|
194
|
+
optionValue: eventConfig.targetOnly,
|
|
195
|
+
eventType,
|
|
196
|
+
refKey,
|
|
197
|
+
});
|
|
198
|
+
assertBooleanEventOption({
|
|
199
|
+
optionName: "once",
|
|
200
|
+
optionValue: eventConfig.once,
|
|
201
|
+
eventType,
|
|
202
|
+
refKey,
|
|
203
|
+
});
|
|
204
|
+
assertNumberEventOption({
|
|
205
|
+
optionName: "debounce",
|
|
206
|
+
optionValue: eventConfig.debounce,
|
|
207
|
+
eventType,
|
|
208
|
+
refKey,
|
|
209
|
+
});
|
|
210
|
+
assertNumberEventOption({
|
|
211
|
+
optionName: "throttle",
|
|
212
|
+
optionValue: eventConfig.throttle,
|
|
213
|
+
eventType,
|
|
214
|
+
refKey,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (hasDebounce && hasThrottle) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`[Parser] Event '${eventType}' on ref '${refKey}' cannot define both 'debounce' and 'throttle'.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (eventConfig.handler && eventConfig.action) {
|
|
224
|
+
throw new Error("Each listener can have handler or action but not both");
|
|
225
|
+
}
|
|
226
|
+
if (!eventConfig.handler && !eventConfig.action) {
|
|
227
|
+
throw new Error("Each listener must define either handler or action");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
hasDebounce,
|
|
232
|
+
hasThrottle,
|
|
233
|
+
};
|
|
234
|
+
};
|