cajoa 0.3.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.3 (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)
@@ -80,6 +80,32 @@ const [todos, setTodos] = createSignal([{ id: 1, text: "Learn Cajoa" }]);
80
80
  </Show>;
81
81
  ```
82
82
 
83
+ ## Routing
84
+
85
+ ```tsx
86
+ import { render, Router, Route, Link, useParams } from "cajoa";
87
+
88
+ const User = () => <h1>User {useParams().id}</h1>;
89
+
90
+ render(
91
+ () => (
92
+ <div>
93
+ <nav>
94
+ <Link href="/">Home</Link> <Link href="/users/7">User 7</Link>
95
+ </nav>
96
+ <Router>
97
+ <Route path="/" component={() => <h1>Home</h1>} />
98
+ <Route path="/users/:id" component={User} />
99
+ <Route path="*" component={() => <h1>Not found</h1>} />
100
+ </Router>
101
+ </div>
102
+ ),
103
+ document.getElementById("app")!,
104
+ );
105
+ ```
106
+
107
+ Routes are matched in order, so put `path="*"` last.
108
+
83
109
  ## API
84
110
 
85
111
  ### Reactivity
@@ -121,18 +147,42 @@ const [todos, setTodos] = createSignal([{ id: 1, text: "Learn Cajoa" }]);
121
147
  | `provide(ctx, value, fn)` | Runs `fn`; everything created inside can read `value`. |
122
148
  | `useContext(ctx)` | Reads the nearest provided value (or the default). |
123
149
 
150
+ ### Router
151
+
152
+ | Function | What it does |
153
+ | ---------------------------- | ---------------------------------------------------------- |
154
+ | `<Router>` | Renders the first `<Route>` that matches the current path. |
155
+ | `<Route path component>` | Maps a path (`/users/:id`, `*`) to a component. |
156
+ | `<Link href>` | An `<a>` that navigates without reloading the page. |
157
+ | `navigate(to, { replace? })` | Navigates programmatically. |
158
+ | `useParams()` | Reads the route params (`{ id: "7" }`). |
159
+ | `usePath()` | Returns a signal getter for the current pathname. |
160
+ | `untrack(fn)` | Runs `fn` without subscribing to signals. |
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
+
124
170
  ## Good to know
125
171
 
126
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.
127
173
  - `useContext` only works while a component is being created (or inside `onMount` / effects). Read it at the top of your component and use the value later in handlers.
128
174
  - Updating an object in place will not trigger effects. Set a new object instead.
175
+ - Fragments (`<>...</>`) work anywhere, but inside `<For>` the child must be a single element, because `For` reuses that node.
129
176
 
130
177
  ## Known limitations
131
178
 
132
- - No Fragments (`<>...</>`) yet. Wrap siblings in an element.
133
179
  - No SSR.
180
+ - Router: pathname only (no query/hash handling), no nested routes, no `base` path.
181
+ - `<Show>` and `<Router>` return a function, so they must sit inside an element, not be the root returned by `render`.
182
+ - Single-page apps need a server fallback to `index.html` for unknown paths.
134
183
  - `createMemo` can show a brief inconsistent state when memos depend on each other.
135
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.
136
186
 
137
187
  ## Roadmap
138
188
 
@@ -143,9 +193,12 @@ const [todos, setTodos] = createSignal([{ id: 1, text: "Learn Cajoa" }]);
143
193
  - [x] `createMemo`, `batch`
144
194
  - [x] Context
145
195
  - [x] JSX support
146
- - [ ] Fragments
196
+ - [x] Fragments
197
+ - [x] Router
198
+ - [x] Renderer abstraction (core no longer depends on DOM)
199
+ - [ ] Split into packages ('@cajoa/core', '@cajoa/dom')
147
200
  - [ ] Vite plugin (removes the two rules above)
148
- - [ ] Router
201
+ - [ ] Native renderer (mobile, desktop)
149
202
  - [ ] SSR
150
203
 
151
204
  ## Development
package/dist/index.cjs CHANGED
@@ -21,6 +21,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  For: () => For,
24
+ Fragment: () => Fragment,
25
+ Link: () => Link,
26
+ Route: () => Route,
27
+ Router: () => Router,
24
28
  Show: () => Show,
25
29
  batch: () => batch,
26
30
  createContext: () => createContext,
@@ -28,15 +32,22 @@ __export(index_exports, {
28
32
  createMemo: () => createMemo,
29
33
  createRoot: () => createRoot,
30
34
  createSignal: () => createSignal,
35
+ domRenderer: () => domRenderer,
36
+ getRenderer: () => getRenderer,
31
37
  h: () => h,
32
38
  jsx: () => jsx,
33
39
  jsxDEV: () => jsxDEV,
34
40
  jsxs: () => jsxs,
41
+ navigate: () => navigate,
35
42
  onCleanup: () => onCleanup,
36
43
  onMount: () => onMount,
37
44
  provide: () => provide,
38
45
  render: () => render,
39
- useContext: () => useContext
46
+ setRenderer: () => setRenderer,
47
+ untrack: () => untrack,
48
+ useContext: () => useContext,
49
+ useParams: () => useParams,
50
+ usePath: () => usePath
40
51
  });
41
52
  module.exports = __toCommonJS(index_exports);
42
53
 
@@ -172,91 +183,134 @@ function useContext(ctx) {
172
183
  }
173
184
  return ctx.defaultValue;
174
185
  }
186
+ function untrack(fn) {
187
+ const prev = currentEffect;
188
+ currentEffect = null;
189
+ try {
190
+ return fn();
191
+ } finally {
192
+ currentEffect = prev;
193
+ }
194
+ }
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
+ }
175
234
 
176
235
  // src/dom/h.ts
177
236
  function h(tag, props, ...children) {
178
- const el = document.createElement(tag);
237
+ const r = getRenderer();
238
+ const el = r.createElement(tag);
179
239
  for (const [key, value] of Object.entries(props ?? {})) {
180
- 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
+ }
181
245
  }
182
246
  appendChildren(el, children);
183
247
  return el;
184
248
  }
185
- function setProp(el, key, value) {
186
- if (key.startsWith("on") && typeof value === "function") {
187
- el.addEventListener(key.slice(2).toLowerCase(), value);
188
- } else if (typeof value === "function") {
189
- createEffect(() => applyAttr(el, key, value()));
190
- } else {
191
- applyAttr(el, key, value);
192
- }
193
- }
194
- function applyAttr(el, key, value) {
195
- const name = key === "className" ? "class" : key;
196
- if (value == null || value === false) el.removeAttribute(name);
197
- else el.setAttribute(name, value === true ? "" : String(value));
198
- }
199
249
  function toNodes(value) {
250
+ const r = getRenderer();
200
251
  const out = [];
201
252
  for (const v of [value].flat(Infinity)) {
202
253
  if (v == null || typeof v === "boolean") continue;
203
- out.push(v instanceof Node ? v : document.createTextNode(String(v)));
254
+ out.push(r.isNode(v) ? v : r.createText(String(v)));
204
255
  }
205
256
  return out;
206
257
  }
207
- function appendChildren(parent, children) {
208
- const pending2 = [...children].reverse();
209
- while (pending2.length > 0) {
210
- const child = pending2.pop();
211
- if (Array.isArray(child)) {
212
- for (let i = child.length - 1; i >= 0; i--) {
213
- pending2.push(child[i]);
214
- }
215
- continue;
216
- }
217
- if (typeof child === "function") {
218
- const start = document.createComment("");
219
- const end = document.createComment("");
220
- parent.append(start, end);
221
- createEffect(() => {
222
- reconcile(start, end, toNodes(child()));
223
- });
224
- } else {
225
- parent.append(...toNodes(child));
226
- }
227
- }
228
- }
229
258
  function reconcile(start, end, next) {
259
+ const r = getRenderer();
230
260
  const keep = new Set(next);
231
- let cur = start.nextSibling;
261
+ let cur = r.nextSibling(start);
232
262
  while (cur && cur !== end) {
233
- const after = cur.nextSibling;
234
- if (!keep.has(cur)) cur.remove();
263
+ const after = r.nextSibling(cur);
264
+ if (!keep.has(cur)) r.remove(cur);
235
265
  cur = after;
236
266
  }
237
- const parent = end.parentNode;
238
- let ref = start.nextSibling;
267
+ const parent = r.parentNode(end);
268
+ let ref = r.nextSibling(start);
239
269
  for (const node of next) {
240
- if (node === ref)
241
- ref = ref.nextSibling;
242
- 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
+ }
243
286
  }
244
287
  }
245
288
 
246
289
  // src/dom/render.ts
247
290
  function render(component, container) {
248
291
  return createRoot((dispose) => {
249
- container.replaceChildren(component());
292
+ const r = getRenderer();
293
+ const node = component();
294
+ r.clear(container);
295
+ r.insert(container, node);
250
296
  return () => {
251
297
  dispose();
252
- container.replaceChildren();
298
+ r.clear(container);
253
299
  };
254
300
  });
255
301
  }
256
302
 
303
+ // src/reactivity/memo.ts
304
+ function createMemo(fn) {
305
+ const [value, setValue] = createSignal(void 0);
306
+ createEffect(() => setValue(() => fn()));
307
+ return value;
308
+ }
309
+
257
310
  // src/components/show.ts
258
311
  function Show(props) {
259
- return () => props.when() ? props.children() : props.fallback?.() ?? null;
312
+ const visible = createMemo(() => !!props.when());
313
+ return () => visible() ? untrack(() => props.children()) : untrack(() => props.fallback?.() ?? null);
260
314
  }
261
315
 
262
316
  // src/components/for.ts
@@ -290,13 +344,6 @@ function For(props) {
290
344
  };
291
345
  }
292
346
 
293
- // src/reactivity/memo.ts
294
- function createMemo(fn) {
295
- const [value, setValue] = createSignal(void 0);
296
- createEffect(() => setValue(() => fn()));
297
- return value;
298
- }
299
-
300
347
  // src/jsx-runtime.ts
301
348
  function jsx(type, props) {
302
349
  if (typeof type === "function") return type(props ?? {});
@@ -306,9 +353,88 @@ function jsx(type, props) {
306
353
  }
307
354
  var jsxs = jsx;
308
355
  var jsxDEV = jsx;
356
+ function Fragment(props) {
357
+ const frag = getRenderer().createFragment();
358
+ appendChildren(frag, props.children === void 0 ? [] : [props.children]);
359
+ return frag;
360
+ }
361
+
362
+ // src/router.ts
363
+ var read = () => typeof location === "undefined" ? "/" : location.pathname;
364
+ var [path, setPath] = createSignal(read());
365
+ var listening = false;
366
+ function listen() {
367
+ if (listening || typeof window === "undefined") return;
368
+ listening = true;
369
+ window.addEventListener("popstate", () => setPath(read()));
370
+ }
371
+ function navigate(to, options = {}) {
372
+ if (new URL(to, location.href).href === location.href) return;
373
+ history[options.replace ? "replaceState" : "pushState"](null, "", to);
374
+ setPath(read());
375
+ }
376
+ var usePath = () => path;
377
+ var Params = createContext({});
378
+ function useParams() {
379
+ return useContext(Params);
380
+ }
381
+ var split = (s) => s.split("/").filter(Boolean);
382
+ function decode(s) {
383
+ try {
384
+ return decodeURIComponent(s);
385
+ } catch {
386
+ return s;
387
+ }
388
+ }
389
+ function match(pattern, pathname) {
390
+ if (pattern === "*") return {};
391
+ const p = split(pattern);
392
+ const u = split(pathname);
393
+ if (p.length !== u.length) return null;
394
+ const params = {};
395
+ for (let i = 0; i < p.length; i++) {
396
+ if (p[i].startsWith(":")) params[p[i].slice(1)] = decode(u[i]);
397
+ else if (p[i] !== u[i]) return null;
398
+ }
399
+ return params;
400
+ }
401
+ function Route(props) {
402
+ return { __route: true, path: props.path, component: props.component };
403
+ }
404
+ function Router(props) {
405
+ listen();
406
+ const routes = [props.children].flat(Infinity).filter(
407
+ (r) => r?.__route === true
408
+ );
409
+ return () => {
410
+ const current2 = path();
411
+ for (const route of routes) {
412
+ const params = match(route.path, current2);
413
+ if (params) {
414
+ return untrack(() => provide(Params, params, () => route.component()));
415
+ }
416
+ }
417
+ return null;
418
+ };
419
+ }
420
+ function Link(props) {
421
+ const { href, children, ...rest } = props;
422
+ const onClick = (e) => {
423
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(href)) return;
424
+ if (e.defaultPrevented || e.button !== 0) return;
425
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
426
+ e.preventDefault();
427
+ navigate(href);
428
+ };
429
+ return h("a", { ...rest, href, onClick }, children);
430
+ }
309
431
  // Annotate the CommonJS export names for ESM import in node:
310
432
  0 && (module.exports = {
311
433
  For,
434
+ Fragment,
435
+ Link,
436
+ Route,
437
+ Router,
312
438
  Show,
313
439
  batch,
314
440
  createContext,
@@ -316,13 +442,20 @@ var jsxDEV = jsx;
316
442
  createMemo,
317
443
  createRoot,
318
444
  createSignal,
445
+ domRenderer,
446
+ getRenderer,
319
447
  h,
320
448
  jsx,
321
449
  jsxDEV,
322
450
  jsxs,
451
+ navigate,
323
452
  onCleanup,
324
453
  onMount,
325
454
  provide,
326
455
  render,
327
- useContext
456
+ setRenderer,
457
+ untrack,
458
+ useContext,
459
+ useParams,
460
+ usePath
328
461
  });
package/dist/index.d.cts CHANGED
@@ -12,6 +12,7 @@ interface Context<T> {
12
12
  declare function createContext<T>(defaultValue: T): Context<T>;
13
13
  declare function provide<T, R>(ctx: Context<T>, value: T, fn: () => R): R;
14
14
  declare function useContext<T>(ctx: Context<T>): T;
15
+ declare function untrack<T>(fn: () => T): T;
15
16
 
16
17
  type Child = string | number | boolean | null | undefined | Node | (() => unknown) | Child[];
17
18
  declare function h(tag: string, props: Record<string, any> | null, ...children: Child[]): HTMLElement;
@@ -44,6 +45,51 @@ declare namespace JSX {
44
45
  children: {};
45
46
  }
46
47
  }
48
+ declare function Fragment(props: {
49
+ children?: Child;
50
+ }): any;
47
51
 
48
- export { For, JSX, Show, batch, createContext, createEffect, createMemo, createRoot, createSignal, h, jsx, jsxDEV, jsxs, onCleanup, onMount, provide, render, useContext };
49
- export type { Child, Context };
52
+ declare function navigate(to: string, options?: {
53
+ replace?: boolean;
54
+ }): void;
55
+ declare const usePath: () => () => string;
56
+ declare function useParams(): Record<string, string>;
57
+ interface RouteDef {
58
+ __route: true;
59
+ path: string;
60
+ component: () => unknown;
61
+ }
62
+ declare function Route(props: {
63
+ path: string;
64
+ component: () => unknown;
65
+ }): RouteDef;
66
+ declare function Router(props: {
67
+ children?: unknown;
68
+ }): () => unknown;
69
+ declare function Link(props: {
70
+ href: string;
71
+ children?: unknown;
72
+ [key: string]: unknown;
73
+ }): HTMLElement;
74
+
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
@@ -12,6 +12,7 @@ interface Context<T> {
12
12
  declare function createContext<T>(defaultValue: T): Context<T>;
13
13
  declare function provide<T, R>(ctx: Context<T>, value: T, fn: () => R): R;
14
14
  declare function useContext<T>(ctx: Context<T>): T;
15
+ declare function untrack<T>(fn: () => T): T;
15
16
 
16
17
  type Child = string | number | boolean | null | undefined | Node | (() => unknown) | Child[];
17
18
  declare function h(tag: string, props: Record<string, any> | null, ...children: Child[]): HTMLElement;
@@ -44,6 +45,51 @@ declare namespace JSX {
44
45
  children: {};
45
46
  }
46
47
  }
48
+ declare function Fragment(props: {
49
+ children?: Child;
50
+ }): any;
47
51
 
48
- export { For, JSX, Show, batch, createContext, createEffect, createMemo, createRoot, createSignal, h, jsx, jsxDEV, jsxs, onCleanup, onMount, provide, render, useContext };
49
- export type { Child, Context };
52
+ declare function navigate(to: string, options?: {
53
+ replace?: boolean;
54
+ }): void;
55
+ declare const usePath: () => () => string;
56
+ declare function useParams(): Record<string, string>;
57
+ interface RouteDef {
58
+ __route: true;
59
+ path: string;
60
+ component: () => unknown;
61
+ }
62
+ declare function Route(props: {
63
+ path: string;
64
+ component: () => unknown;
65
+ }): RouteDef;
66
+ declare function Router(props: {
67
+ children?: unknown;
68
+ }): () => unknown;
69
+ declare function Link(props: {
70
+ href: string;
71
+ children?: unknown;
72
+ [key: string]: unknown;
73
+ }): HTMLElement;
74
+
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
@@ -130,91 +130,134 @@ function useContext(ctx) {
130
130
  }
131
131
  return ctx.defaultValue;
132
132
  }
133
+ function untrack(fn) {
134
+ const prev = currentEffect;
135
+ currentEffect = null;
136
+ try {
137
+ return fn();
138
+ } finally {
139
+ currentEffect = prev;
140
+ }
141
+ }
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
+ }
133
181
 
134
182
  // src/dom/h.ts
135
183
  function h(tag, props, ...children) {
136
- const el = document.createElement(tag);
184
+ const r = getRenderer();
185
+ const el = r.createElement(tag);
137
186
  for (const [key, value] of Object.entries(props ?? {})) {
138
- 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
+ }
139
192
  }
140
193
  appendChildren(el, children);
141
194
  return el;
142
195
  }
143
- function setProp(el, key, value) {
144
- if (key.startsWith("on") && typeof value === "function") {
145
- el.addEventListener(key.slice(2).toLowerCase(), value);
146
- } else if (typeof value === "function") {
147
- createEffect(() => applyAttr(el, key, value()));
148
- } else {
149
- applyAttr(el, key, value);
150
- }
151
- }
152
- function applyAttr(el, key, value) {
153
- const name = key === "className" ? "class" : key;
154
- if (value == null || value === false) el.removeAttribute(name);
155
- else el.setAttribute(name, value === true ? "" : String(value));
156
- }
157
196
  function toNodes(value) {
197
+ const r = getRenderer();
158
198
  const out = [];
159
199
  for (const v of [value].flat(Infinity)) {
160
200
  if (v == null || typeof v === "boolean") continue;
161
- out.push(v instanceof Node ? v : document.createTextNode(String(v)));
201
+ out.push(r.isNode(v) ? v : r.createText(String(v)));
162
202
  }
163
203
  return out;
164
204
  }
165
- function appendChildren(parent, children) {
166
- const pending2 = [...children].reverse();
167
- while (pending2.length > 0) {
168
- const child = pending2.pop();
169
- if (Array.isArray(child)) {
170
- for (let i = child.length - 1; i >= 0; i--) {
171
- pending2.push(child[i]);
172
- }
173
- continue;
174
- }
175
- if (typeof child === "function") {
176
- const start = document.createComment("");
177
- const end = document.createComment("");
178
- parent.append(start, end);
179
- createEffect(() => {
180
- reconcile(start, end, toNodes(child()));
181
- });
182
- } else {
183
- parent.append(...toNodes(child));
184
- }
185
- }
186
- }
187
205
  function reconcile(start, end, next) {
206
+ const r = getRenderer();
188
207
  const keep = new Set(next);
189
- let cur = start.nextSibling;
208
+ let cur = r.nextSibling(start);
190
209
  while (cur && cur !== end) {
191
- const after = cur.nextSibling;
192
- if (!keep.has(cur)) cur.remove();
210
+ const after = r.nextSibling(cur);
211
+ if (!keep.has(cur)) r.remove(cur);
193
212
  cur = after;
194
213
  }
195
- const parent = end.parentNode;
196
- let ref = start.nextSibling;
214
+ const parent = r.parentNode(end);
215
+ let ref = r.nextSibling(start);
197
216
  for (const node of next) {
198
- if (node === ref)
199
- ref = ref.nextSibling;
200
- 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
+ }
201
233
  }
202
234
  }
203
235
 
204
236
  // src/dom/render.ts
205
237
  function render(component, container) {
206
238
  return createRoot((dispose) => {
207
- container.replaceChildren(component());
239
+ const r = getRenderer();
240
+ const node = component();
241
+ r.clear(container);
242
+ r.insert(container, node);
208
243
  return () => {
209
244
  dispose();
210
- container.replaceChildren();
245
+ r.clear(container);
211
246
  };
212
247
  });
213
248
  }
214
249
 
250
+ // src/reactivity/memo.ts
251
+ function createMemo(fn) {
252
+ const [value, setValue] = createSignal(void 0);
253
+ createEffect(() => setValue(() => fn()));
254
+ return value;
255
+ }
256
+
215
257
  // src/components/show.ts
216
258
  function Show(props) {
217
- return () => props.when() ? props.children() : props.fallback?.() ?? null;
259
+ const visible = createMemo(() => !!props.when());
260
+ return () => visible() ? untrack(() => props.children()) : untrack(() => props.fallback?.() ?? null);
218
261
  }
219
262
 
220
263
  // src/components/for.ts
@@ -248,13 +291,6 @@ function For(props) {
248
291
  };
249
292
  }
250
293
 
251
- // src/reactivity/memo.ts
252
- function createMemo(fn) {
253
- const [value, setValue] = createSignal(void 0);
254
- createEffect(() => setValue(() => fn()));
255
- return value;
256
- }
257
-
258
294
  // src/jsx-runtime.ts
259
295
  function jsx(type, props) {
260
296
  if (typeof type === "function") return type(props ?? {});
@@ -264,8 +300,87 @@ function jsx(type, props) {
264
300
  }
265
301
  var jsxs = jsx;
266
302
  var jsxDEV = jsx;
303
+ function Fragment(props) {
304
+ const frag = getRenderer().createFragment();
305
+ appendChildren(frag, props.children === void 0 ? [] : [props.children]);
306
+ return frag;
307
+ }
308
+
309
+ // src/router.ts
310
+ var read = () => typeof location === "undefined" ? "/" : location.pathname;
311
+ var [path, setPath] = createSignal(read());
312
+ var listening = false;
313
+ function listen() {
314
+ if (listening || typeof window === "undefined") return;
315
+ listening = true;
316
+ window.addEventListener("popstate", () => setPath(read()));
317
+ }
318
+ function navigate(to, options = {}) {
319
+ if (new URL(to, location.href).href === location.href) return;
320
+ history[options.replace ? "replaceState" : "pushState"](null, "", to);
321
+ setPath(read());
322
+ }
323
+ var usePath = () => path;
324
+ var Params = createContext({});
325
+ function useParams() {
326
+ return useContext(Params);
327
+ }
328
+ var split = (s) => s.split("/").filter(Boolean);
329
+ function decode(s) {
330
+ try {
331
+ return decodeURIComponent(s);
332
+ } catch {
333
+ return s;
334
+ }
335
+ }
336
+ function match(pattern, pathname) {
337
+ if (pattern === "*") return {};
338
+ const p = split(pattern);
339
+ const u = split(pathname);
340
+ if (p.length !== u.length) return null;
341
+ const params = {};
342
+ for (let i = 0; i < p.length; i++) {
343
+ if (p[i].startsWith(":")) params[p[i].slice(1)] = decode(u[i]);
344
+ else if (p[i] !== u[i]) return null;
345
+ }
346
+ return params;
347
+ }
348
+ function Route(props) {
349
+ return { __route: true, path: props.path, component: props.component };
350
+ }
351
+ function Router(props) {
352
+ listen();
353
+ const routes = [props.children].flat(Infinity).filter(
354
+ (r) => r?.__route === true
355
+ );
356
+ return () => {
357
+ const current2 = path();
358
+ for (const route of routes) {
359
+ const params = match(route.path, current2);
360
+ if (params) {
361
+ return untrack(() => provide(Params, params, () => route.component()));
362
+ }
363
+ }
364
+ return null;
365
+ };
366
+ }
367
+ function Link(props) {
368
+ const { href, children, ...rest } = props;
369
+ const onClick = (e) => {
370
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(href)) return;
371
+ if (e.defaultPrevented || e.button !== 0) return;
372
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
373
+ e.preventDefault();
374
+ navigate(href);
375
+ };
376
+ return h("a", { ...rest, href, onClick }, children);
377
+ }
267
378
  export {
268
379
  For,
380
+ Fragment,
381
+ Link,
382
+ Route,
383
+ Router,
269
384
  Show,
270
385
  batch,
271
386
  createContext,
@@ -273,13 +388,20 @@ export {
273
388
  createMemo,
274
389
  createRoot,
275
390
  createSignal,
391
+ domRenderer,
392
+ getRenderer,
276
393
  h,
277
394
  jsx,
278
395
  jsxDEV,
279
396
  jsxs,
397
+ navigate,
280
398
  onCleanup,
281
399
  onMount,
282
400
  provide,
283
401
  render,
284
- useContext
402
+ setRenderer,
403
+ untrack,
404
+ useContext,
405
+ useParams,
406
+ usePath
285
407
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cajoa",
3
- "version": "0.3.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",