cajoa 0.3.0 → 0.4.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.4 (early stage).** The API may still change. Not production-ready yet.
7
7
 
8
8
  ## Why Cajoa?
9
9
 
@@ -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,16 +147,31 @@ 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
+
124
162
  ## Good to know
125
163
 
126
164
  - `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
165
  - `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
166
  - Updating an object in place will not trigger effects. Set a new object instead.
167
+ - Fragments (`<>...</>`) work anywhere, but inside `<For>` the child must be a single element, because `For` reuses that node.
129
168
 
130
169
  ## Known limitations
131
170
 
132
- - No Fragments (`<>...</>`) yet. Wrap siblings in an element.
133
171
  - No SSR.
172
+ - Router: pathname only (no query/hash handling), no nested routes, no `base` path.
173
+ - `<Show>` and `<Router>` return a function, so they must sit inside an element, not be the root returned by `render`.
174
+ - Single-page apps need a server fallback to `index.html` for unknown paths.
134
175
  - `createMemo` can show a brief inconsistent state when memos depend on each other.
135
176
  - `set` calls are not batched automatically inside event handlers. Wrap them in `batch()`.
136
177
 
@@ -143,9 +184,9 @@ const [todos, setTodos] = createSignal([{ id: 1, text: "Learn Cajoa" }]);
143
184
  - [x] `createMemo`, `batch`
144
185
  - [x] Context
145
186
  - [x] JSX support
146
- - [ ] Fragments
187
+ - [x] Fragments
147
188
  - [ ] Vite plugin (removes the two rules above)
148
- - [ ] Router
189
+ - [x] Router
149
190
  - [ ] SSR
150
191
 
151
192
  ## 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,
@@ -32,11 +36,15 @@ __export(index_exports, {
32
36
  jsx: () => jsx,
33
37
  jsxDEV: () => jsxDEV,
34
38
  jsxs: () => jsxs,
39
+ navigate: () => navigate,
35
40
  onCleanup: () => onCleanup,
36
41
  onMount: () => onMount,
37
42
  provide: () => provide,
38
43
  render: () => render,
39
- useContext: () => useContext
44
+ untrack: () => untrack,
45
+ useContext: () => useContext,
46
+ useParams: () => useParams,
47
+ usePath: () => usePath
40
48
  });
41
49
  module.exports = __toCommonJS(index_exports);
42
50
 
@@ -172,6 +180,15 @@ function useContext(ctx) {
172
180
  }
173
181
  return ctx.defaultValue;
174
182
  }
183
+ function untrack(fn) {
184
+ const prev = currentEffect;
185
+ currentEffect = null;
186
+ try {
187
+ return fn();
188
+ } finally {
189
+ currentEffect = prev;
190
+ }
191
+ }
175
192
 
176
193
  // src/dom/h.ts
177
194
  function h(tag, props, ...children) {
@@ -254,9 +271,17 @@ function render(component, container) {
254
271
  });
255
272
  }
256
273
 
274
+ // src/reactivity/memo.ts
275
+ function createMemo(fn) {
276
+ const [value, setValue] = createSignal(void 0);
277
+ createEffect(() => setValue(() => fn()));
278
+ return value;
279
+ }
280
+
257
281
  // src/components/show.ts
258
282
  function Show(props) {
259
- return () => props.when() ? props.children() : props.fallback?.() ?? null;
283
+ const visible = createMemo(() => !!props.when());
284
+ return () => visible() ? untrack(() => props.children()) : untrack(() => props.fallback?.() ?? null);
260
285
  }
261
286
 
262
287
  // src/components/for.ts
@@ -290,13 +315,6 @@ function For(props) {
290
315
  };
291
316
  }
292
317
 
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
318
  // src/jsx-runtime.ts
