@rettangoli/fe 0.0.3

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 ADDED
@@ -0,0 +1,324 @@
1
+
2
+ # Rettangoli Frontend
3
+
4
+ ## Development
5
+
6
+ Bundle the code under `example` folder using `rettangoli-fe`
7
+
8
+ ```bash
9
+ bun run ../rettangoli-cli/cli.js fe build
10
+ ```
11
+
12
+ Visit the example project that shows the components in action
13
+
14
+ ```bash
15
+ bunx serve ./viz/static
16
+ ```
17
+
18
+ Instead of running `fe build` each time, we can also watch for file changes and bundle the code automatically.
19
+
20
+ ```bash
21
+ bun run ../rettangoli-cli/cli.js fe watch
22
+ ```
23
+
24
+
25
+ Note: rettangoli-vt is not setup for this project yet. We just use static files under `viz/static` folder.
26
+
27
+
28
+ ## Introduction
29
+
30
+ A frontend framework with just 3 type of files to scale from a single page to a full fledged complex application.
31
+
32
+ Implemented using:
33
+
34
+ Browser native:
35
+ * web components for components
36
+
37
+ Runtime:
38
+ * [snabbdom](https://github.com/snabbdom/snabbdom) for virtual dom
39
+ * [immer](https://github.com/immerjs/immer) for data immutability for state management
40
+ * [json-e](https://github.com/json-e/json-e) for templating using json
41
+ * [rxjs](https://github.com/ReactiveX/rxjs) for reactive programming
42
+
43
+ Build & Development:
44
+ * [esbuild](https://esbuild.github.io/) for bundling
45
+ * [vite](https://vite.dev/) for development
46
+
47
+ ## View Layer
48
+
49
+ The view layer is unique in that it uses yaml.
50
+
51
+ * The yaml will be converted into json at build time. The json will then be consumed by snabbdom to be transformed into html through a virtual dom.
52
+
53
+ ### Yaml to write html
54
+
55
+ Standard html can be totally written in yaml.
56
+
57
+ All child element are arrays. Except for things like actual content of text
58
+
59
+ Use `#` and `.` selectors to represent `id` and `class`.
60
+
61
+ `div#myid.class1.class2 custorm-attribute=abcd`
62
+
63
+ will become
64
+
65
+ `<div id="myid" class="class1 class2" custom-attribute="abcd"></div>`
66
+
67
+
68
+ ### Templating using json-e
69
+
70
+ `json-e` templating language allows us to write conditions and loops in our yaml. For example:
71
+
72
+
73
+ Loop
74
+
75
+ ```yaml
76
+ template:
77
+ - rtgl-view w=f g=m:
78
+ - $map: { $eval: projects }
79
+ each(v,k):
80
+ - rtgl-view#project-${v.id} h=64 w=f bw=xs p=m cur=p:
81
+ - rtgl-text s=lg: "${v.name}"
82
+ - rtgl-text s=sm: "${v.description}"
83
+ ```
84
+
85
+ Conditional. We usually use `$switch` more as it tends to be more flexible than `$if`.
86
+
87
+ ```yaml
88
+ template:
89
+ - rtgl-view d=h w=f h=f:
90
+ - $switch:
91
+ 'showSidebar':
92
+ - sidebar-component: []
93
+ - rtgl-view w=f h=f:
94
+ - $switch:
95
+ 'currentRoute== "/projects"':
96
+ - projects-component: []
97
+ 'currentRoute== "/profile"':
98
+ ```
99
+
100
+ `json-e` has many more features but we want to keep it simple and in most cases loops and conditionals are enough.
101
+
102
+ The actual data used in the template is passed in as `viewData` from the state store which we will cover later.
103
+
104
+ ### Define a elementName
105
+
106
+ ```yaml
107
+ elementName: custom-projects
108
+ ```
109
+
110
+ This will be the web component name that will be used for the component.
111
+
112
+ The component can later be used as `<custom-projects></custom-projects>`.
113
+
114
+ ### Styles
115
+
116
+ Styles can also be completely written in yaml.
117
+
118
+ ```yaml
119
+ styles:
120
+ '#title':
121
+ font-size: 24px
122
+ '@media (min-width: 768px)':
123
+ '#title':
124
+ font-size: 32px
125
+ ```
126
+
127
+ TODO better support nesting and issue with some global selectors.
128
+
129
+ ### Event listeners
130
+
131
+ ```yaml
132
+ refs:
133
+ createButton:
134
+ eventListeners:
135
+ click:
136
+ handler: handleCreateButtonClick
137
+ project-*:
138
+ eventListeners:
139
+ click:
140
+ handler: handleProjectsClick
141
+
142
+ template:
143
+ - rtgl-button#createButton: Create Project
144
+ - rtgl-view w=f g=m:
145
+ - $map: { $eval: projects }
146
+ each(v,k):
147
+ - rtgl-view#project-${v.id} h=64 w=f bw=xs p=m cur=p:
148
+ - rtgl-text s=lg: "${v.name}"
149
+ - rtgl-text s=sm: "${v.description}"
150
+ ```
151
+
152
+ The above example, will attach event listenrs to `#createButton` and all `#project-*` (wild card support) elements. And bind them to the handlers `handleCreateButtonClick` and `handleProjectsClick`.
153
+
154
+ ### Defining data schema
155
+
156
+ Component have a few types of data that can be defined using a JSON schema:
157
+
158
+ * `viewDataSchema` - The data that will used for the template.
159
+ * `propsSchema` - The data that will be passed to the component via javascript, those can be objects.
160
+ * `attrsSchema` - The data that will be passed to the component via html attributes, this is raw strings.
161
+
162
+
163
+ ```yaml
164
+ viewDataSchema:
165
+ type: object
166
+ properties:
167
+ title:
168
+ type: string
169
+ default: Projects
170
+ createButtonText:
171
+ type: string
172
+ default: Create Project
173
+ projects:
174
+ type: array
175
+ items:
176
+ type: object
177
+ properties:
178
+ id:
179
+ type: string
180
+ name:
181
+ type: string
182
+ default: Project 1
183
+ description:
184
+ type: string
185
+ default: Project 1 description
186
+ propsSchema:
187
+ type: object
188
+ properties: {}
189
+ ```
190
+
191
+
192
+ ## State Store
193
+
194
+ * Define `initial state`
195
+ * `toViewData` will take current `state`, `props` and `attrs` and return the `viewData` to be used by the view template
196
+ * Any exported function that starts with `select` will beceme selectors and are used by handlers to access state data
197
+ * `actions` are all other exported functions that are used to mutate the state.
198
+
199
+ ```js
200
+ export const INITIAL_STATE = Object.freeze({
201
+ title: "Projects",
202
+ createButtonText: "Create Project",
203
+ projects: [
204
+ {
205
+ id: "1",
206
+ name: "Project 1",
207
+ description: "Project 1 description",
208
+ },
209
+ {
210
+ id: '2',
211
+ name: 'Project 2',
212
+ description: 'Project 2 description'
213
+ }
214
+ ],
215
+ });
216
+
217
+ export const toViewData = ({ state, props }, payload) => {
218
+ return state;
219
+ }
220
+
221
+ export const selectProjects = (state, props, payload) => {
222
+ return state.projects;
223
+ }
224
+
225
+ export const setProjects = (state, payload) => {
226
+
227
+ }
228
+ ```
229
+
230
+ Note that this is just a dump store, it is not reactive. Components will need to call `deps.render()` from handlers to re-render the component.
231
+
232
+ ## Handlers
233
+
234
+ `handleOnMount` is a special handler that is called when the component is mounted. It returns a promise that resolves when the component is mounted.
235
+
236
+ All other exported functions will automatically become handlers and can be used in the view layers's `eventListeners`
237
+
238
+ A special object called `deps` is injected into all handlers. It has the following properties:
239
+
240
+ * `deps.render()` will re-render the component. We call this function each time we have chaged state and want to re-render the component.
241
+ * `deps.store` is the store instance. Use selectors to select state and actions to mutate state.
242
+ * `deps.transformedHandlers` can be used to call other handlers.
243
+ * `deps.attrs` is the html attributes that are passed to the component.
244
+ * `deps.props` is the javascript properties that are passed to the component.
245
+
246
+
247
+ ```js
248
+ export const handleOnMount = (deps) => {
249
+ () => {
250
+ // unsubscribe
251
+ }
252
+ }
253
+
254
+ export const handleCreateButtonClick = async (e, deps) => {
255
+ const { store, deps, render } = deps;
256
+ const formIsVisible = store.selectFormIsVisible();
257
+
258
+ if (!formIsVisible) {
259
+ store.setFormIsVisible(true);
260
+ }
261
+ deps.render();
262
+ }
263
+
264
+ export const handleProjectsClick = (e, deps) => {
265
+ const id = e.target.id
266
+ console.log('handleProjectsClick', id);
267
+ }
268
+ ```
269
+
270
+
271
+
272
+ * `deps.dispatchEvent` can be used to dispatch custom dom events.
273
+
274
+ ```js
275
+ export const handleProjectsClick = (e, deps) => {
276
+ deps.dispatchEvent(new CustomEvent('project-clicked', {
277
+ projectId: '1',
278
+ }));
279
+ }
280
+ ```
281
+
282
+
283
+ ### Adding additional dependencies
284
+
285
+ This is a simple yet powerful way to do dependency injection. Those are all global singleton dependencies. Technically anything can be injected and be made accessible to all components.
286
+
287
+ ```js
288
+ const componentDependencies = {
289
+ }
290
+
291
+ const pageDependencies = {
292
+ }
293
+
294
+ export const deps = {
295
+ components: componentDependencies,
296
+ pages: pageDependencies,
297
+ }
298
+ ```
299
+
300
+
301
+ ## Testing
302
+
303
+ This framework is written with testability in mind.
304
+
305
+
306
+ ## View
307
+
308
+ Visual testing `rettangoli-vt`
309
+
310
+
311
+ ## State Store
312
+
313
+ Those are all pure functions and it is straighforward to test them. Actions can be turned into pure functions using immer produce.
314
+
315
+ Example
316
+
317
+ ```yaml
318
+ ...
319
+ ```
320
+
321
+ ## Handlers
322
+
323
+ Test them as normal functions.
324
+ They are not always pure per se due to calling of dependencies.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@rettangoli/fe",
3
+ "version": "0.0.3",
4
+ "description": "Frontend framework for building reactive web components",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "keywords": ["frontend", "reactive", "components", "web", "framework"],
8
+ "files": [
9
+ "src/*.js",
10
+ "src/!(cli)",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/yuusoft-org/rettangoli.git"
17
+ },
18
+ "license": "MIT",
19
+ "exports": {
20
+ ".": "./src/index.js",
21
+ "./cli": "./src/cli/index.js"
22
+ },
23
+ "devDependencies": {
24
+ "js-yaml": "^4.1.0",
25
+ "esbuild": "^0.25.4",
26
+ "vite": "^6.3.5"
27
+ },
28
+ "dependencies": {
29
+ "immer": "^10.1.1",
30
+ "jempl": "^0.0.6",
31
+ "rxjs": "^7.8.2",
32
+ "snabbdom": "^3.6.2"
33
+ },
34
+ "scripts": {
35
+ "dev": "node watch.js --watch"
36
+ }
37
+ }
package/src/common.js ADDED
@@ -0,0 +1,203 @@
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
+ * console.log(action, 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
+ const getQueryParamsObject = () => {
35
+ const queryParams = new URLSearchParams(window.location.search + "");
36
+ const paramsObject = {};
37
+
38
+ for (const [key, value] of queryParams.entries()) {
39
+ paramsObject[key] = value;
40
+ }
41
+
42
+ return paramsObject;
43
+ };
44
+
45
+ class LayoutOptions {
46
+ constructor(params) {
47
+ this._isTouchLayout = params?.isTouchLayout || false;
48
+ }
49
+
50
+ get isTouchLayout() {
51
+ return this._isTouchLayout;
52
+ }
53
+
54
+ setIsTouchLayout = (isTouchLayout) => {
55
+ this._isTouchLayout = isTouchLayout;
56
+ };
57
+ }
58
+
59
+ const matchPaths = (path, pattern) => {
60
+ // Normalize paths by removing trailing slashes
61
+ const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path;
62
+ const normalizedPattern = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
63
+
64
+ // Convert pattern segments with parameters like [id] to regex patterns
65
+ const regexPattern = normalizedPattern
66
+ .split("/")
67
+ .map((segment) => {
68
+ // Check if segment is a parameter (enclosed in square brackets)
69
+ if (segment.startsWith("[") && segment.endsWith("]")) {
70
+ // Extract parameter name and create a capturing group
71
+ return "([^/]+)";
72
+ }
73
+ // Regular segment, match exactly
74
+ return segment;
75
+ })
76
+ .join("/");
77
+
78
+ // Create regex with start and end anchors
79
+ const regex = new RegExp(`^${regexPattern}$`);
80
+
81
+ // Test if path matches the pattern
82
+ return regex.test(normalizedPath);
83
+ };
84
+
85
+
86
+ class Request {
87
+ _authToken;
88
+
89
+ constructor(baseUrl, authToken, headers) {
90
+ this._baseUrl = baseUrl;
91
+ this._authToken = authToken;
92
+ this._headers = headers;
93
+ }
94
+
95
+ setAuthToken(token) {
96
+ this._authToken = token;
97
+ }
98
+
99
+ async request(name, payload, options) {
100
+ const headers = {
101
+ "Content-Type": "application/json",
102
+ ...this._headers,
103
+ };
104
+ if (this._authToken) {
105
+ headers.Authorization = `Bearer ${this._authToken}`;
106
+ }
107
+
108
+ const response = await fetch(`${this._baseUrl}/${name}`, {
109
+ method: "POST",
110
+ body: JSON.stringify(payload),
111
+ headers,
112
+ credentials: options?.includeCredentials ? "include" : undefined,
113
+ });
114
+
115
+ return response.json();
116
+ }
117
+ }
118
+
119
+ /**
120
+ * @typedef {Object} ApiEndpointConfig
121
+ * @property {boolean} [includeCredentials] - Whether to include credentials in the request
122
+ */
123
+
124
+ /**
125
+ * Creates an HTTP client with a flexible JSON configuration.
126
+ * The generated client provides typed methods for each API endpoint based on the configuration.
127
+ *
128
+ * @param {Object} config - The client configuration
129
+ * @param {string} config.baseUrl - Base URL for all API requests
130
+ * @param {Object} [config.headers={}] - Default headers for all requests
131
+ * @param {Object.<string, Object.<string, ApiEndpointConfig>>} [config.apis={}] - API configuration object
132
+ * @returns {Object} The configured HTTP client with API methods
133
+ */
134
+ export function createHttpClient(config) {
135
+ const { baseUrl, apis = {}, headers = {} } = config;
136
+ const requests = new Map();
137
+
138
+ const httpClient = {};
139
+
140
+ // Create request instances and client structure from configuration
141
+ Object.entries(apis).forEach(([apiName, endpoints]) => {
142
+ const apiBaseUrl = `${baseUrl}/${apiName}`;
143
+ const request = new Request(apiBaseUrl, undefined, headers);
144
+ requests.set(apiName, request);
145
+
146
+ // Create API namespace on the client
147
+ httpClient[apiName] = {};
148
+
149
+ // Create methods for each endpoint
150
+ Object.entries(endpoints).forEach(([endpointName, options]) => {
151
+ httpClient[apiName][endpointName] = (body) => {
152
+ return request.request(endpointName, body, options);
153
+ };
154
+ });
155
+ });
156
+
157
+ // Add setAuthToken method to the client
158
+ httpClient.setAuthToken = (token) => {
159
+ for (const request of requests.values()) {
160
+ request.setAuthToken(token);
161
+ }
162
+ };
163
+
164
+ return httpClient;
165
+ }
166
+
167
+
168
+ export const extractCategoryAndComponent = (filePath) => {
169
+ const parts = filePath.split("/");
170
+ const component = parts[parts.length - 1].split(".")[0];
171
+ const category = parts[parts.length - 3];
172
+ const fileType = parts[parts.length - 1].split(".")[1];
173
+ return { category, component, fileType };
174
+ }
175
+
176
+
177
+
178
+ // Helper function to flatten arrays while preserving object structure
179
+ export const flattenArrays = (items) => {
180
+ if (!Array.isArray(items)) {
181
+ return items;
182
+ }
183
+
184
+ return items.reduce((acc, item) => {
185
+ if (Array.isArray(item)) {
186
+ // Recursively flatten nested arrays
187
+ acc.push(...flattenArrays(item));
188
+ } else {
189
+ // If it's an object with nested arrays, process those too
190
+ if (item && typeof item === "object") {
191
+ const entries = Object.entries(item);
192
+ if (entries.length > 0) {
193
+ const [key, value] = entries[0];
194
+ if (Array.isArray(value)) {
195
+ item = { [key]: flattenArrays(value) };
196
+ }
197
+ }
198
+ }
199
+ acc.push(item);
200
+ }
201
+ return acc;
202
+ }, []);
203
+ };
@@ -0,0 +1,22 @@
1
+
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+
6
+ // Function to recursively get all files in a directory
7
+ export function getAllFiles(dirPaths, arrayOfFiles = []) {
8
+ dirPaths.forEach((dirPath) => {
9
+ const files = readdirSync(dirPath);
10
+
11
+ files.forEach((file) => {
12
+ const fullPath = join(dirPath, file);
13
+ if (statSync(fullPath).isDirectory()) {
14
+ arrayOfFiles = getAllFiles([fullPath], arrayOfFiles);
15
+ } else {
16
+ arrayOfFiles.push(fullPath);
17
+ }
18
+ });
19
+ });
20
+
21
+ return arrayOfFiles;
22
+ }