@ozsarman/clarityjs 0.6.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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Clarity.js — File-Based Pages Router (browser runtime + pure helpers)
3
+ *
4
+ * This is the browser-side counterpart to the Vite `scanPages` integration.
5
+ * The Vite plugin scans `pages/` at dev/build time and emits a virtual module
6
+ * (`virtual:clarity-pages`) containing a lazy route table; this module turns
7
+ * that table into a working, code-split router.
8
+ *
9
+ * import { Routes } from 'virtual:clarity-pages';
10
+ * import { mount } from '@ozsarman/clarityjs';
11
+ * mount(Routes, document.getElementById('app'));
12
+ *
13
+ * File → route convention (Next.js / SvelteKit style):
14
+ *
15
+ * pages/index.clarity → /
16
+ * pages/about.clarity → /about
17
+ * pages/blog/index.clarity → /blog
18
+ * pages/blog/[slug].clarity → /blog/:slug
19
+ * pages/users/[id]/edit.clarity → /users/:id/edit
20
+ * pages/[...all].clarity → /* (catch-all)
21
+ *
22
+ * The pure helpers (filePathToRoutePattern, routeScore, sortRoutes,
23
+ * selectRoute) have no DOM or Node dependencies and are unit-tested directly.
24
+ *
25
+ * Author: Claude (Anthropic) + Özdemir Sarman
26
+ */
27
+
28
+ import { matchRoute, currentPath } from './router.js';
29
+ import { signal, effect, _runMountHooks, _runCleanupHooks } from './runtime.js';
30
+
31
+ // ─── Pure helpers ─────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Convert a page file path (relative to the pages dir) into a route pattern.
35
+ *
36
+ * @param {string} relPath e.g. 'blog/[slug].clarity', 'index.js'
37
+ * @returns {string} e.g. '/blog/:slug', '/'
38
+ */
39
+ export function filePathToRoutePattern(relPath) {
40
+ const stripped = String(relPath)
41
+ .replace(/\\/g, '/')
42
+ .replace(/^\.?\//, '')
43
+ .replace(/\.(clarity|jsx?|mjs|tsx?)$/i, '');
44
+
45
+ const segments = stripped.split('/').filter(Boolean);
46
+ const out = [];
47
+
48
+ for (const seg of segments) {
49
+ // index maps onto its parent directory (no extra segment)
50
+ if (seg === 'index') continue;
51
+
52
+ // Catch-all: [...name] → *
53
+ const rest = seg.match(/^\[\.\.\.(.+)\]$/);
54
+ if (rest) { out.push('*'); continue; }
55
+
56
+ // Dynamic: [name] → :name
57
+ const dyn = seg.match(/^\[(.+)\]$/);
58
+ if (dyn) { out.push(':' + dyn[1]); continue; }
59
+
60
+ out.push(seg);
61
+ }
62
+
63
+ const pattern = '/' + out.join('/');
64
+ return pattern === '/' ? '/' : pattern.replace(/\/$/, '');
65
+ }
66
+
67
+ /**
68
+ * Specificity score for a route pattern — higher = more specific.
69
+ * Static segments outrank dynamic params, which outrank catch-all wildcards.
70
+ * Used to decide which route wins when several could match.
71
+ *
72
+ * @param {string} pattern
73
+ * @returns {number}
74
+ */
75
+ export function routeScore(pattern) {
76
+ const segs = String(pattern).split('/').filter(Boolean);
77
+ let score = 0;
78
+ for (const s of segs) {
79
+ if (s === '*') score -= 1000; // catch-all — always least specific,
80
+ // even below the root '/' (score 0)
81
+ else if (s.startsWith(':')) score += 10; // dynamic param
82
+ else score += 100; // static segment — most specific
83
+ }
84
+ return score;
85
+ }
86
+
87
+ /**
88
+ * Return a new array of routes sorted most-specific first.
89
+ * @param {Array<{pattern: string}>} routes
90
+ */
91
+ export function sortRoutes(routes) {
92
+ return [...routes].sort((a, b) => routeScore(b.pattern) - routeScore(a.pattern));
93
+ }
94
+
95
+ /**
96
+ * Select the best-matching route for a path.
97
+ * @param {Array<{pattern: string}>} routes
98
+ * @param {string} path
99
+ * @returns {{ route: object, params: Record<string,string> } | null}
100
+ */
101
+ export function selectRoute(routes, path) {
102
+ for (const route of sortRoutes(routes)) {
103
+ const params = matchRoute(route.pattern, path);
104
+ if (params) return { route, params };
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Build a sorted route table from a list of page file paths.
111
+ * @param {string[]} files page file paths (relative to pages dir)
112
+ * @param {(file: string) => any} [loaderFor] optional: returns a lazy loader per file
113
+ * @returns {Array<{ pattern: string, file: string, load?: Function }>}
114
+ */
115
+ export function buildRouteTable(files, loaderFor) {
116
+ const routes = files.map(file => {
117
+ const route = { pattern: filePathToRoutePattern(file), file };
118
+ if (typeof loaderFor === 'function') route.load = loaderFor(file);
119
+ return route;
120
+ });
121
+ return sortRoutes(routes);
122
+ }
123
+
124
+ // ─── Browser router component ─────────────────────────────────────────────────
125
+
126
+ /** Pick the Clarity component export from a loaded page module. */
127
+ function _pickComponent(mod) {
128
+ if (!mod) return null;
129
+ if (typeof mod.default === 'function') return mod.default;
130
+ // Fall back to the first function export (named component)
131
+ for (const v of Object.values(mod)) {
132
+ if (typeof v === 'function') return v;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Create a file-based pages router component.
139
+ *
140
+ * @param {object} options
141
+ * @param {Array<{ pattern, file, load }>} options.routes route table (each `load` returns Promise<module>)
142
+ * @param {Function} [options.loading] component shown while a page chunk loads
143
+ * @param {Function} [options.notFound] component shown when no route matches
144
+ * @param {Function} [options.error] component shown when a chunk fails to load — receives { error }
145
+ * @returns {Function} a Clarity component
146
+ */
147
+ export function createPagesRouter(options = {}) {
148
+ const {
149
+ routes = [],
150
+ loading: LoadingComp = null,
151
+ notFound: NotFoundComp = null,
152
+ error: ErrorComp = null,
153
+ } = options;
154
+
155
+ const sorted = sortRoutes(routes);
156
+ const _moduleCache = new Map(); // pattern → resolved component
157
+
158
+ return function PagesRouter() {
159
+ const host = (typeof document !== 'undefined')
160
+ ? document.createElement('div')
161
+ : { nodeType: 1, _children: [], replaceChildren(...n) { this._children = n; }, append(...n) { this._children.push(...n); } };
162
+ host.setAttribute?.('data-clarity-pages', '');
163
+
164
+ // Track the currently displayed nodes so we can run their cleanup hooks
165
+ // (stop game loops, dispose effects) before swapping in a new page.
166
+ let _current = [];
167
+
168
+ // Render a node (or component result) into the host, replacing prior content.
169
+ const renderInto = (node) => {
170
+ for (const n of _current) _runCleanupHooks(n);
171
+ _current = [];
172
+
173
+ if (node == null) { host.replaceChildren?.(); return; }
174
+ const resolved = typeof node === 'function' ? node() : node;
175
+ const arr = Array.isArray(resolved) ? resolved : [resolved];
176
+ host.replaceChildren?.(...arr);
177
+
178
+ // Fire onMount across the freshly-inserted subtree (nested components too).
179
+ for (const n of arr) {
180
+ if (n && typeof n === 'object' && n.nodeType) {
181
+ _runMountHooks(n);
182
+ _current.push(n);
183
+ }
184
+ }
185
+ };
186
+
187
+ const showLoading = () => { if (LoadingComp) renderInto(LoadingComp); };
188
+ const showNotFound = (path) => { if (NotFoundComp) renderInto(() => NotFoundComp({ path })); };
189
+ const showError = (err) => {
190
+ if (ErrorComp) renderInto(() => ErrorComp({ error: err }));
191
+ else console.error('[Clarity pages] failed to load route chunk:', err);
192
+ };
193
+
194
+ // React to path changes — select + lazy-load + render the matched page.
195
+ effect(() => {
196
+ const path = currentPath.get ? currentPath.get() : currentPath();
197
+ const match = selectRoute(sorted, path);
198
+
199
+ if (!match) { showNotFound(path); return; }
200
+
201
+ const { route, params } = match;
202
+
203
+ // Cached component → render synchronously.
204
+ if (_moduleCache.has(route.pattern)) {
205
+ const Comp = _moduleCache.get(route.pattern);
206
+ renderInto(() => Comp({ params }));
207
+ return;
208
+ }
209
+
210
+ // Not loaded yet → show loading, then lazy-import the chunk.
211
+ showLoading();
212
+ const loader = typeof route.load === 'function' ? route.load : null;
213
+ if (!loader) { showError(new Error(`Route ${route.pattern} has no loader`)); return; }
214
+
215
+ Promise.resolve(loader())
216
+ .then(mod => {
217
+ const Comp = _pickComponent(mod);
218
+ if (!Comp) throw new Error(`Route ${route.pattern} module has no component export`);
219
+ _moduleCache.set(route.pattern, Comp);
220
+ // Guard against a path change while we were loading.
221
+ const now = currentPath.get ? currentPath.get() : currentPath();
222
+ if (matchRoute(route.pattern, now)) renderInto(() => Comp({ params }));
223
+ })
224
+ .catch(showError);
225
+ });
226
+
227
+ return host;
228
+ };
229
+ }