cajoa 0.4.0 → 0.5.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
@@ -3,7 +3,7 @@
3
3
  A small, beginner-friendly frontend framework built on **signals** and **JSX**.
4
4
  No virtual DOM, no hooks rules, no re-rendering components.
5
5
 
6
- > ⚠️ **v0.4 (early stage).** The API may still change. Not production-ready yet.
6
+ > ⚠️ **v0.5 (early stage).** The API may still change. Not production-ready yet.
7
7
 
8
8
  ## Why Cajoa?
9
9
 
@@ -15,7 +15,7 @@ No virtual DOM, no hooks rules, no re-rendering components.
15
15
  ## Install
16
16
 
17
17
  ```bash
18
- npm install cajoa
18
+ npm create cajoa@latest my-app
19
19
  ```
20
20
 
21
21
  ## Setup (JSX)
@@ -159,6 +159,14 @@ Routes are matched in order, so put `path="*"` last.
159
159
  | `usePath()` | Returns a signal getter for the current pathname. |
160
160
  | `untrack(fn)` | Runs `fn` without subscribing to signals. |
161
161
 
162
+ ### Renderer
163
+
164
+ | Function | What it does |
165
+ | ----------------------- | ------------------------------------------------------------------ |
166
+ | `setRenderer(renderer)` | Swaps the renderer (default: DOM). Used to target other platforms. |
167
+ | `getRenderer()` | Returns the current renderer. |
168
+ | `domRenderer` | The built-in DOM renderer. |
169
+
162
170
  ## Good to know
163
171
 
164
172
  - `For` uses the item itself as the key by default. If you pass `key`, items with the same key are **not** re-rendered when their data changes, so keys must be unique and the item should hold its own signals if it needs to update.
@@ -174,6 +182,7 @@ Routes are matched in order, so put `path="*"` last.
174
182
  - Single-page apps need a server fallback to `index.html` for unknown paths.
175
183
  - `createMemo` can show a brief inconsistent state when memos depend on each other.
176
184
  - `set` calls are not batched automatically inside event handlers. Wrap them in `batch()`.
185
+ - The Router and event names (`onClick`) are still web-specific for now.
177
186
 
178
187
  ## Roadmap
179
188
 
@@ -185,8 +194,11 @@ Routes are matched in order, so put `path="*"` last.
185
194
  - [x] Context
186
195
  - [x] JSX support
187
196
  - [x] Fragments
188
- - [ ] Vite plugin (removes the two rules above)
189
197
  - [x] Router
198
+ - [x] Renderer abstraction (core no longer depends on DOM)
199
+ - [ ] Split into packages ('@cajoa/core', '@cajoa/dom')
200
+ - [ ] Vite plugin (removes the two rules above)
201
+ - [ ] Native renderer (mobile, desktop)
190
202
  - [ ] SSR
191
203
 
192
204
  ## Development