301
319
  function jsx(type, props) {
302
320
  if (typeof type === "function") return type(props ?? {});
@@ -306,9 +324,88 @@ function jsx(type, props) {
306
324
  }
307
325
  var jsxs = jsx;
308
326
  var jsxDEV = jsx;
327
+ function Fragment(props) {
328
+ const frag = document.createDocumentFragment();
329
+ appendChildren(frag, props.children === void 0 ? [] : [props.children]);
330
+ return frag;
331
+ }
332
+
333
+ // src/router.ts
334
+ var read = () => typeof location === "undefined" ? "/" : location.pathname;
335
+ var [path, setPath] = createSignal(read());
336
+ var listening = false;
337
+ function listen() {
338
+ if (listening || typeof window === "undefined") return;
339
+ listening = true;
340
+ window.addEventListener("popstate", () => setPath(read()));
341
+ }
342
+ function navigate(to, options = {}) {
343
+ if (new URL(to, location.href).href === location.href) return;
344
+ history[options.replace ? "replaceState" : "pushState"](null, "", to);
345
+ setPath(read());
346
+ }
347
+ var usePath = () => path;
348
+ var Params = createContext({});
349
+ function useParams() {
350
+ return useContext(Params);
351
+ }
352
+ var split = (s) => s.split("/").filter(Boolean);
353
+ function decode(s) {
354
+ try {
355
+ return decodeURIComponent(s);
356
+ } catch {
357
+ return s;
358
+ }
359
+ }
360
+ function match(pattern, pathname) {
361
+ if (pattern === "*") return {};
362
+ const p = split(pattern);
363
+ const u = split(pathname);
364
+ if (p.length !== u.length) return null;
365
+ const params = {};
366
+ for (let i = 0; i < p.length; i++) {
367
+ if (p[i].startsWith(":")) params[p[i].slice(1)] = decode(u[i]);
368
+ else if (p[i] !== u[i]) return null;
369
+ }
370
+ return params;
371
+ }
372
+ function Route(props) {
373
+ return { __route: true, path: props.path, component: props.component };
374
+ }
375
+ function Router(props) {
376
+ listen();
377
+ const routes = [props.children].flat(Infinity).filter(
378
+ (r) => r?.__route === true
379
+ );
380
+ return () => {
381
+ const current = path();
382
+ for (const route of routes) {
383
+ const params = match(route.path, current);
384
+ if (params) {
385
+ return untrack(() => provide(Params, params, () => route.component()));
386
+ }
387
+ }
388
+ return null;
389
+ };
390
+ }
391
+ function Link(props) {
392
+ const { href, children, ...rest } = props;
393
+ const onClick = (e) => {
394
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(href)) return;
395
+ if (e.defaultPrevented || e.button !== 0) return;
396
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
397
+ e.preventDefault();
398
+ navigate(href);
399
+ };
400
+ return h("a", { ...rest, href, onClick }, children);
401
+ }
309
402
  // Annotate the CommonJS export names for ESM import in node:
310
403
  0 && (module.exports = {
311
404
  For,
405
+ Fragment,
406
+ Link,
407
+ Route,
408
+ Router,
312
409
  Show,
313
410
  batch,
314
411
  createContext,
@@ -320,9 +417,13 @@ var jsxDEV = jsx;
320
417
  jsx,
321
418
  jsxDEV,
322
419
  jsxs,
420
+ navigate,
323
421
  onCleanup,
324
422
  onMount,
325
423
  provide,
326
424
  render,
327
- useContext
425
+ untrack,
426
+ useContext,
427
+ useParams,
428
+ usePath
328
429
  });
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,32 @@ declare namespace JSX {
44
45
  children: {};
45
46
  }
46
47
  }
48
+ declare function Fragment(props: {
49
+ children?: Child;
50
+ }): DocumentFragment;
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
+ 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 };
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,32 @@ declare namespace JSX {
44
45
  children: {};
45
46
  }
46
47
  }
48
+ declare function Fragment(props: {
49
+ children?: Child;
50
+ }): DocumentFragment;
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
+ 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 };
package/dist/index.js CHANGED
@@ -130,6 +130,15 @@ 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
+ }
133
142
 
134
143
  // src/dom/h.ts
135
144
  function h(tag, props, ...children) {
@@ -212,9 +221,17 @@ function render(component, container) {
212
221
  });
213
222
  }
214
223
 
224
+ // src/reactivity/memo.ts
225
+ function createMemo(fn) {
226
+ const [value, setValue] = createSignal(void 0);
227
+ createEffect(() => setValue(() => fn()));
228
+ return value;
229
+ }
230
+
215
231
  // src/components/show.ts
