@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/src/parser.js CHANGED
@@ -1,65 +1,25 @@
1
- import { render as jemplRender } from 'jempl';
1
+ import { parseAndRender as jemplParseAndRender, render as jemplRender } from "jempl";
2
2
 
3
3
  import { flattenArrays } from './common.js';
4
-
5
- const lodashGet = (obj, path) => {
6
- if (!path) return obj;
7
-
8
- // Parse path to handle both dot notation and bracket notation
9
- const parts = [];
10
- let current = '';
11
- let inBrackets = false;
12
- let quoteChar = null;
13
-
14
- for (let i = 0; i < path.length; i++) {
15
- const char = path[i];
16
-
17
- if (!inBrackets && char === '.') {
18
- if (current) {
19
- parts.push(current);
20
- current = '';
21
- }
22
- } else if (!inBrackets && char === '[') {
23
- if (current) {
24
- parts.push(current);
25
- current = '';
26
- }
27
- inBrackets = true;
28
- } else if (inBrackets && char === ']') {
29
- if (current) {
30
- // Remove quotes if present and add the key
31
- if ((current.startsWith('"') && current.endsWith('"')) ||
32
- (current.startsWith("'") && current.endsWith("'"))) {
33
- parts.push(current.slice(1, -1));
34
- } else {
35
- // Numeric index or unquoted string
36
- const numValue = Number(current);
37
- parts.push(isNaN(numValue) ? current : numValue);
38
- }
39
- current = '';
40
- }
41
- inBrackets = false;
42
- quoteChar = null;
43
- } else if (inBrackets && (char === '"' || char === "'")) {
44
- if (!quoteChar) {
45
- quoteChar = char;
46
- } else if (char === quoteChar) {
47
- quoteChar = null;
48
- }
49
- current += char;
50
- } else {
51
- current += char;
52
- }
53
- }
54
-
55
- if (current) {
56
- parts.push(current);
57
- }
58
-
59
- return parts.reduce((acc, part) => acc && acc[part], obj);
60
- };
61
-
62
- export const parseView = ({ h, template, viewData, refs, handlers }) => {
4
+ import { parseNodeBindings } from './core/view/bindings.js';
5
+ import {
6
+ createRefMatchers,
7
+ resolveBestRefMatcher,
8
+ validateElementIdForRefs,
9
+ } from './core/view/refs.js';
10
+ import {
11
+ createConfiguredEventListener,
12
+ getEventRateLimitState,
13
+ } from "./core/runtime/events.js";
14
+
15
+ export const parseView = ({
16
+ h,
17
+ template,
18
+ viewData,
19
+ refs,
20
+ handlers,
21
+ createComponentUpdateHook,
22
+ }) => {
63
23
  const result = jemplRender(template, viewData, {});
64
24
 
65
25
  // Flatten the array carefully to maintain structure
@@ -71,6 +31,7 @@ export const parseView = ({ h, template, viewData, refs, handlers }) => {
71
31
  refs,
72
32
  handlers,
73
33
  viewData,
34
+ createComponentUpdateHook,
74
35
  });
75
36
 
76
37
  const vdom = h("div", { style: { display: "contents" } }, childNodes);
@@ -91,13 +52,17 @@ export const createVirtualDom = ({
91
52
  items,
92
53
  refs = {},
93
54
  handlers = {},
94
- viewData = {}
55
+ viewData = {},
56
+ createComponentUpdateHook,
95
57
  }) => {
96
58
  if (!Array.isArray(items)) {
97
59
  console.error("Input to createVirtualDom must be an array.");
98
60
  return [h("div", {}, [])];
99
61
  }
100
62
 
63
+ const refMatchers = createRefMatchers(refs);
64
+ const hasIdRefMatchers = refMatchers.some((refMatcher) => refMatcher.targetType === "id");
65
+
101
66
  function processItems(currentItems, parentPath = "") {
102
67
  return currentItems
103
68
  .map((item, index) => {
@@ -156,70 +121,13 @@ export const createVirtualDom = ({
156
121
  const tagName = selector.split(/[.#]/)[0];
157
122
  const isWebComponent = tagName.includes("-");
158
123
 
159
- // 1. Parse attributes from attrsString
160
- const attrs = {}; // Ensure attrs is always an object
161
- const props = {};
162
- if (attrsString) {
163
- // First, handle attributes with values
164
- const attrRegex = /(\S+?)=(?:\"([^\"]*)\"|\'([^\']*)\'|([^\s]+))/g;
165
- let match;
166
- const processedAttrs = new Set();
167
-
168
- while ((match = attrRegex.exec(attrsString)) !== null) {
169
- processedAttrs.add(match[1]);
170
- if (match[1].startsWith(".")) {
171
- const propName = match[1].substring(1);
172
- const valuePathName = match[4];
173
- props[propName] = lodashGet(viewData, valuePathName);
174
- } else if (match[1].startsWith("?")) {
175
- // Handle conditional boolean attributes
176
- const attrName = match[1].substring(1);
177
- const attrValue = match[2] || match[3] || match[4];
178
-
179
- // Convert string values to boolean
180
- let evalValue;
181
- if (attrValue === "true") {
182
- evalValue = true;
183
- } else if (attrValue === "false") {
184
- evalValue = false;
185
- } else {
186
- // Try to get from viewData if it's not a literal boolean
187
- evalValue = lodashGet(viewData, attrValue);
188
- }
189
-
190
- // Only add attribute if value is truthy
191
- if (evalValue) {
192
- attrs[attrName] = "";
193
- }
194
- } else {
195
- attrs[match[1]] = match[2] || match[3] || match[4];
196
- }
197
- }
198
-
199
- // Then, handle boolean attributes without values
200
- // Remove all processed attribute-value pairs from the string first
201
- let remainingAttrsString = attrsString;
202
- const processedMatches = [];
203
- let tempMatch;
204
- const tempAttrRegex = /(\S+?)=(?:\"([^\"]*)\"|\'([^\']*)\'|([^\s]+))/g;
205
- while ((tempMatch = tempAttrRegex.exec(attrsString)) !== null) {
206
- processedMatches.push(tempMatch[0]);
207
- }
208
- // Remove all matched attribute=value pairs
209
- processedMatches.forEach(match => {
210
- remainingAttrsString = remainingAttrsString.replace(match, ' ');
211
- });
212
-
213
- const booleanAttrRegex = /\b(\S+?)(?=\s|$)/g;
214
- let boolMatch;
215
- while ((boolMatch = booleanAttrRegex.exec(remainingAttrsString)) !== null) {
216
- const attrName = boolMatch[1];
217
- // Skip if already processed or starts with . (prop) or contains =
218
- if (!processedAttrs.has(attrName) && !attrName.startsWith(".") && !attrName.includes("=")) {
219
- attrs[attrName] = "";
220
- }
221
- }
222
- }
124
+ // 1. Parse selector bindings into attrs/props.
125
+ const { attrs, props } = parseNodeBindings({
126
+ attrsString,
127
+ viewData,
128
+ tagName,
129
+ isWebComponent,
130
+ });
223
131
 
224
132
  // 2. Handle ID from selector string (e.g., tag#id)
225
133
  // If an 'id' was already parsed from attrsString (e.g. id=value), it takes precedence.
@@ -243,18 +151,21 @@ export const createVirtualDom = ({
243
151
  elementIdForRefs = tagName;
244
152
  }
245
153
 
154
+ const selectorClassMatches = selector.match(/\.([^.#]+)/g) || [];
155
+ const selectorClassNames = selectorClassMatches.map((classMatch) => classMatch.substring(1));
156
+ const attributeClassNames = typeof attrs.class === "string"
157
+ ? attrs.class.split(/\s+/).filter(Boolean)
158
+ : [];
159
+ const classNamesForRefs = [...new Set([...selectorClassNames, ...attributeClassNames])];
160
+
246
161
  // Extract classes and ID from selector (if not a web component)
247
162
  const classObj = Object.create(null); // Using Object.create(null) to avoid prototype issues
248
163
  let elementId = null;
249
164
 
250
165
  if (!isWebComponent) {
251
- const classMatches = selector.match(/\.([^.#]+)/g);
252
- if (classMatches) {
253
- classMatches.forEach((classMatch) => {
254
- const className = classMatch.substring(1);
255
- classObj[className] = true;
256
- });
257
- }
166
+ selectorClassNames.forEach((className) => {
167
+ classObj[className] = true;
168
+ });
258
169
 
259
170
  const idMatch = selector.match(/#([^.#\s]+)/);
260
171
  if (idMatch) {
@@ -283,78 +194,44 @@ export const createVirtualDom = ({
283
194
  // Apply event listeners
284
195
  const eventHandlers = Object.create(null);
285
196
 
286
- if (elementIdForRefs && refs) {
287
- const matchingRefKeys = [];
288
- Object.keys(refs).forEach((refKey) => {
289
- if (refKey.includes("*")) {
290
- const pattern =
291
- "^" +
292
- refKey
293
- .replace(/[.*+?^${}()|[\\\]\\]/g, "\\$&")
294
- .replace(/\\\*/g, ".*") +
295
- "$";
296
- try {
297
- const regex = new RegExp(pattern);
298
- if (regex.test(elementIdForRefs)) {
299
- matchingRefKeys.push(refKey);
300
- }
301
- } catch (e) {
302
- // Keep this warning for invalid regex patterns
303
- console.warn(
304
- `[Parser] Invalid regex pattern created from refKey '${refKey}': ${pattern}`,
305
- e,
306
- );
307
- }
308
- } else {
309
- if (elementIdForRefs === refKey) {
310
- matchingRefKeys.push(refKey);
311
- }
312
- }
197
+ if (refMatchers.length > 0) {
198
+ if (hasIdRefMatchers && elementIdForRefs) {
199
+ validateElementIdForRefs(elementIdForRefs);
200
+ }
201
+ const bestMatchRef = resolveBestRefMatcher({
202
+ elementIdForRefs,
203
+ classNames: classNamesForRefs,
204
+ refMatchers,
313
205
  });
314
206
 
315
- if (matchingRefKeys.length > 0) {
316
- matchingRefKeys.sort((a, b) => {
317
- const aIsExact = !a.includes("*");
318
- const bIsExact = !b.includes("*");
319
- if (aIsExact && !bIsExact) return -1;
320
- if (!aIsExact && bIsExact) return 1;
321
- return b.length - a.length;
322
- });
323
-
324
- const bestMatchRefKey = matchingRefKeys[0];
207
+ if (bestMatchRef) {
208
+ const bestMatchRefKey = bestMatchRef.refKey;
209
+ const matchIdentity = bestMatchRef.matchedValue || elementIdForRefs || bestMatchRefKey;
325
210
 
326
- if (refs[bestMatchRefKey] && refs[bestMatchRefKey].eventListeners) {
327
- const eventListeners = refs[bestMatchRefKey].eventListeners;
211
+ if (bestMatchRef.refConfig && bestMatchRef.refConfig.eventListeners) {
212
+ const eventListeners = bestMatchRef.refConfig.eventListeners;
213
+ const eventRateLimitState = getEventRateLimitState(handlers);
328
214
  Object.entries(eventListeners).forEach(
329
215
  ([eventType, eventConfig]) => {
330
- if (eventConfig.handler && eventConfig.action) {
331
- throw new Error('Each listener can have hanlder or action but not both')
332
- }
333
-
334
- if (eventConfig.action) {
335
- eventHandlers[eventType] = (event) => {
336
- handlers.handleCallStoreAction({
337
- ...eventConfig.payload,
338
- _event: event,
339
- _action: eventConfig.action,
340
- })
341
- }
216
+ const stateKey = `${bestMatchRefKey}:${matchIdentity}:${eventType}`;
217
+ const listener = createConfiguredEventListener({
218
+ eventType,
219
+ eventConfig,
220
+ refKey: bestMatchRefKey,
221
+ handlers,
222
+ eventRateLimitState,
223
+ stateKey,
224
+ parseAndRenderFn: jemplParseAndRender,
225
+ onMissingHandler: (missingHandlerName) => {
226
+ console.warn(
227
+ `[Parser] Handler '${missingHandlerName}' for refKey '${bestMatchRefKey}' (matching '${matchIdentity}') is referenced but not found in available handlers.`,
228
+ );
229
+ },
230
+ });
231
+ if (!listener) {
342
232
  return;
343
233
  }
344
-
345
- if (eventConfig.handler && handlers[eventConfig.handler]) {
346
- eventHandlers[eventType] = (event) => {
347
- handlers[eventConfig.handler]({
348
- ...eventConfig.payload,
349
- _event: event,
350
- });
351
- };
352
- } else if (eventConfig.handler) {
353
- // Keep this warning for missing handlers
354
- console.warn(
355
- `[Parser] Handler '${eventConfig.handler}' for refKey '${bestMatchRefKey}' (matching elementId '${elementIdForRefs}') is referenced but not found in available handlers.`,
356
- );
357
- }
234
+ eventHandlers[eventType] = listener;
358
235
  },
359
236
  );
360
237
  }
@@ -396,58 +273,15 @@ export const createVirtualDom = ({
396
273
  snabbdomData.props = props;
397
274
  }
398
275
 
399
- // For web components, add a hook to detect prop and attr changes
400
- if (isWebComponent) {
401
- snabbdomData.hook = {
402
- update: (oldVnode, vnode) => {
403
- const oldProps = oldVnode.data?.props || {};
404
- const newProps = vnode.data?.props || {};
405
- const oldAttrs = oldVnode.data?.attrs || {};
406
- const newAttrs = vnode.data?.attrs || {};
407
-
408
- // Check if props have changed
409
- const propsChanged =
410
- JSON.stringify(oldProps) !== JSON.stringify(newProps);
411
-
412
- // Check if attrs have changed
413
- const attrsChanged =
414
- JSON.stringify(oldAttrs) !== JSON.stringify(newAttrs);
415
-
416
- if (propsChanged || attrsChanged) {
417
- // Set isDirty attribute and trigger re-render
418
- const element = vnode.elm;
419
- if (
420
- element &&
421
- element.render &&
422
- typeof element.render === "function"
423
- ) {
424
- element.setAttribute("isDirty", "true");
425
- requestAnimationFrame(() => {
426
- element.render();
427
- element.removeAttribute("isDirty");
428
- // Call the specific component's handleOnUpdate instead of the parent's onUpdate
429
- if (element.handlers && element.handlers.handleOnUpdate) {
430
- const deps = {
431
- ...(element.deps),
432
- store: element.store,
433
- render: element.render.bind(element),
434
- handlers: element.handlers,
435
- dispatchEvent: element.dispatchEvent.bind(element),
436
- refIds: element.refIds || {},
437
- getRefIds: () => element.refIds || {},
438
- };
439
- element.handlers.handleOnUpdate(deps, {
440
- oldProps,
441
- newProps,
442
- oldAttrs,
443
- newAttrs,
444
- });
445
- }
446
- });
447
- }
448
- }
449
- },
450
- };
276
+ // Hook behavior is injected so parser core stays environment-agnostic.
277
+ if (isWebComponent && typeof createComponentUpdateHook === "function") {
278
+ const componentHook = createComponentUpdateHook({
279
+ selector,
280
+ tagName,
281
+ });
282
+ if (componentHook) {
283
+ snabbdomData.hook = componentHook;
284
+ }
451
285
  }
452
286
 
453
287
  try {
@@ -0,0 +1,49 @@
1
+ const COMMON_LINK_STYLE_TEXT = `
2
+ a, a:link, a:visited, a:hover, a:active {
3
+ display: contents;
4
+ color: inherit;
5
+ text-decoration: none;
6
+ background: none;
7
+ border: none;
8
+ padding: 0;
9
+ margin: 0;
10
+ font: inherit;
11
+ cursor: pointer;
12
+ }
13
+ `;
14
+
15
+ export const initializeComponentDom = ({
16
+ host,
17
+ cssText,
18
+ createStyleSheet = () => new CSSStyleSheet(),
19
+ createElement = (tagName) => document.createElement(tagName),
20
+ }) => {
21
+ const shadow = host.attachShadow({ mode: "open" });
22
+
23
+ const commonStyleSheet = createStyleSheet();
24
+ commonStyleSheet.replaceSync(COMMON_LINK_STYLE_TEXT);
25
+
26
+ const adoptedStyleSheets = [commonStyleSheet];
27
+
28
+ if (cssText) {
29
+ const styleSheet = createStyleSheet();
30
+ styleSheet.replaceSync(cssText);
31
+ adoptedStyleSheets.push(styleSheet);
32
+ }
33
+
34
+ shadow.adoptedStyleSheets = adoptedStyleSheets;
35
+
36
+ const renderTarget = createElement("div");
37
+ renderTarget.style.cssText = "display: contents;";
38
+ shadow.appendChild(renderTarget);
39
+ if (!renderTarget.parentNode) {
40
+ host.appendChild(renderTarget);
41
+ }
42
+ host.style.display = "contents";
43
+
44
+ return {
45
+ shadow,
46
+ renderTarget,
47
+ adoptedStyleSheets,
48
+ };
49
+ };
@@ -0,0 +1,43 @@
1
+ import { scheduleFrame } from "./scheduler.js";
2
+
3
+ export const createWebComponentUpdateHook = ({
4
+ scheduleFrameFn = scheduleFrame,
5
+ } = {}) => {
6
+ return {
7
+ update: (oldVnode, vnode) => {
8
+ const oldProps = oldVnode.data?.props || {};
9
+ const newProps = vnode.data?.props || {};
10
+
11
+ const propsChanged = JSON.stringify(oldProps) !== JSON.stringify(newProps);
12
+ if (!propsChanged) {
13
+ return;
14
+ }
15
+
16
+ const element = vnode.elm;
17
+ if (!element || typeof element.render !== "function") {
18
+ return;
19
+ }
20
+
21
+ element.setAttribute("isDirty", "true");
22
+ scheduleFrameFn(() => {
23
+ element.render();
24
+ element.removeAttribute("isDirty");
25
+
26
+ if (element.handlers && element.handlers.handleOnUpdate) {
27
+ const deps = {
28
+ ...(element.deps),
29
+ store: element.store,
30
+ render: element.render.bind(element),
31
+ handlers: element.handlers,
32
+ dispatchEvent: element.dispatchEvent.bind(element),
33
+ refs: element.refIds || {},
34
+ };
35
+ element.handlers.handleOnUpdate(deps, {
36
+ oldProps,
37
+ newProps,
38
+ });
39
+ }
40
+ });
41
+ },
42
+ };
43
+ };
@@ -0,0 +1,150 @@
1
+ import { parseAndRender } from "jempl";
2
+ import { bindMethods } from "../core/runtime/methods.js";
3
+ import { bindStore } from "../core/runtime/store.js";
4
+ import { resolveConstants } from "../core/runtime/constants.js";
5
+ import { yamlToCss } from "../core/style/yamlToCss.js";
6
+ import {
7
+ runAttributeChangedComponentLifecycle,
8
+ runConnectedComponentLifecycle,
9
+ runDisconnectedComponentLifecycle,
10
+ runRenderComponentLifecycle,
11
+ } from "../core/runtime/componentOrchestrator.js";
12
+ import { createPropsProxy, toKebabCase } from "../core/runtime/props.js";
13
+ import {
14
+ buildObservedAttributes,
15
+ } from "../core/runtime/componentRuntime.js";
16
+ import { initializeComponentDom } from "./componentDom.js";
17
+ import { createWebComponentUpdateHook } from "./componentUpdateHook.js";
18
+ import { scheduleFrame } from "./scheduler.js";
19
+
20
+ export const createWebComponentClass = ({
21
+ elementName,
22
+ propsSchema,
23
+ propsSchemaKeys,
24
+ template,
25
+ refs,
26
+ styles,
27
+ handlers,
28
+ methods,
29
+ constants,
30
+ store,
31
+ patch,
32
+ h,
33
+ deps,
34
+ }) => {
35
+ class BaseComponent extends HTMLElement {
36
+ elementName;
37
+ styles;
38
+ h;
39
+ store;
40
+ props;
41
+ propsSchema;
42
+ template;
43
+ handlers;
44
+ methods;
45
+ constants;
46
+ transformedHandlers = {};
47
+ refs;
48
+ refIds = {};
49
+ patch;
50
+ _unmountCallback;
51
+ _globalListenersCleanup;
52
+ _oldVNode;
53
+ deps;
54
+ _propsSchemaKeys = [];
55
+ cssText;
56
+
57
+ static get observedAttributes() {
58
+ return ["key"];
59
+ }
60
+
61
+ get viewData() {
62
+ let data = {};
63
+ if (this.store.selectViewData) {
64
+ data = this.store.selectViewData();
65
+ }
66
+ return data;
67
+ }
68
+
69
+ connectedCallback() {
70
+ const dom = initializeComponentDom({
71
+ host: this,
72
+ cssText: this.cssText,
73
+ });
74
+ this.shadow = dom.shadow;
75
+ this.renderTarget = dom.renderTarget;
76
+ runConnectedComponentLifecycle({
77
+ instance: this,
78
+ parseAndRenderFn: parseAndRender,
79
+ renderFn: this.render,
80
+ });
81
+ }
82
+
83
+ disconnectedCallback() {
84
+ runDisconnectedComponentLifecycle({
85
+ instance: this,
86
+ clearTimerFn: clearTimeout,
87
+ });
88
+ }
89
+
90
+ attributeChangedCallback(name, oldValue, newValue) {
91
+ runAttributeChangedComponentLifecycle({
92
+ instance: this,
93
+ attributeName: name,
94
+ oldValue,
95
+ newValue,
96
+ scheduleFrameFn: scheduleFrame,
97
+ });
98
+ }
99
+
100
+ render = () => {
101
+ runRenderComponentLifecycle({
102
+ instance: this,
103
+ createComponentUpdateHookFn: createWebComponentUpdateHook,
104
+ });
105
+ };
106
+ }
107
+
108
+ class MyComponent extends BaseComponent {
109
+ static get observedAttributes() {
110
+ return buildObservedAttributes({
111
+ propsSchemaKeys,
112
+ toKebabCase,
113
+ });
114
+ }
115
+
116
+ constructor() {
117
+ super();
118
+ this.constants = resolveConstants({
119
+ setupConstants: deps?.constants,
120
+ fileConstants: constants,
121
+ });
122
+ this.propsSchema = propsSchema;
123
+ this.props = propsSchema
124
+ ? createPropsProxy(this, propsSchemaKeys)
125
+ : {};
126
+ this._propsSchemaKeys = propsSchemaKeys;
127
+ this.elementName = elementName;
128
+ this.styles = styles;
129
+ this.store = bindStore(store, this.props, this.constants);
130
+ this.template = template;
131
+ this.handlers = handlers;
132
+ this.methods = methods;
133
+ this.refs = refs;
134
+ this.patch = patch;
135
+ this.deps = {
136
+ ...deps,
137
+ store: this.store,
138
+ render: this.render,
139
+ handlers,
140
+ props: this.props,
141
+ constants: this.constants,
142
+ };
143
+ bindMethods(this, this.methods);
144
+ this.h = h;
145
+ this.cssText = yamlToCss(elementName, styles);
146
+ }
147
+ }
148
+
149
+ return MyComponent;
150
+ };
@@ -0,0 +1,6 @@
1
+ export const scheduleFrame = (
2
+ callback,
3
+ requestAnimationFrameFn = requestAnimationFrame,
4
+ ) => {
5
+ return requestAnimationFrameFn(callback);
6
+ };