package/dist/index.cjs CHANGED
@@ -32,6 +32,8 @@ __export(index_exports, {
32
32
  createMemo: () => createMemo,
33
33
  createRoot: () => createRoot,
34
34
  createSignal: () => createSignal,
35
+ domRenderer: () => domRenderer,
36
+ getRenderer: () => getRenderer,
35
37
  h: () => h,
36
38
  jsx: () => jsx,
37
39
  jsxDEV: () => jsxDEV,
@@ -41,6 +43,7 @@ __export(index_exports, {
41
43
  onMount: () => onMount,
42
44
  provide: () => provide,
43
45
  render: () => render,
46
+ setRenderer: () => setRenderer,
44
47
  untrack: () => untrack,
45
48
  useContext: () => useContext,
46
49
  useParams: () => useParams,
@@ -190,83 +193,109 @@ function untrack(fn) {
190
193
  }
191
194
  }
192
195
 
196
+ // src/renderer/dom.ts
197
+ var domRenderer = {
198
+ createElement: (tag) => document.createElement(tag),
199
+ createText: (value) => document.createTextNode(value),
200
+ createMarker: () => document.createComment(""),
201
+ createFragment: () => document.createDocumentFragment(),
202
+ isNode: (v) => typeof Node !== "undefined" && v instanceof Node,
203
+ insert: (parent, node, anchor = null) => {
204
+ parent.insertBefore(node, anchor);
205
+ },
206
+ remove: (node) => {
207
+ node.remove();
208
+ },
209
+ nextSibling: (node) => node.nextSibling,
210
+ parentNode: (node) => node.parentNode,
211
+ clear: (parent) => {
212
+ parent.replaceChildren();
213
+ },
214
+ setProp(el, key, value) {
215
+ const e = el;
216
+ if (key.startsWith("on") && typeof value === "function") {
217
+ e.addEventListener(key.slice(2).toLowerCase(), value);
218
+ return;
219
+ }
220
+ const name = key === "className" ? "class" : key;
221
+ if (value == null || value === false) e.removeAttribute(name);
222
+ else e.setAttribute(name, value === true ? "" : String(value));
223
+ }
224
+ };
225
+
226
+ // src/renderer/index.ts
227
+ var current = domRenderer;
228
+ function setRenderer(renderer) {
229
+ current = renderer;
230
+ }
231
+ function getRenderer() {
232
+ return current;
233
+ }
234
+
193
235
  // src/dom/h.ts
194
236
  function h(tag, props, ...children) {
195
- const el = document.createElement(tag);
237
+ const r = getRenderer();
238
+ const el = r.createElement(tag);
196
239
  for (const [key, value] of Object.entries(props ?? {})) {
197
- setProp(el, key, value);
240
+ if (typeof value === "function" && !key.startsWith("on")) {
241
+ createEffect(() => r.setProp(el, key, value()));
242
+ } else {
243
+ r.setProp(el, key, value);
244
+ }
198
245
  }
199
246
  appendChildren(el, children);
200
247
  return el;
201
248
  }
202
- function setProp(el, key, value) {
203
- if (key.startsWith("on") && typeof value === "function") {
204
- el.addEventListener(key.slice(2).toLowerCase(), value);
205
- } else if (typeof value === "function") {
206
- createEffect(() => applyAttr(el, key, value()));
207
- } else {
208
- applyAttr(el, key, value);
209
- }
210
- }
211
- function applyAttr(el, key, value) {
212
- const name = key === "className" ? "class" : key;
213
- if (value == null || value === false) el.removeAttribute(name);
214
- else el.setAttribute(name, value === true ? "" : String(value));
215
- }
216
249
  function toNodes(value) {
250
+ const r = getRenderer();
217
251
  const out = [];
218
252
  for (const v of [value].flat(Infinity)) {
219
253
  if (v == null || typeof v === "boolean") continue;
220
- out.push(v instanceof Node ? v : document.createTextNode(String(v)));
254
+ out.push(r.isNode(v) ? v : r.createText(String(v)));
221
255
  }
222
256
  return out;
223
257
  }
224
- function appendChildren(parent, children) {
225
- const pending2 = [...children].reverse();
226
- while (pending2.length > 0) {
227
- const child = pending2.pop();
228
- if (Array.isArray(child)) {
229
- for (let i = child.length - 1; i >= 0; i--) {
230
- pending2.push(child[i]);
231
- }
232
- continue;
233
- }
234
- if (typeof child === "function") {
235
- const start = document.createComment("");
236
- const end = document.createComment("");
237
- parent.append(start, end);
238
- createEffect(() => {
239
- reconcile(start, end, toNodes(child()));
240
- });
241
- } else {
242
- parent.append(...toNodes(child));
243
- }
244
- }
245
- }
246
258
  function reconcile(start, end, next) {
259
+ const r = getRenderer();
247
260
  const keep = new Set(next);
248
- let cur = start.nextSibling;
261
+ let cur = r.nextSibling(start);
249
262
  while (cur && cur !== end) {
250
- const after = cur.nextSibling;
251
- if (!keep.has(cur)) cur.remove();
263
+ const after = r.nextSibling(cur);
264
+ if (!keep.has(cur)) r.remove(cur);
252
265
  cur = after;
253
266
  }
254
- const parent = end.parentNode;
255
- let ref = start.nextSibling;
267
+ const parent = r.parentNode(end);
268
+ let ref = r.nextSibling(start);
256
269
  for (const node of next) {
257
- if (node === ref)
258
- ref = ref.nextSibling;
259
- else parent.insertBefore(node, ref);
270
+ if (node === ref) ref = r.nextSibling(ref);
271
+ else r.insert(parent, node, ref);
272
+ }
273
+ }
274
+ function appendChildren(parent, children) {
275
+ const r = getRenderer();
276
+ for (const child of children.flat(Infinity)) {
277
+ if (typeof child === "function") {
278
+ const start = r.createMarker();
279
+ const end = r.createMarker();
280
+ r.insert(parent, start);
281
+ r.insert(parent, end);
282
+ createEffect(() => reconcile(start, end, toNodes(child())));
283
+ } else {
284
+ for (const node of toNodes(child)) r.insert(parent, node);
285
+ }
260
286
  }
261
287
  }
262
288
 
263
289
  // src/dom/render.ts
264
290
  function render(component, container) {
265
291
  return createRoot((dispose) => {
266
- container.replaceChildren(component());
292
+ const r = getRenderer();
293
+ const node = component();
294
+ r.clear(container);
295
+ r.insert(container, node);
267
296
  return () => {
268
297
  dispose();
269
- container.replaceChildren();
298
+ r.clear(container);
270
299
  };
271
300
  });
272
301
  }
@@ -325,7 +354,7 @@ function jsx(type, props) {
325
354
  var jsxs = jsx;
326
355
  var jsxDEV = jsx;
327
356
  function Fragment(props) {
328
- const frag = document.createDocumentFragment();
357
+ const frag = getRenderer().createFragment();
329
358
  appendChildren(frag, props.children === void 0 ? [] : [props.children]);
330
359
  return frag;
331
360
  }
@@ -378,9 +407,9 @@ function Router(props) {
378
407
  (r) => r?.__route === true
379
408
  );
380
409
  return () => {
381
- const current = path();
410
+ const current2 = path();
382
411
  for (const route of routes) {
383
- const params = match(route.path, current);
412
+ const params = match(route.path, current2);
384
413
  if (params) {
385
414
  return untrack(() => provide(Params, params, () => route.component()));
386
415
  }
@@ -413,6 +442,8 @@ function Link(props) {
413
442
  createMemo,
414
443
  createRoot,
415
444
  createSignal,
445
+ domRenderer,
446
+ getRenderer,
416
447
  h,
417
448
  jsx,
418
449
  jsxDEV,
@@ -422,6 +453,7 @@ function Link(props) {
422
453
  onMount,
423
454
  provide,
424
455
  render,
456
+ setRenderer,
425
457
  untrack,
426
458
  useContext,
427
459
  useParams,
package/dist/index.d.cts CHANGED
@@ -47,7 +47,7 @@ declare namespace JSX {
47
47
  }
48
48
  declare function Fragment(props: {
49
49
  children?: Child;
50
- }): DocumentFragment;
50
+ }): any;
51
51
 
52
52
  declare function navigate(to: string, options?: {
53
53
  replace?: boolean;
@@ -72,5 +72,24 @@ declare function Link(props: {
72
72
  [key: string]: unknown;
73
73
  }): HTMLElement;
74
74
 
75
- export { For, Fragment, JSX, Link, Route, Router, Show, batch, createContext, createEffect, createMemo, createRoot, createSignal, h, jsx, jsxDEV, jsxs, navigate, onCleanup, onMount, provide, render, untrack, useContext, useParams, usePath };
76
- export type { Child, Context, RouteDef };
75
+ interface Renderer<N = any> {
76
+ createElement(tag: string): N;
77
+ createText(value: string): N;
78
+ createMarker(): N;
79
+ createFragment(): N;
80
+ isNode(value: unknown): value is N;
81
+ insert(parent: N, node: N, anchor?: N | null): void;
82
+ remove(node: N): void;
83
+ nextSibling(node: N): N | null;
84
+ parentNode(node: N): N | null;
85
+ clear(parent: N): void;
86
+ setProp(el: N, name: string, value: unknown): void;
87
+ }
88
+
89
+ declare function setRenderer(renderer: Renderer): void;
90
+ declare function getRenderer(): Renderer;
91
+
92
+ declare const domRenderer: Renderer<Node>;
93
+
94
+ export { For, Fragment, JSX, Link, Route, Router, Show, batch, createContext, createEffect, createMemo, createRoot, createSignal, domRenderer, getRenderer, h, jsx, jsxDEV, jsxs, navigate, onCleanup, onMount, provide, render, setRenderer, untrack, useContext, useParams, usePath };
95
+ export type { Child, Context, Renderer, RouteDef };
package/dist/index.d.ts CHANGED
@@ -47,7 +47,7 @@ declare namespace JSX {
47
47
  }
48
48
  declare function Fragment(props: {
49
49
  children?: Child;
50
- }): DocumentFragment;
50
+ }): any;
51
51
 
52
52
  declare function navigate(to: string, options?: {
53
53
  replace?: boolean;
@@ -72,5 +72,24 @@ declare function Link(props: {
72
72
  [key: string]: unknown;
73
73
  }): HTMLElement;
74
74
 
75
- export { For, Fragment, JSX, Link, Route, Router, Show, batch, createContext, createEffect, createMemo, createRoot, createSignal, h, jsx, jsxDEV, jsxs, navigate, onCleanup, onMount, provide, render, untrack, useContext, useParams, usePath };
76
- export type { Child, Context, RouteDef };
75
+ interface Renderer<N = any> {
76
+ createElement(tag: string): N;
77
+ createText(value: string): N;
78
+ createMarker(): N;
79
+ createFragment(): N;
80
+ isNode(value: unknown): value is N;
81
+ insert(parent: N, node: N, anchor?: N | null): void;
82
+ remove(node: N): void;
83
+ nextSibling(node: N): N | null;
84
+ parentNode(node: N): N | null;
85
+ clear(parent: N): void;
86
+ setProp(el: N, name: string, value: unknown): void;
87
+ }
88
+
89
+ declare function setRenderer(renderer: Renderer): void;
90
+ declare function getRenderer(): Renderer;
91
+
92
+ declare const domRenderer: Renderer<Node>;
93
+
94
+ export { For, Fragment, JSX, Link, Route, Router, Show, batch, createContext, createEffect, createMemo, createRoot, createSignal, domRenderer, getRenderer, h, jsx, jsxDEV, jsxs, navigate, onCleanup, onMount, provide, render, setRenderer, untrack, useContext, useParams, usePath };
95
+ export type { Child, Context, Renderer, RouteDef };
package/dist/index.js CHANGED
@@ -140,83 +140,109 @@ function untrack(fn) {
140
140
  }
141
141
  }
142
142
 
143
+ // src/renderer/dom.ts
144
+ var domRenderer = {
145
+ createElement: (tag) => document.createElement(tag),
146
+ createText: (value) => document.createTextNode(value),
147
+ createMarker: () => document.createComment(""),
148
+ createFragment: () => document.createDocumentFragment(),
149
+ isNode: (v) => typeof Node !== "undefined" && v instanceof Node,
150
+ insert: (parent, node, anchor = null) => {
151
+ parent.insertBefore(node, anchor);
152
+ },
153
+ remove: (node) => {
154
+ node.remove();
155
+ },
156
+ nextSibling: (node) => node.nextSibling,
157
+ parentNode: (node) => node.parentNode,
158
+ clear: (parent) => {
159
+ parent.replaceChildren();
160
+ },
161
+ setProp(el, key, value) {
162
+ const e = el;
163
+ if (key.startsWith("on") && typeof value === "function") {
164
+ e.addEventListener(key.slice(2).toLowerCase(), value);
165
+ return;
166
+ }
167
+ const name = key === "className" ? "class" : key;
168
+ if (value == null || value === false) e.removeAttribute(name);
169
+ else e.setAttribute(name, value === true ? "" : String(value));
170
+ }
171
+ };
172
+
173
+ // src/renderer/index.ts
174
+ var current = domRenderer;
175
+ function setRenderer(renderer) {
176
+ current = renderer;
177
+ }
178
+ function getRenderer() {
179
+ return current;
180
+ }
181
+
143
182
  // src/dom/h.ts
144
183
  function h(tag, props, ...children) {
145
- const el = document.createElement(tag);
184
+ const r = getRenderer();
185
+ const el = r.createElement(tag);
146
186
  for (const [key, value] of Object.entries(props ?? {})) {
147
- setProp(el, key, value);
187
+ if (typeof value === "function" && !key.startsWith("on")) {
188
+ createEffect(() => r.setProp(el, key, value()));
189
+ } else {
190
+ r.setProp(el, key, value);
191
+ }
148
192
  }
149
193
  appendChildren(el, children);
150
194
  return el;
151
195
  }
152
- function setProp(el, key, value) {
153
- if (key.startsWith("on") && typeof value === "function") {
154
- el.addEventListener(key.slice(2).toLowerCase(), value);
155
- } else if (typeof value === "function") {
156
- createEffect(() => applyAttr(el, key, value()));
157
- } else {
158
- applyAttr(el, key, value);
159
- }
160
- }
161
- function applyAttr(el, key, value) {
162
- const name = key === "className" ? "class" : key;
163
- if (value == null || value === false) el.removeAttribute(name);
164
- else el.setAttribute(name, value === true ? "" : String(value));
165
- }
166
196
  function toNodes(value) {
197
+ const r = getRenderer();
167
198
  const out = [];
168
199
  for (const v of [value].flat(Infinity)) {
169
200
  if (v == null || typeof v === "boolean") continue;
170
- out.push(v instanceof Node ? v : document.createTextNode(String(v)));
201
+ out.push(r.isNode(v) ? v : r.createText(String(v)));
171
202
  }
172
203
  return out;
173
204
  }
174
- function appendChildren(parent, children) {
175
- const pending2 = [...children].reverse();
176
- while (pending2.length > 0) {
177
- const child = pending2.pop();
178
- if (Array.isArray(child)) {
179
- for (let i = child.length - 1; i >= 0; i--) {
180
- pending2.push(child[i]);
181
- }
182
- continue;
183
- }
184
- if (typeof child === "function") {
185
- const start = document.createComment("");
186
- const end = document.createComment("");
187
- parent.append(start, end);
188
- createEffect(() => {
189
- reconcile(start, end, toNodes(child()));
190
- });
191
- } else {
192
- parent.append(...toNodes(child));
193
- }
194
- }
195
- }
196
205
  function reconcile(start, end, next) {
206
+ const r = getRenderer();
197
207
  const keep = new Set(next);
198
- let cur = start.nextSibling;
208
+ let cur = r.nextSibling(start);
199
209
  while (cur && cur !== end) {
200
- const after = cur.nextSibling;
201
- if (!keep.has(cur)) cur.remove();
210
+ const after = r.nextSibling(cur);
211
+ if (!keep.has(cur)) r.remove(cur);
202
212
  cur = after;
203
213
  }
204
- const parent = end.parentNode;
205
- let ref = start.nextSibling;
214
+ const parent = r.parentNode(end);
215
+ let ref = r.nextSibling(start);
206
216
  for (const node of next) {
207
- if (node === ref)
208
- ref = ref.nextSibling;
209
- else parent.insertBefore(node, ref);
217
+ if (node === ref) ref = r.nextSibling(ref);
218
+ else r.insert(parent, node, ref);
219
+ }
220
+ }
221
+ function appendChildren(parent, children) {
222
+ const r = getRenderer();
223
+ for (const child of children.flat(Infinity)) {
224
+ if (typeof child === "function") {
225
+ const start = r.createMarker();
226
+ const end = r.createMarker();
227
+ r.insert(parent, start);
228
+ r.insert(parent, end);
229
+ createEffect(() => reconcile(start, end, toNodes(child())));
230
+ } else {
231
+ for (const node of toNodes(child)) r.insert(parent, node);
232
+ }
210
233
  }
211
234
  }
212
235
 
213
236
  // src/dom/render.ts
214
237
  function render(component, container) {
215
238
  return createRoot((dispose) => {
216
- container.replaceChildren(component());
239
+ const r = getRenderer();
240
+ const node = component();
241
+ r.clear(container);
242
+ r.insert(container, node);
217
243
  return () => {
218
244
  dispose();
219
- container.replaceChildren();
245
+ r.clear(container);
220
246
  };
221
247
  });
222
248
  }
@@ -275,7 +301,7 @@ function jsx(type, props) {
275
301
  var jsxs = jsx;
276
302
  var jsxDEV = jsx;
277
303
  function Fragment(props) {
278
- const frag = document.createDocumentFragment();
304
+ const frag = getRenderer().createFragment();
279
305
  appendChildren(frag, props.children === void 0 ? [] : [props.children]);
280
306
  return frag;
281
307
  }
@@ -328,9 +354,9 @@ function Router(props) {
328
354
  (r) => r?.__route === true
329
355
  );
330
356
  return () => {
331
- const current = path();
357
+ const current2 = path();
332
358
  for (const route of routes) {
333
- const params = match(route.path, current);
359
+ const params = match(route.path, current2);
334
360
  if (params) {
335
361
  return untrack(() => provide(Params, params, () => route.component()));
336
362
  }
@@ -362,6 +388,8 @@ export {
362
388
  createMemo,
363
389
  createRoot,
364
390
  createSignal,
391
+ domRenderer,
392
+ getRenderer,
365
393
  h,
366
394
  jsx,
367
395
  jsxDEV,
@@ -371,6 +399,7 @@ export {
371
399
  onMount,
372
400
  provide,
373
401
  render,
402
+ setRenderer,
374
403
  untrack,
375
404
  useContext,
376
405
  useParams,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cajoa",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A beginner-friendly, signals-based frontend framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",