216
232
  function Show(props) {
217
- return () => props.when() ? props.children() : props.fallback?.() ?? null;
233
+ const visible = createMemo(() => !!props.when());
234
+ return () => visible() ? untrack(() => props.children()) : untrack(() => props.fallback?.() ?? null);
218
235
  }
219
236
 
220
237
  // src/components/for.ts
@@ -248,13 +265,6 @@ function For(props) {
248
265
  };
249
266
  }
250
267
 
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
268
  // src/jsx-runtime.ts
259
269
  function jsx(type, props) {
260
270
  if (typeof type === "function") return type(props ?? {});
@@ -264,8 +274,87 @@ function jsx(type, props) {
264
274
  }
265
275
  var jsxs = jsx;
266
276
  var jsxDEV = jsx;
277
+ function Fragment(props) {
278
+ const frag = document.createDocumentFragment();
279
+ appendChildren(frag, props.children === void 0 ? [] : [props.children]);
280
+ return frag;
281
+ }
282
+
283
+ // src/router.ts
284
+ var read = () => typeof location === "undefined" ? "/" : location.pathname;
285
+ var [path, setPath] = createSignal(read());
286
+ var listening = false;
287
+ function listen() {
288
+ if (listening || typeof window === "undefined") return;
289
+ listening = true;
290
+ window.addEventListener("popstate", () => setPath(read()));
291
+ }
292
+ function navigate(to, options = {}) {
293
+ if (new URL(to, location.href).href === location.href) return;
294
+ history[options.replace ? "replaceState" : "pushState"](null, "", to);
295
+ setPath(read());
296
+ }
297
+ var usePath = () => path;
298
+ var Params = createContext({});
299
+ function useParams() {
300
+ return useContext(Params);
301
+ }
302
+ var split = (s) => s.split("/").filter(Boolean);
303
+ function decode(s) {
304
+ try {
305
+ return decodeURIComponent(s);
306
+ } catch {
307
+ return s;
308
+ }
309
+ }
310
+ function match(pattern, pathname) {
311
+ if (pattern === "*") return {};
312
+ const p = split(pattern);
313
+ const u = split(pathname);
314
+ if (p.length !== u.length) return null;
315
+ const params = {};
316
+ for (let i = 0; i < p.length; i++) {
317
+ if (p[i].startsWith(":")) params[p[i].slice(1)] = decode(u[i]);
318
+ else if (p[i] !== u[i]) return null;
319
+ }
320
+ return params;
321
+ }
322
+ function Route(props) {
323
+ return { __route: true, path: props.path, component: props.component };
324
+ }
325
+ function Router(props) {
326
+ listen();
327
+ const routes = [props.children].flat(Infinity).filter(
328
+ (r) => r?.__route === true
329
+ );
330
+ return () => {
331
+ const current = path();
332
+ for (const route of routes) {
333
+ const params = match(route.path, current);
334
+ if (params) {
335
+ return untrack(() => provide(Params, params, () => route.component()));
336
+ }
337
+ }
338
+ return null;
339
+ };
340
+ }
341
+ function Link(props) {
342
+ const { href, children, ...rest } = props;
343
+ const onClick = (e) => {
344
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(href)) return;
345
+ if (e.defaultPrevented || e.button !== 0) return;
346
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
347
+ e.preventDefault();
348
+ navigate(href);
349
+ };
350
+ return h("a", { ...rest, href, onClick }, children);
351
+ }
267
352
  export {
268
353
  For,
354
+ Fragment,
355
+ Link,
356
+ Route,
357
+ Router,
269
358
  Show,
270
359
  batch,
271
360
  createContext,
@@ -277,9 +366,13 @@ export {
277
366
  jsx,
278
367
  jsxDEV,
279
368
  jsxs,
369
+ navigate,
280
370
  onCleanup,
281
371
  onMount,
282
372
  provide,
283
373
  render,
284
- useContext
374
+ untrack,
375
+ useContext,
376
+ useParams,
377
+ usePath
285
378
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cajoa",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A beginner-friendly, signals-based frontend framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",