@rettangoli/fe 0.0.14 → 1.0.0-rc3
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 +94 -9
- package/package.json +9 -3
- package/src/cli/blank/blank.handlers.js +5 -2
- package/src/cli/blank/blank.schema.yaml +13 -0
- package/src/cli/blank/blank.view.yaml +0 -11
- package/src/cli/build.js +56 -24
- package/src/cli/check.js +53 -0
- package/src/cli/contracts.js +149 -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 +158 -0
- package/src/core/runtime/componentRuntime.js +54 -0
- package/src/core/runtime/constants.js +27 -0
- package/src/core/runtime/events.js +198 -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 +46 -0
- package/src/core/style/yamlToCss.js +44 -0
- package/src/core/view/bindings.js +198 -0
- package/src/core/view/refs.js +234 -0
- package/src/createComponent.js +42 -527
- package/src/index.js +0 -2
- package/src/parser.js +101 -277
- package/src/web/componentDom.js +49 -0
- package/src/web/componentUpdateHook.js +43 -0
- package/src/web/createWebComponentClass.js +151 -0
- package/src/web/scheduler.js +6 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Rettangoli Frontend
|
|
2
2
|
|
|
3
|
-
A modern frontend framework that uses YAML for view definitions, web components for composition, and Immer for state management. Build reactive applications with minimal complexity using
|
|
3
|
+
A modern frontend framework that uses YAML for view definitions, web components for composition, and Immer for state management. Build reactive applications with minimal complexity using 4 types of files.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **🗂️
|
|
7
|
+
- **🗂️ Four-File Architecture** - `.view.yaml`, `.store.js`, `.handlers.js`, `.schema.yaml` scale from single page to complex applications
|
|
8
8
|
- **📝 YAML Views** - Declarative UI definitions that compile to virtual DOM
|
|
9
9
|
- **🧩 Web Components** - Standards-based component architecture
|
|
10
10
|
- **🔄 Reactive State** - Immer-powered immutable state management
|
|
@@ -23,6 +23,7 @@ rtgl fe watch # Start dev server
|
|
|
23
23
|
|
|
24
24
|
- **[Developer Quickstart](./docs/overview.md)** - Complete introduction and examples
|
|
25
25
|
- **[View System](./docs/view.md)** - Complete YAML syntax
|
|
26
|
+
- **[Schema System](./docs/schema.md)** - Component API and metadata
|
|
26
27
|
- **[Store Management](./docs/store.md)** - State patterns
|
|
27
28
|
- **[Event Handlers](./docs/handlers.md)** - Event handling
|
|
28
29
|
|
|
@@ -38,7 +39,6 @@ rtgl fe watch # Start dev server
|
|
|
38
39
|
|
|
39
40
|
**Build & Development:**
|
|
40
41
|
- [ESBuild](https://esbuild.github.io/) - Fast bundling
|
|
41
|
-
- [Vite](https://vite.dev/) - Development server with hot reload
|
|
42
42
|
|
|
43
43
|
**Browser Native:**
|
|
44
44
|
- Web Components - Component encapsulation
|
|
@@ -60,7 +60,7 @@ bun install
|
|
|
60
60
|
2. **Create project structure**:
|
|
61
61
|
```bash
|
|
62
62
|
# Scaffold a new component
|
|
63
|
-
node ../rettangoli-cli/cli.js fe scaffold --category components --name MyButton
|
|
63
|
+
node ../rettangoli-cli/cli.js fe scaffold --category components --component-name MyButton
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
3. **Start development**:
|
|
@@ -83,7 +83,7 @@ src/
|
|
|
83
83
|
│ ├── examples.js # Generate examples for testing
|
|
84
84
|
│ └── blank/ # Component templates
|
|
85
85
|
├── createComponent.js # Component factory
|
|
86
|
-
├── createWebPatch.js #
|
|
86
|
+
├── createWebPatch.js # Internal virtual DOM patching
|
|
87
87
|
├── parser.js # YAML to JSON converter
|
|
88
88
|
├── common.js # Shared utilities
|
|
89
89
|
└── index.js # Main exports
|
|
@@ -104,17 +104,102 @@ fe:
|
|
|
104
104
|
outputDir: "./vt/specs/examples"
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Setup Contract
|
|
108
|
+
|
|
109
|
+
`setup.js` should export `deps` only.
|
|
110
|
+
`createWebPatch`/`h` wiring is internalized by the framework.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
const deps = {
|
|
114
|
+
components: {},
|
|
115
|
+
pages: {},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export { deps };
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Action Listeners
|
|
122
|
+
|
|
123
|
+
In `.view.yaml`, listeners can dispatch store actions directly with `action`.
|
|
124
|
+
This path auto-runs render after the action executes.
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
refs:
|
|
128
|
+
inputEmail:
|
|
129
|
+
eventListeners:
|
|
130
|
+
input:
|
|
131
|
+
action: setEmail
|
|
132
|
+
payload:
|
|
133
|
+
value: ${_event.target.value}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Store action:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
export const setEmail = ({ state }, { value }) => {
|
|
140
|
+
state.email = value;
|
|
141
|
+
};
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Runtime-injected action payload fields:
|
|
145
|
+
- `_event`
|
|
146
|
+
- `_action` (internal dispatch metadata)
|
|
147
|
+
|
|
107
148
|
## Testing
|
|
108
149
|
|
|
109
|
-
###
|
|
150
|
+
### Unit and Contract Tests
|
|
110
151
|
|
|
111
|
-
|
|
152
|
+
- **Puty contract tests** (`spec/`) — YAML-driven pure-function specs for view, store, schema, and handler contracts.
|
|
153
|
+
- **Vitest integration tests** (`test/`) — runtime behavior tests for component lifecycle, props, events, and DOM.
|
|
112
154
|
|
|
113
155
|
```bash
|
|
114
|
-
|
|
115
|
-
|
|
156
|
+
bun run test # all tests
|
|
157
|
+
bun run test:puty # contract tests only
|
|
158
|
+
bun run test:vitest # integration tests only
|
|
116
159
|
```
|
|
117
160
|
|
|
161
|
+
### End-to-End Testing
|
|
162
|
+
|
|
163
|
+
FE has two E2E suites in this package:
|
|
164
|
+
|
|
165
|
+
- `packages/rettangoli-fe/e2e/dashboard`
|
|
166
|
+
- `packages/rettangoli-fe/e2e/interactions`
|
|
167
|
+
|
|
168
|
+
Use this workflow:
|
|
169
|
+
1. Build FE with the local repo CLI (`cli.js`) so it uses your current FE source.
|
|
170
|
+
2. Run VT in Docker for stable Playwright runtime.
|
|
171
|
+
|
|
172
|
+
Docker image:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
IMAGE="han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc4"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Dashboard suite:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
(cd packages/rettangoli-fe/e2e/dashboard && node ../../../rettangoli-cli/cli.js fe build)
|
|
182
|
+
docker run --rm -v "$(pwd):/workspace" -w /workspace/packages/rettangoli-fe/e2e/dashboard "$IMAGE" rtgl vt generate
|
|
183
|
+
docker run --rm -v "$(pwd):/workspace" -w /workspace/packages/rettangoli-fe/e2e/dashboard "$IMAGE" rtgl vt report
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Interactions suite:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
(cd packages/rettangoli-fe/e2e/interactions && node ../../../rettangoli-cli/cli.js fe build)
|
|
190
|
+
docker run --rm -v "$(pwd):/workspace" -w /workspace/packages/rettangoli-fe/e2e/interactions "$IMAGE" rtgl vt generate
|
|
191
|
+
docker run --rm -v "$(pwd):/workspace" -w /workspace/packages/rettangoli-fe/e2e/interactions "$IMAGE" rtgl vt report
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Accept intentional visual changes:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
docker run --rm -v "$(pwd):/workspace" -w /workspace/packages/rettangoli-fe/e2e/dashboard "$IMAGE" rtgl vt accept
|
|
198
|
+
docker run --rm -v "$(pwd):/workspace" -w /workspace/packages/rettangoli-fe/e2e/interactions "$IMAGE" rtgl vt accept
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
VT specs live under each suite's `vt/specs/` directory.
|
|
202
|
+
|
|
118
203
|
## Examples
|
|
119
204
|
|
|
120
205
|
For a complete working example, see the todos app in `examples/example1/`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/fe",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "1.0.0-rc3",
|
|
4
4
|
"description": "Frontend framework for building reactive web components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
".": "./src/index.js",
|
|
26
26
|
"./cli": "./src/cli/index.js"
|
|
27
27
|
},
|
|
28
|
-
"devDependencies": {
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"puty": "^0.1.1",
|
|
30
|
+
"vitest": "^4.0.15"
|
|
31
|
+
},
|
|
29
32
|
"dependencies": {
|
|
30
33
|
"esbuild": "^0.25.5",
|
|
31
34
|
"immer": "^10.1.1",
|
|
@@ -36,6 +39,9 @@
|
|
|
36
39
|
"vite": "^6.3.5"
|
|
37
40
|
},
|
|
38
41
|
"scripts": {
|
|
39
|
-
"dev": "node watch.js --watch"
|
|
42
|
+
"dev": "node watch.js --watch",
|
|
43
|
+
"test": "vitest run --reporter=verbose",
|
|
44
|
+
"test:puty": "vitest run puty.spec.js --reporter=verbose",
|
|
45
|
+
"test:vitest": "vitest run test/**/*.test.js --reporter=verbose"
|
|
40
46
|
}
|
|
41
47
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
export const handleBeforeMount = (deps, payload) => {
|
|
2
|
+
//
|
|
3
|
+
}
|
|
1
4
|
|
|
2
|
-
export const
|
|
5
|
+
export const handleAfterMount = async (deps, payload) => {
|
|
3
6
|
//
|
|
4
7
|
}
|
|
5
8
|
|
|
6
|
-
export const
|
|
9
|
+
export const handleSomeEvent = (deps, payload) => {
|
|
7
10
|
//
|
|
8
11
|
}
|
package/src/cli/build.js
CHANGED
|
@@ -10,24 +10,32 @@ import { load as loadYaml } from "js-yaml";
|
|
|
10
10
|
import { parse } from 'jempl';
|
|
11
11
|
import { extractCategoryAndComponent } from '../commonBuild.js';
|
|
12
12
|
import { getAllFiles } from '../commonBuild.js';
|
|
13
|
+
import {
|
|
14
|
+
isSupportedComponentFile,
|
|
15
|
+
validateComponentEntries,
|
|
16
|
+
} from "./contracts.js";
|
|
13
17
|
import path from "node:path";
|
|
14
18
|
|
|
15
19
|
function capitalize(word) {
|
|
16
20
|
return word ? word[0].toUpperCase() + word.slice(1) : word;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
export const writeViewFile = (view, category, component, tempDir) => {
|
|
23
|
+
const writeYamlModuleFile = (yamlObject, category, component, fileType, tempDir = path.resolve(process.cwd(), ".temp")) => {
|
|
21
24
|
const dir = path.join(tempDir, category);
|
|
22
25
|
if (!existsSync(dir)) {
|
|
23
26
|
mkdirSync(dir, { recursive: true });
|
|
24
27
|
}
|
|
25
28
|
writeFileSync(
|
|
26
|
-
path.join(dir, `${component}.
|
|
27
|
-
`export default ${JSON.stringify(
|
|
29
|
+
path.join(dir, `${component}.${fileType}.js`),
|
|
30
|
+
`export default ${JSON.stringify(yamlObject)};`,
|
|
28
31
|
);
|
|
29
32
|
};
|
|
30
33
|
|
|
34
|
+
// Function to process view files - loads YAML and creates temporary JS file
|
|
35
|
+
export const writeViewFile = (view, category, component, tempDir = path.resolve(process.cwd(), ".temp")) => {
|
|
36
|
+
writeYamlModuleFile(view, category, component, "view", tempDir);
|
|
37
|
+
};
|
|
38
|
+
|
|
31
39
|
export const bundleFile = async (options) => {
|
|
32
40
|
const { outfile, tempDir, development = false } = options;
|
|
33
41
|
await esbuild.build({
|
|
@@ -66,18 +74,13 @@ const buildRettangoliFrontend = async (options) => {
|
|
|
66
74
|
mkdirSync(tempDir, { recursive: true });
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
const allFiles = getAllFiles(resolvedDirs).filter((filePath) =>
|
|
70
|
-
return (
|
|
71
|
-
filePath.endsWith(".store.js") ||
|
|
72
|
-
filePath.endsWith(".handlers.js") ||
|
|
73
|
-
filePath.endsWith(".view.yaml")
|
|
74
|
-
);
|
|
75
|
-
});
|
|
77
|
+
const allFiles = getAllFiles(resolvedDirs).filter((filePath) => isSupportedComponentFile(filePath));
|
|
76
78
|
|
|
77
79
|
let output = "";
|
|
78
80
|
|
|
79
81
|
const categories = [];
|
|
80
82
|
const imports = {};
|
|
83
|
+
const componentContractEntries = [];
|
|
81
84
|
|
|
82
85
|
// unique identifier needed for replacing
|
|
83
86
|
let count = 10000000000;
|
|
@@ -101,7 +104,14 @@ const buildRettangoliFrontend = async (options) => {
|
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
const componentContractEntry = {
|
|
108
|
+
category,
|
|
109
|
+
component,
|
|
110
|
+
fileType,
|
|
111
|
+
filePath,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (["handlers", "store", "methods"].includes(fileType)) {
|
|
105
115
|
const relativePath = path.relative(tempDir, filePath).replaceAll(path.sep, "/");
|
|
106
116
|
output += `import * as ${component}${capitalize(
|
|
107
117
|
fileType,
|
|
@@ -110,35 +120,57 @@ const buildRettangoliFrontend = async (options) => {
|
|
|
110
120
|
replaceMap[count] = `${component}${capitalize(fileType)}`;
|
|
111
121
|
imports[category][component][fileType] = count;
|
|
112
122
|
count++;
|
|
113
|
-
} else if (["view"].includes(fileType)) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
} else if (["view", "constants", "schema"].includes(fileType)) {
|
|
124
|
+
const yamlObject = loadYaml(readFileSync(filePath, "utf8")) ?? {};
|
|
125
|
+
componentContractEntry.yamlObject = yamlObject;
|
|
126
|
+
if (fileType === "view") {
|
|
127
|
+
try {
|
|
128
|
+
yamlObject.template = parse(yamlObject.template);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error(`Error parsing template in file: ${filePath}`);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
120
133
|
}
|
|
121
|
-
|
|
134
|
+
if (
|
|
135
|
+
fileType === "constants" &&
|
|
136
|
+
(yamlObject === null || typeof yamlObject !== "object" || Array.isArray(yamlObject))
|
|
137
|
+
) {
|
|
138
|
+
throw new Error(`[Build] ${filePath} must contain a YAML object at the root.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
writeYamlModuleFile(yamlObject, category, component, fileType, tempDir);
|
|
122
142
|
output += `import ${component}${capitalize(
|
|
123
143
|
fileType,
|
|
124
|
-
)} from './${category}/${component}.
|
|
144
|
+
)} from './${category}/${component}.${fileType}.js';\n`;
|
|
125
145
|
replaceMap[count] = `${component}${capitalize(fileType)}`;
|
|
126
146
|
|
|
127
147
|
imports[category][component][fileType] = count;
|
|
128
148
|
count++;
|
|
129
149
|
}
|
|
150
|
+
|
|
151
|
+
componentContractEntries.push(componentContractEntry);
|
|
130
152
|
}
|
|
131
153
|
|
|
154
|
+
validateComponentEntries({
|
|
155
|
+
entries: componentContractEntries,
|
|
156
|
+
errorPrefix: "[Build]",
|
|
157
|
+
});
|
|
158
|
+
|
|
132
159
|
const relativeSetup = path.relative(tempDir, resolvedSetup).replaceAll(path.sep, "/");
|
|
133
160
|
output += `
|
|
134
161
|
import { createComponent } from '@rettangoli/fe';
|
|
135
|
-
import { deps
|
|
162
|
+
import { deps } from '${relativeSetup}';
|
|
136
163
|
const imports = ${JSON.stringify(imports, null, 2)};
|
|
137
164
|
|
|
138
165
|
Object.keys(imports).forEach(category => {
|
|
139
166
|
Object.keys(imports[category]).forEach(component => {
|
|
140
|
-
const
|
|
141
|
-
|
|
167
|
+
const componentConfig = imports[category][component];
|
|
168
|
+
const webComponent = createComponent({ ...componentConfig }, deps[category])
|
|
169
|
+
const elementName = componentConfig.schema?.componentName;
|
|
170
|
+
if (!elementName) {
|
|
171
|
+
throw new Error(\`[Build] Missing schema.componentName for \${category}/\${component}. Define it in .schema.yaml.\`);
|
|
172
|
+
}
|
|
173
|
+
customElements.define(elementName, webComponent);
|
|
142
174
|
})
|
|
143
175
|
})
|
|
144
176
|
|
package/src/cli/check.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
analyzeComponentDirs,
|
|
4
|
+
formatContractFailureReport,
|
|
5
|
+
} from "./contracts.js";
|
|
6
|
+
|
|
7
|
+
const checkRettangoliFrontend = (options = {}) => {
|
|
8
|
+
const {
|
|
9
|
+
cwd = process.cwd(),
|
|
10
|
+
dirs = ["./example"],
|
|
11
|
+
format = "text",
|
|
12
|
+
} = options;
|
|
13
|
+
const outputFormat = format === "json" ? "json" : "text";
|
|
14
|
+
|
|
15
|
+
const resolvedDirs = dirs.map((dir) => path.resolve(cwd, dir));
|
|
16
|
+
const { errors, summary, index } = analyzeComponentDirs({ dirs: resolvedDirs });
|
|
17
|
+
|
|
18
|
+
if (errors.length > 0) {
|
|
19
|
+
if (outputFormat === "json") {
|
|
20
|
+
console.log(JSON.stringify({
|
|
21
|
+
ok: false,
|
|
22
|
+
prefix: "[Check]",
|
|
23
|
+
summary,
|
|
24
|
+
errors,
|
|
25
|
+
}, null, 2));
|
|
26
|
+
} else {
|
|
27
|
+
console.error(formatContractFailureReport({
|
|
28
|
+
errorPrefix: "[Check]",
|
|
29
|
+
errors,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const componentCount = Object.values(index).reduce((count, categoryComponents) => {
|
|
37
|
+
return count + Object.keys(categoryComponents).length;
|
|
38
|
+
}, 0);
|
|
39
|
+
|
|
40
|
+
if (outputFormat === "json") {
|
|
41
|
+
console.log(JSON.stringify({
|
|
42
|
+
ok: true,
|
|
43
|
+
prefix: "[Check]",
|
|
44
|
+
componentCount,
|
|
45
|
+
summary,
|
|
46
|
+
}, null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(`[Check] Component contracts passed for ${componentCount} component(s).`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default checkRettangoliFrontend;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { load as loadYaml } from "js-yaml";
|
|
3
|
+
import { extractCategoryAndComponent, getAllFiles } from "../commonBuild.js";
|
|
4
|
+
import {
|
|
5
|
+
buildComponentContractIndex,
|
|
6
|
+
formatContractErrors as formatContractErrorLines,
|
|
7
|
+
validateComponentContractIndex,
|
|
8
|
+
} from "../core/contracts/componentFiles.js";
|
|
9
|
+
|
|
10
|
+
export const SUPPORTED_COMPONENT_FILE_SUFFIXES = Object.freeze([
|
|
11
|
+
".store.js",
|
|
12
|
+
".handlers.js",
|
|
13
|
+
".methods.js",
|
|
14
|
+
".constants.yaml",
|
|
15
|
+
".schema.yaml",
|
|
16
|
+
".view.yaml",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const isSupportedComponentFile = (filePath) => {
|
|
20
|
+
return SUPPORTED_COMPONENT_FILE_SUFFIXES.some((suffix) => filePath.endsWith(suffix));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const collectComponentContractEntriesFromFiles = (allFiles = []) => {
|
|
24
|
+
return allFiles
|
|
25
|
+
.filter((filePath) => isSupportedComponentFile(filePath))
|
|
26
|
+
.map((filePath) => {
|
|
27
|
+
const { category, component, fileType } = extractCategoryAndComponent(filePath);
|
|
28
|
+
const entry = {
|
|
29
|
+
category,
|
|
30
|
+
component,
|
|
31
|
+
fileType,
|
|
32
|
+
filePath,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (["view", "schema"].includes(fileType)) {
|
|
36
|
+
try {
|
|
37
|
+
entry.yamlObject = loadYaml(readFileSync(filePath, "utf8")) ?? {};
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[Check] Failed to read or parse ${filePath}: ${err.message}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return entry;
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const collectComponentContractEntriesFromDirs = (dirs = []) => {
|
|
50
|
+
const allFiles = getAllFiles(dirs);
|
|
51
|
+
return collectComponentContractEntriesFromFiles(allFiles);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const validateComponentEntries = ({
|
|
55
|
+
entries = [],
|
|
56
|
+
errorPrefix = "[Check]",
|
|
57
|
+
}) => {
|
|
58
|
+
const index = buildComponentContractIndex(entries);
|
|
59
|
+
const errors = validateComponentContractIndex(index);
|
|
60
|
+
if (errors.length > 0) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${errorPrefix} Component contract validation failed:\n${formatContractErrors(errors).join("\n")}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
index,
|
|
67
|
+
errors,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const validateComponentDirs = ({
|
|
72
|
+
dirs = [],
|
|
73
|
+
errorPrefix = "[Check]",
|
|
74
|
+
}) => {
|
|
75
|
+
const entries = collectComponentContractEntriesFromDirs(dirs);
|
|
76
|
+
const validationResult = validateComponentEntries({ entries, errorPrefix });
|
|
77
|
+
return {
|
|
78
|
+
entries,
|
|
79
|
+
...validationResult,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const summarizeContractErrors = (errors = []) => {
|
|
84
|
+
const byCode = {};
|
|
85
|
+
const byComponent = {};
|
|
86
|
+
|
|
87
|
+
errors.forEach((error) => {
|
|
88
|
+
const code = error?.code || "UNKNOWN";
|
|
89
|
+
byCode[code] = (byCode[code] || 0) + 1;
|
|
90
|
+
|
|
91
|
+
const componentLabelMatch = String(error?.message || "").match(/^([^:]+):\s/);
|
|
92
|
+
const componentLabel = componentLabelMatch ? componentLabelMatch[1] : "unknown";
|
|
93
|
+
byComponent[componentLabel] = (byComponent[componentLabel] || 0) + 1;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const byCodeSorted = Object.entries(byCode)
|
|
97
|
+
.map(([code, count]) => ({ code, count }))
|
|
98
|
+
.sort((a, b) => a.code.localeCompare(b.code));
|
|
99
|
+
|
|
100
|
+
const byComponentSorted = Object.entries(byComponent)
|
|
101
|
+
.map(([component, count]) => ({ component, count }))
|
|
102
|
+
.sort((a, b) => b.count - a.count || a.component.localeCompare(b.component));
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
total: errors.length,
|
|
106
|
+
byCode: byCodeSorted,
|
|
107
|
+
byComponent: byComponentSorted,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const formatContractFailureReport = ({
|
|
112
|
+
errorPrefix = "[Check]",
|
|
113
|
+
errors = [],
|
|
114
|
+
}) => {
|
|
115
|
+
const summary = summarizeContractErrors(errors);
|
|
116
|
+
const header = `${errorPrefix} Component contract validation failed: ${summary.total} issue(s)`;
|
|
117
|
+
const byCodeLines = summary.byCode.map(({ code, count }) => `- ${code}: ${count}`);
|
|
118
|
+
const byComponentLines = summary.byComponent.map(
|
|
119
|
+
({ component, count }) => `- ${component}: ${count}`,
|
|
120
|
+
);
|
|
121
|
+
const detailLines = formatContractErrorLines(errors);
|
|
122
|
+
|
|
123
|
+
return [
|
|
124
|
+
header,
|
|
125
|
+
"By rule:",
|
|
126
|
+
...(byCodeLines.length > 0 ? byCodeLines : ["- none"]),
|
|
127
|
+
"By component:",
|
|
128
|
+
...(byComponentLines.length > 0 ? byComponentLines : ["- none"]),
|
|
129
|
+
"Details:",
|
|
130
|
+
...(detailLines.length > 0 ? detailLines : ["- none"]),
|
|
131
|
+
].join("\n");
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const analyzeComponentEntries = ({ entries = [] }) => {
|
|
135
|
+
const index = buildComponentContractIndex(entries);
|
|
136
|
+
const errors = validateComponentContractIndex(index);
|
|
137
|
+
const summary = summarizeContractErrors(errors);
|
|
138
|
+
return {
|
|
139
|
+
entries,
|
|
140
|
+
index,
|
|
141
|
+
errors,
|
|
142
|
+
summary,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const analyzeComponentDirs = ({ dirs = [] }) => {
|
|
147
|
+
const entries = collectComponentContractEntriesFromDirs(dirs);
|
|
148
|
+
return analyzeComponentEntries({ entries });
|
|
149
|
+
};
|
package/src/cli/index.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export {
|
|
7
|
-
build,
|
|
8
|
-
scaffold,
|
|
9
|
-
watch,
|
|
10
|
-
examples
|
|
11
|
-
}
|
|
1
|
+
export { default as build } from "./build.js";
|
|
2
|
+
export { default as check } from "./check.js";
|
|
3
|
+
export { default as scaffold } from "./scaffold.js";
|
|
4
|
+
export { default as watch } from "./watch.js";
|
|
5
|
+
export { default as examples } from "./examples.js";
|
package/src/cli/scaffold.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { validateComponentDirs } from './contracts.js';
|
|
3
4
|
|
|
4
5
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
5
6
|
|
|
@@ -37,6 +38,11 @@ const scaffoldPage = (options) => {
|
|
|
37
38
|
}
|
|
38
39
|
});
|
|
39
40
|
|
|
41
|
+
validateComponentDirs({
|
|
42
|
+
dirs: [path.resolve(targetDir)],
|
|
43
|
+
errorPrefix: "[Scaffold]",
|
|
44
|
+
});
|
|
45
|
+
|
|
40
46
|
console.log(`Successfully scaffolded ${targetDir} from template`);
|
|
41
47
|
}
|
|
42
48
|
|
package/src/cli/watch.js
CHANGED
|
@@ -20,9 +20,10 @@ const setupWatcher = (directory, options) => {
|
|
|
20
20
|
console.log(`Detected ${event} in ${directory}/${filename}`);
|
|
21
21
|
if (filename) {
|
|
22
22
|
try {
|
|
23
|
+
const changedFilePath = path.join(directory, filename);
|
|
23
24
|
if (filename.endsWith('.view.yaml')) {
|
|
24
|
-
const view = loadYaml(readFileSync(
|
|
25
|
-
const { category, component } = extractCategoryAndComponent(
|
|
25
|
+
const view = loadYaml(readFileSync(changedFilePath, "utf8"));
|
|
26
|
+
const { category, component } = extractCategoryAndComponent(changedFilePath);
|
|
26
27
|
await writeViewFile(view, category, component);
|
|
27
28
|
}
|
|
28
29
|
|