@rettangoli/fe 1.0.3 → 1.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/README.md CHANGED
@@ -35,7 +35,6 @@ rtgl fe watch # Start dev server
35
35
  - [Snabbdom](https://github.com/snabbdom/snabbdom) - Virtual DOM
36
36
  - [Immer](https://github.com/immerjs/immer) - Immutable state management
37
37
  - [Jempl](https://github.com/yuusoft-org/jempl) - Template engine
38
- - [RxJS](https://github.com/ReactiveX/rxjs) - Reactive programming
39
38
 
40
39
  **Build & Development:**
41
40
  - [Vite](https://vite.dev/) - Dev server and production bundling
@@ -196,7 +195,7 @@ Use this workflow:
196
195
  Docker image:
197
196
 
198
197
  ```bash
199
- IMAGE="han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27"
198
+ IMAGE="han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.12"
200
199
  ```
201
200
 
202
201
  Dashboard suite:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/fe",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Frontend framework for building reactive web components",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -18,7 +18,8 @@
18
18
  ],
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "git+https://github.com/yuusoft-org/rettangoli.git"
21
+ "url": "https://github.com/yuusoft-org/rettangoli",
22
+ "directory": "packages/rettangoli-fe"
22
23
  },
23
24
  "license": "MIT",
24
25
  "exports": {
@@ -32,9 +33,8 @@
32
33
  },
33
34
  "dependencies": {
34
35
  "immer": "^10.1.1",
35
- "jempl": "0.3.2-rc2",
36
+ "jempl": "1.0.1",
36
37
  "js-yaml": "^4.1.0",
37
- "rxjs": "^7.8.2",
38
38
  "snabbdom": "^3.6.2",
39
39
  "vite": "^6.3.5"
40
40
  },
@@ -3,9 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { load as loadYaml, loadAll } from "js-yaml";
4
4
  import { render, parse } from "jempl";
5
5
 
6
- import {
7
- flattenArrays,
8
- } from "../common.js";
6
+ import { flattenArrays } from "../utils/flattenArrays.js";
9
7
  import { extractCategoryAndComponent } from "../commonBuild.js";
10
8
  import { getAllFiles } from "../commonBuild.js";
11
9
  import path, { dirname } from "node:path";
@@ -113,6 +113,10 @@ export const generateFrontendEntrySource = ({
113
113
  } else if (YAML_FILE_TYPES.has(fileType)) {
114
114
  const yamlObject = readYamlObject(filePath);
115
115
 
116
+ if (fileType === "view" || fileType === "schema") {
117
+ componentContractEntry.yamlObject = structuredClone(yamlObject);
118
+ }
119
+
116
120
  if (fileType === "view") {
117
121
  try {
118
122
  yamlObject.template = parseTemplate(yamlObject.template);
@@ -127,10 +131,6 @@ export const generateFrontendEntrySource = ({
127
131
  validateConstantsRoot({ filePath, yamlObject, errorPrefix });
128
132
  }
129
133
 
130
- if (fileType === "view" || fileType === "schema") {
131
- componentContractEntry.yamlObject = yamlObject;
132
- }
133
-
134
134
  declarationLines.push(
135
135
  `const ${declarationName} = ${JSON.stringify(yamlObject)};`,
136
136
  );
package/src/common.js CHANGED
@@ -1,36 +1,3 @@
1
- import { Subject } from "rxjs";
2
-
3
- /**
4
- * A custom subject that can be used to dispatch actions and subscribe to them
5
- * You can think of this as a bus for all frontend events and communication
6
- *
7
- * Example:
8
- * const subject = new CustomSubject();
9
- *
10
- * const subscription = subject.subscribe(({ action, payload }) => {
11
- * // handle action and payload
12
- * });
13
- *
14
- * subject.dispatch("action", { payload: "payload" });
15
- *
16
- * subscription.unsubscribe();
17
- */
18
- export class CustomSubject {
19
- _subject = new Subject();
20
- pipe = (...args) => {
21
- return this._subject.pipe(...args);
22
- };
23
- dispatch = (action, payload) => {
24
- this._subject.next({
25
- action,
26
- payload: payload || {},
27
- });
28
- };
29
- dispatchCall = (action, payload) => {
30
- return () => this.dispatch(action, payload || {});
31
- };
32
- }
33
-
34
1
  const getQueryParamsObject = () => {
35
2
  const queryParams = new URLSearchParams(window.location.search + "");
36
3
  const paramsObject = {};
@@ -164,34 +131,4 @@ export function createHttpClient(config) {
164
131
  return httpClient;
165
132
  }
166
133
 
167
-
168
-
169
-
170
-
171
-
172
- // Helper function to flatten arrays while preserving object structure
173
- export const flattenArrays = (items) => {
174
- if (!Array.isArray(items)) {
175
- return items;
176
- }
177
-
178
- return items.reduce((acc, item) => {
179
- if (Array.isArray(item)) {
180
- // Recursively flatten nested arrays
181
- acc.push(...flattenArrays(item));
182
- } else {
183
- // If it's an object with nested arrays, process those too
184
- if (item && typeof item === "object") {
185
- const entries = Object.entries(item);
186
- if (entries.length > 0) {
187
- const [key, value] = entries[0];
188
- if (Array.isArray(value)) {
189
- item = { [key]: flattenArrays(value) };
190
- }
191
- }
192
- }
193
- acc.push(item);
194
- }
195
- return acc;
196
- }, []);
197
- };
134
+ export { flattenArrays } from "./utils/flattenArrays.js";
@@ -1,3 +1,5 @@
1
+ import { findUnsupportedTemplatePropertyBindingSyntax } from "../view/templatePropertyBindings.js";
2
+
1
3
  export const FORBIDDEN_VIEW_KEYS = Object.freeze([
2
4
  "elementName",
3
5
  "viewDataSchema",
@@ -7,24 +9,6 @@ export const FORBIDDEN_VIEW_KEYS = Object.freeze([
7
9
  "attrsSchema",
8
10
  ]);
9
11
 
10
- const LEGACY_PROP_BINDING_REGEX = /(^|\s)\.[A-Za-z_][A-Za-z0-9_-]*\s*=/;
11
-
12
- const hasLegacyDotPropBinding = (node) => {
13
- if (Array.isArray(node)) {
14
- return node.some((item) => hasLegacyDotPropBinding(item));
15
- }
16
- if (!node || typeof node !== "object") {
17
- return false;
18
- }
19
-
20
- return Object.entries(node).some(([key, value]) => {
21
- if (LEGACY_PROP_BINDING_REGEX.test(key)) {
22
- return true;
23
- }
24
- return hasLegacyDotPropBinding(value);
25
- });
26
- };
27
-
28
12
  export const buildComponentContractIndex = (entries = []) => {
29
13
  const index = {};
30
14
 
@@ -99,10 +83,10 @@ export const validateComponentContractIndex = (index = {}) => {
99
83
  });
100
84
  });
101
85
 
102
- if (hasLegacyDotPropBinding(viewYaml.template)) {
86
+ if (findUnsupportedTemplatePropertyBindingSyntax(viewYaml.template)) {
103
87
  errors.push({
104
88
  code: "RTGL-CONTRACT-003",
105
- message: `${componentLabel}: legacy '.prop=' binding is not supported. Use ':prop=' in .view.yaml.`,
89
+ message: `${componentLabel}: legacy property binding syntax is not supported. Use ':prop=\${value}' in .view.yaml.`,
106
90
  filePath: viewFilePath || representativeFile,
107
91
  });
108
92
  }
@@ -149,7 +149,7 @@ export const parseNodeBindings = ({
149
149
  }
150
150
  if (Object.prototype.hasOwnProperty.call(props, normalizedPropName)) {
151
151
  throw new Error(
152
- `[Parser] Duplicate prop binding '${normalizedPropName}' on '${tagName}'. Use only one of 'name=value' or ':name=value'.`,
152
+ `[Parser] Duplicate prop binding '${normalizedPropName}' on '${tagName}'. Use only one of 'name=value' or ':name=\${expr}'.`,
153
153
  );
154
154
  }
155
155
  props[normalizedPropName] = propValue;
@@ -0,0 +1,276 @@
1
+ import { parse as parseTemplate } from "jempl";
2
+ import { NodeType } from "jempl/src/parse/constants.js";
3
+
4
+ const ATTR_ASSIGNMENT_REGEX = /(\S+?)=(?:\"([^\"]*)\"|\'([^\']*)\'|([^\s]*))/g;
5
+ const LOOP_DIRECTIVE_REGEX = /^\$for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*,\s*([A-Za-z_][A-Za-z0-9_]*))?\s+in\s+.+$/;
6
+ const INTERPOLATION_ONLY_REGEX = /^\$\{([^{}]+)\}$/;
7
+ const SIMPLE_PATH_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(?:(?:\.[A-Za-z_][A-Za-z0-9_]*)|\[(?:\d+|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\])*$/;
8
+
9
+ const normalizedTemplateCache = new WeakSet();
10
+
11
+ const extendScopeVars = (scopeVars, itemVar, indexVar) => {
12
+ const nextScopeVars = new Set(scopeVars);
13
+ if (itemVar) {
14
+ nextScopeVars.add(itemVar);
15
+ }
16
+ if (indexVar) {
17
+ nextScopeVars.add(indexVar);
18
+ }
19
+ return nextScopeVars;
20
+ };
21
+
22
+ const getLoopScopeVarsFromRawKey = (key, scopeVars) => {
23
+ if (typeof key !== "string") {
24
+ return scopeVars;
25
+ }
26
+
27
+ const loopMatch = key.match(LOOP_DIRECTIVE_REGEX);
28
+ if (!loopMatch) {
29
+ return scopeVars;
30
+ }
31
+
32
+ return extendScopeVars(scopeVars, loopMatch[1], loopMatch[2]);
33
+ };
34
+
35
+ const getPropertyBindingViolationForKey = (key) => {
36
+ if (typeof key !== "string" || (!key.includes(":") && !key.includes("."))) {
37
+ return null;
38
+ }
39
+
40
+ ATTR_ASSIGNMENT_REGEX.lastIndex = 0;
41
+ let match;
42
+ while ((match = ATTR_ASSIGNMENT_REGEX.exec(key)) !== null) {
43
+ const rawBindingName = match[1];
44
+ if (rawBindingName.startsWith(".")) {
45
+ return {
46
+ bindingName: rawBindingName,
47
+ rawValue: match[2] ?? match[3] ?? match[4] ?? "",
48
+ key,
49
+ };
50
+ }
51
+
52
+ if (!rawBindingName.startsWith(":")) {
53
+ continue;
54
+ }
55
+
56
+ const isQuoted = match[2] !== undefined || match[3] !== undefined;
57
+ const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
58
+ if (isQuoted || !INTERPOLATION_ONLY_REGEX.test(rawValue)) {
59
+ return {
60
+ bindingName: rawBindingName,
61
+ rawValue,
62
+ key,
63
+ };
64
+ }
65
+ }
66
+
67
+ return null;
68
+ };
69
+
70
+ const walkRawTemplateForViolation = (node, scopeVars = new Set()) => {
71
+ if (Array.isArray(node)) {
72
+ for (const item of node) {
73
+ const violation = walkRawTemplateForViolation(item, scopeVars);
74
+ if (violation) {
75
+ return violation;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ if (!node || typeof node !== "object") {
82
+ return null;
83
+ }
84
+
85
+ for (const [key, value] of Object.entries(node)) {
86
+ const violation = getPropertyBindingViolationForKey(key);
87
+ if (violation) {
88
+ return violation;
89
+ }
90
+
91
+ const nextScopeVars = getLoopScopeVarsFromRawKey(key, scopeVars);
92
+ const nestedViolation = walkRawTemplateForViolation(value, nextScopeVars);
93
+ if (nestedViolation) {
94
+ return nestedViolation;
95
+ }
96
+ }
97
+
98
+ return null;
99
+ };
100
+
101
+ const walkAstTemplateForViolation = (node, scopeVars = new Set()) => {
102
+ if (!node || typeof node !== "object") {
103
+ return null;
104
+ }
105
+
106
+ if (Array.isArray(node)) {
107
+ for (const item of node) {
108
+ const violation = walkAstTemplateForViolation(item, scopeVars);
109
+ if (violation) {
110
+ return violation;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ if (node.type === NodeType.LOOP) {
117
+ const nextScopeVars = extendScopeVars(scopeVars, node.itemVar, node.indexVar);
118
+ return walkAstTemplateForViolation(node.body, nextScopeVars);
119
+ }
120
+
121
+ if (node.type === NodeType.ARRAY && Array.isArray(node.items)) {
122
+ return walkAstTemplateForViolation(node.items, scopeVars);
123
+ }
124
+
125
+ if (node.type === NodeType.OBJECT && Array.isArray(node.properties)) {
126
+ for (const property of node.properties) {
127
+ const violation = getPropertyBindingViolationForKey(property.key);
128
+ if (violation) {
129
+ return violation;
130
+ }
131
+ const nestedViolation = walkAstTemplateForViolation(property.value, scopeVars);
132
+ if (nestedViolation) {
133
+ return nestedViolation;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
139
+ for (const value of Object.values(node)) {
140
+ const violation = walkAstTemplateForViolation(value, scopeVars);
141
+ if (violation) {
142
+ return violation;
143
+ }
144
+ }
145
+
146
+ return null;
147
+ };
148
+
149
+ const deriveParsedKey = (key) => {
150
+ const parsed = parseTemplate([{ [key]: "" }]);
151
+ const property = parsed?.items?.[0]?.properties?.[0];
152
+ return property?.parsedKey;
153
+ };
154
+
155
+ const getInterpolationExpression = (rawValue) => {
156
+ const match = rawValue.match(INTERPOLATION_ONLY_REGEX);
157
+ return match ? match[1].trim() : null;
158
+ };
159
+
160
+ const getBaseIdentifier = (expression) => {
161
+ const match = expression.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
162
+ return match ? match[1] : null;
163
+ };
164
+
165
+ const normalizePropertyBindingsInKey = (key, scopeVars) => {
166
+ let changed = false;
167
+
168
+ const normalizedKey = key.replace(
169
+ ATTR_ASSIGNMENT_REGEX,
170
+ (fullMatch, rawBindingName, doubleQuotedValue, singleQuotedValue, bareValue) => {
171
+ if (!rawBindingName.startsWith(":")) {
172
+ return fullMatch;
173
+ }
174
+
175
+ const rawValue = doubleQuotedValue ?? singleQuotedValue ?? bareValue ?? "";
176
+ const expression = getInterpolationExpression(rawValue);
177
+ if (!expression || !SIMPLE_PATH_REGEX.test(expression)) {
178
+ return fullMatch;
179
+ }
180
+
181
+ const baseIdentifier = getBaseIdentifier(expression);
182
+ if (!baseIdentifier) {
183
+ return fullMatch;
184
+ }
185
+
186
+ const internalValue = scopeVars.has(baseIdentifier)
187
+ ? `#{${expression}}`
188
+ : expression;
189
+
190
+ if (internalValue === rawValue) {
191
+ return fullMatch;
192
+ }
193
+
194
+ changed = true;
195
+ return `${rawBindingName}=${internalValue}`;
196
+ },
197
+ );
198
+
199
+ return changed ? normalizedKey : key;
200
+ };
201
+
202
+ const normalizeAstTemplate = (node, scopeVars = new Set()) => {
203
+ if (!node || typeof node !== "object") {
204
+ return;
205
+ }
206
+
207
+ if (Array.isArray(node)) {
208
+ node.forEach((item) => normalizeAstTemplate(item, scopeVars));
209
+ return;
210
+ }
211
+
212
+ if (node.type === NodeType.LOOP) {
213
+ const nextScopeVars = extendScopeVars(scopeVars, node.itemVar, node.indexVar);
214
+ normalizeAstTemplate(node.body, nextScopeVars);
215
+ return;
216
+ }
217
+
218
+ if (node.type === NodeType.ARRAY && Array.isArray(node.items)) {
219
+ node.items.forEach((item) => normalizeAstTemplate(item, scopeVars));
220
+ return;
221
+ }
222
+
223
+ if (node.type === NodeType.OBJECT && Array.isArray(node.properties)) {
224
+ node.properties.forEach((property) => {
225
+ const normalizedKey = normalizePropertyBindingsInKey(property.key, scopeVars);
226
+ if (normalizedKey !== property.key) {
227
+ property.key = normalizedKey;
228
+ const parsedKey = deriveParsedKey(normalizedKey);
229
+ if (parsedKey) {
230
+ property.parsedKey = parsedKey;
231
+ } else {
232
+ delete property.parsedKey;
233
+ }
234
+ }
235
+
236
+ normalizeAstTemplate(property.value, scopeVars);
237
+ });
238
+ return;
239
+ }
240
+
241
+ Object.values(node).forEach((value) => {
242
+ normalizeAstTemplate(value, scopeVars);
243
+ });
244
+ };
245
+
246
+ export const findUnsupportedTemplatePropertyBindingSyntax = (template) => {
247
+ if (!template) {
248
+ return null;
249
+ }
250
+
251
+ if (template.type && typeof template.type === "number") {
252
+ return walkAstTemplateForViolation(template);
253
+ }
254
+
255
+ return walkRawTemplateForViolation(template);
256
+ };
257
+
258
+ export const ensureNormalizedTemplatePropertyBindings = (template) => {
259
+ if (!template || typeof template !== "object") {
260
+ return;
261
+ }
262
+
263
+ if (normalizedTemplateCache.has(template)) {
264
+ return;
265
+ }
266
+
267
+ const violation = findUnsupportedTemplatePropertyBindingSyntax(template);
268
+ if (violation) {
269
+ throw new Error(
270
+ `Property-form bindings must use ':prop=\${value}' syntax. Found '${violation.bindingName}=${violation.rawValue}' in '${violation.key}'.`,
271
+ );
272
+ }
273
+
274
+ normalizeAstTemplate(template);
275
+ normalizedTemplateCache.add(template);
276
+ };
package/src/parser.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { parseAndRender as jemplParseAndRender, render as jemplRender } from "jempl";
2
2
 
3
- import { flattenArrays } from './common.js';
3
+ import { flattenArrays } from "./utils/flattenArrays.js";
4
4
  import { parseNodeBindings } from './core/view/bindings.js';
5
+ import { ensureNormalizedTemplatePropertyBindings } from "./core/view/templatePropertyBindings.js";
5
6
  import {
6
7
  createRefMatchers,
7
8
  resolveBestRefMatcher,
@@ -20,6 +21,7 @@ export const parseView = ({
20
21
  handlers,
21
22
  createComponentUpdateHook,
22
23
  }) => {
24
+ ensureNormalizedTemplatePropertyBindings(template);
23
25
  const result = jemplRender(template, viewData, {});
24
26
 
25
27
  // Flatten the array carefully to maintain structure
@@ -0,0 +1,23 @@
1
+ export const flattenArrays = (items) => {
2
+ if (!Array.isArray(items)) {
3
+ return items;
4
+ }
5
+
6
+ return items.reduce((acc, item) => {
7
+ if (Array.isArray(item)) {
8
+ acc.push(...flattenArrays(item));
9
+ } else {
10
+ if (item && typeof item === "object") {
11
+ const entries = Object.entries(item);
12
+ if (entries.length > 0) {
13
+ const [key, value] = entries[0];
14
+ if (Array.isArray(value)) {
15
+ item = { [key]: flattenArrays(value) };
16
+ }
17
+ }
18
+ }
19
+ acc.push(item);
20
+ }
21
+ return acc;
22
+ }, []);
23
+ };