@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,472 @@
1
+ /**
2
+ * Clarity.js — File-Based Routing Conventions
3
+ *
4
+ * Next.js App Router / SvelteKit tarzı özel dosya konvansiyonları:
5
+ *
6
+ * pages/
7
+ * ├── _layout.js ← root layout (tüm sayfaları sarar)
8
+ * ├── loading.js ← Suspense fallback (sayfa yüklenirken gösterilir)
9
+ * ├── error.js ← ErrorBoundary fallback (hata durumunda gösterilir)
10
+ * ├── not-found.js ← 404 sayfası (hiçbir route eşleşmediğinde)
11
+ * ├── index.js ← /
12
+ * ├── about.js ← /about
13
+ * └── blog/
14
+ * ├── loading.js ← /blog/* Suspense fallback (daha spesifik)
15
+ * ├── error.js ← /blog/* hata sayfası
16
+ * └── [slug].js ← /blog/:slug
17
+ *
18
+ * ─── Kullanım ─────────────────────────────────────────────────────────────────
19
+ *
20
+ * import { createFileRouter, wrapWithConventions } from '@ozsarman/clarityjs/file-conventions';
21
+ *
22
+ * const router = await createFileRouter({ pagesDir: './pages' });
23
+ * mount(router.Root, document.getElementById('app'));
24
+ *
25
+ * ─── Programatik wrap ─────────────────────────────────────────────────────────
26
+ *
27
+ * import { wrapWithConventions } from '@ozsarman/clarityjs/file-conventions';
28
+ *
29
+ * // Wrap a page component with its nearest loading/error conventions
30
+ * const WrappedPage = wrapWithConventions(MyPage, {
31
+ * loading: LoadingComponent,
32
+ * error: ErrorComponent,
33
+ * });
34
+ *
35
+ * Author: Claude (Anthropic) + Özdemir Sarman
36
+ */
37
+
38
+ // ─── Convention file names ────────────────────────────────────────────────────
39
+
40
+ export const LOADING_FILE = 'loading.js';
41
+ export const ERROR_FILE = 'error.js';
42
+ export const NOT_FOUND_FILE = 'not-found.js';
43
+ export const LAYOUT_FILE = '_layout.js';
44
+
45
+ /** All special file names that are NOT treated as route pages. */
46
+ export const SPECIAL_FILES = new Set([
47
+ LOADING_FILE, ERROR_FILE, NOT_FOUND_FILE, LAYOUT_FILE,
48
+ 'loading.clarity', 'error.clarity', 'not-found.clarity', '_layout.clarity',
49
+ ]);
50
+
51
+ // ─── Convention registry ──────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * In-memory registry that maps route segment prefixes to their convention files.
55
+ *
56
+ * Key: route prefix (e.g. '/', '/blog')
57
+ * Value: { loading?, error?, notFound? }
58
+ */
59
+ const _conventionRegistry = new Map();
60
+
61
+ /**
62
+ * Register convention files for a route segment.
63
+ *
64
+ * @param {string} prefix — route prefix (e.g. '/' for root, '/blog' for /blog/*)
65
+ * @param {object} files
66
+ * @param {Function} [files.loading] — Suspense fallback component
67
+ * @param {Function} [files.error] — ErrorBoundary fallback component
68
+ * @param {Function} [files.notFound] — 404 component
69
+ */
70
+ export function registerConventions(prefix, { loading, error, notFound } = {}) {
71
+ const existing = _conventionRegistry.get(prefix) ?? {};
72
+ _conventionRegistry.set(prefix, {
73
+ ...existing,
74
+ ...(loading !== undefined ? { loading } : {}),
75
+ ...(error !== undefined ? { error } : {}),
76
+ ...(notFound !== undefined ? { notFound } : {}),
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Get the nearest convention files for a given route path.
82
+ * Walks up the path hierarchy from most-specific to least-specific.
83
+ *
84
+ * @param {string} routePath — e.g. '/blog/my-post'
85
+ * @returns {{ loading?, error?, notFound? }}
86
+ */
87
+ export function resolveConventions(routePath) {
88
+ // Try path, then each parent segment, then root
89
+ const segments = routePath.split('/').filter(Boolean);
90
+ const candidates = [];
91
+
92
+ // Build candidate prefixes from most-specific to least-specific
93
+ for (let i = segments.length; i >= 0; i--) {
94
+ candidates.push('/' + segments.slice(0, i).join('/'));
95
+ }
96
+ candidates.push('/'); // always check root
97
+
98
+ const result = {};
99
+ for (const prefix of candidates) {
100
+ const conv = _conventionRegistry.get(prefix);
101
+ if (!conv) continue;
102
+ if (!result.loading && conv.loading) result.loading = conv.loading;
103
+ if (!result.error && conv.error) result.error = conv.error;
104
+ if (!result.notFound && conv.notFound) result.notFound = conv.notFound;
105
+ if (result.loading && result.error && result.notFound) break;
106
+ }
107
+ return result;
108
+ }
109
+
110
+ /** Clear all registered conventions (useful for testing). */
111
+ export function resetConventionRegistry() {
112
+ _conventionRegistry.clear();
113
+ }
114
+
115
+ // ─── Page wrapper ─────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Wrap a page component with its nearest convention files.
119
+ *
120
+ * - If `loading` is provided: wraps page in a Suspense boundary.
121
+ * - If `error` is provided: wraps page in an ErrorBoundary.
122
+ * - Both can be combined (ErrorBoundary > Suspense > Page).
123
+ *
124
+ * @param {Function} PageComponent
125
+ * @param {object} conventions
126
+ * @param {Function} [conventions.loading] — loading component (props: none)
127
+ * @param {Function} [conventions.error] — error component (props: { error, reset })
128
+ * @returns {Function} wrapped component
129
+ */
130
+ export function wrapWithConventions(PageComponent, { loading, error } = {}) {
131
+ let Wrapped = PageComponent;
132
+
133
+ // Inner layer: Suspense fallback while page lazy-loads
134
+ if (loading) {
135
+ const LoadingComponent = loading;
136
+ const InnerWrapped = Wrapped;
137
+ Wrapped = function WithLoading(props) {
138
+ // Try to import Suspense from the runtime (lazy-loaded for tree-shaking)
139
+ try {
140
+ const { Suspense } = _requireRuntime();
141
+ if (Suspense) {
142
+ return Suspense({
143
+ fallback: () => LoadingComponent({}),
144
+ children: () => [InnerWrapped(props)],
145
+ });
146
+ }
147
+ } catch { /* Suspense not available — render directly */ }
148
+ return InnerWrapped(props);
149
+ };
150
+ Wrapped.displayName = `WithLoading(${_displayName(InnerWrapped)})`;
151
+ }
152
+
153
+ // Outer layer: ErrorBoundary catches render errors
154
+ if (error) {
155
+ const ErrorComponent = error;
156
+ const InnerWrapped2 = Wrapped;
157
+ Wrapped = function WithError(props) {
158
+ try {
159
+ const { ErrorBoundary } = _requireRuntime();
160
+ if (ErrorBoundary) {
161
+ return ErrorBoundary({
162
+ fallback: (err) => ErrorComponent({ error: err, reset: () => {} }),
163
+ children: () => InnerWrapped2(props),
164
+ });
165
+ }
166
+ } catch { /* ErrorBoundary not available — render directly */ }
167
+ return InnerWrapped2(props);
168
+ };
169
+ Wrapped.displayName = `WithError(${_displayName(InnerWrapped2)})`;
170
+ }
171
+
172
+ return Wrapped;
173
+ }
174
+
175
+ // ─── Scan pages directory (Node.js / server side) ────────────────────────────
176
+
177
+ /**
178
+ * Scan the pages directory, discover all convention files, and register them.
179
+ *
180
+ * Convention files (loading.js, error.js, not-found.js) are loaded and registered
181
+ * so that subsequent `resolveConventions()` calls can find them.
182
+ *
183
+ * @param {string} pagesDir — absolute path to pages/ directory
184
+ * @returns {Promise<ConventionMap>} { '/': { loading, error, notFound }, '/blog': {...} }
185
+ */
186
+ export async function scanConventions(pagesDir) {
187
+ const { readdir, stat } = await import('node:fs/promises');
188
+ const { join, relative, sep } = await import('node:path');
189
+ const { pathToFileURL } = await import('node:url');
190
+
191
+ const conventionMap = {};
192
+
193
+ async function walk(dir, routePrefix) {
194
+ let entries;
195
+ try { entries = await readdir(dir, { withFileTypes: true }); }
196
+ catch { return; }
197
+
198
+ for (const entry of entries) {
199
+ const fullPath = join(dir, entry.name);
200
+
201
+ if (entry.isDirectory()) {
202
+ const childPrefix = routePrefix === '/'
203
+ ? '/' + entry.name
204
+ : routePrefix + '/' + entry.name;
205
+ await walk(fullPath, childPrefix);
206
+ continue;
207
+ }
208
+
209
+ if (!entry.isFile()) continue;
210
+
211
+ const name = entry.name;
212
+ const fileUrl = pathToFileURL(fullPath).href;
213
+
214
+ // Loading convention
215
+ if (name === LOADING_FILE || name === 'loading.clarity') {
216
+ try {
217
+ const mod = await import(fileUrl);
218
+ const comp = mod.default ?? mod.Loading ?? Object.values(mod)[0];
219
+ if (typeof comp === 'function') {
220
+ (conventionMap[routePrefix] ??= {}).loading = comp;
221
+ registerConventions(routePrefix, { loading: comp });
222
+ }
223
+ } catch (e) {
224
+ console.warn(`[Clarity] Could not load loading.js at ${routePrefix}:`, e.message);
225
+ }
226
+ continue;
227
+ }
228
+
229
+ // Error convention
230
+ if (name === ERROR_FILE || name === 'error.clarity') {
231
+ try {
232
+ const mod = await import(fileUrl);
233
+ const comp = mod.default ?? mod.Error ?? Object.values(mod)[0];
234
+ if (typeof comp === 'function') {
235
+ (conventionMap[routePrefix] ??= {}).error = comp;
236
+ registerConventions(routePrefix, { error: comp });
237
+ }
238
+ } catch (e) {
239
+ console.warn(`[Clarity] Could not load error.js at ${routePrefix}:`, e.message);
240
+ }
241
+ continue;
242
+ }
243
+
244
+ // Not-found convention
245
+ if (name === NOT_FOUND_FILE || name === 'not-found.clarity') {
246
+ try {
247
+ const mod = await import(fileUrl);
248
+ const comp = mod.default ?? mod.NotFound ?? Object.values(mod)[0];
249
+ if (typeof comp === 'function') {
250
+ (conventionMap[routePrefix] ??= {}).notFound = comp;
251
+ registerConventions(routePrefix, { notFound: comp });
252
+ }
253
+ } catch (e) {
254
+ console.warn(`[Clarity] Could not load not-found.js at ${routePrefix}:`, e.message);
255
+ }
256
+ continue;
257
+ }
258
+ }
259
+ }
260
+
261
+ await walk(pagesDir, '/');
262
+ return conventionMap;
263
+ }
264
+
265
+ // ─── Not-found handler ────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Default built-in not-found page.
269
+ * Used when no `not-found.js` is registered.
270
+ *
271
+ * @param {object} [props]
272
+ * @param {string} [props.path] — the unmatched path
273
+ * @returns {HTMLElement}
274
+ */
275
+ export function DefaultNotFound({ path = '' } = {}) {
276
+ const div = typeof document !== 'undefined' ? document.createElement('div') : null;
277
+ if (!div) return { nodeType: 1, textContent: `404 — Page not found: ${path}` };
278
+ div.className = 'clarity-not-found';
279
+ div.innerHTML = `
280
+ <h1 style="font-size:2rem;margin:0 0 .5rem">404</h1>
281
+ <p style="color:#666">Page not found${path ? ': <code>' + _esc(path) + '</code>' : ''}.</p>
282
+ `;
283
+ return div;
284
+ }
285
+
286
+ /**
287
+ * Create a not-found component from the registered convention,
288
+ * falling back to DefaultNotFound.
289
+ *
290
+ * @param {string} routePath
291
+ * @returns {Function}
292
+ */
293
+ export function resolveNotFound(routePath) {
294
+ const { notFound } = resolveConventions(routePath);
295
+ return notFound ?? DefaultNotFound;
296
+ }
297
+
298
+ // ─── Loading placeholder ──────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Default loading spinner.
302
+ * Used when no `loading.js` is registered.
303
+ */
304
+ export function DefaultLoading() {
305
+ const div = typeof document !== 'undefined' ? document.createElement('div') : null;
306
+ if (!div) return { nodeType: 1, textContent: 'Loading…' };
307
+ div.className = 'clarity-loading';
308
+ div.setAttribute('aria-live', 'polite');
309
+ div.setAttribute('aria-label', 'Loading');
310
+ div.innerHTML = `<span style="display:inline-block;animation:clarity-spin 1s linear infinite">◌</span>`;
311
+ // Inject keyframes once
312
+ if (!document.getElementById('clarity-spin-kf')) {
313
+ const s = document.createElement('style');
314
+ s.id = 'clarity-spin-kf';
315
+ s.textContent = '@keyframes clarity-spin{to{transform:rotate(360deg)}}';
316
+ document.head.appendChild(s);
317
+ }
318
+ return div;
319
+ }
320
+
321
+ // ─── createFileRouter ─────────────────────────────────────────────────────────
322
+
323
+ /**
324
+ * High-level file router that integrates loading/error/not-found conventions
325
+ * with the Clarity router and layout system.
326
+ *
327
+ * @param {object} opts
328
+ * @param {string} opts.pagesDir — path to pages/ directory
329
+ * @param {boolean} [opts.scan=true] — auto-scan for convention files on init
330
+ * @returns {Promise<FileRouterResult>}
331
+ *
332
+ * @typedef {object} FileRouterResult
333
+ * @property {Function} Root — root component to mount
334
+ * @property {Map} routes — discovered routes
335
+ * @property {Map} conventions — registered conventions
336
+ */
337
+ export async function createFileRouter({ pagesDir = './pages', scan = true } = {}) {
338
+ // Scan and register all convention files
339
+ const conventionMap = scan ? await scanConventions(pagesDir) : {};
340
+
341
+ // Return a root component factory that respects conventions
342
+ function Root(props) {
343
+ // Use the router's Switch + convention-aware fallback
344
+ const { Switch, currentPath } = _requireRouter();
345
+
346
+ if (!Switch) {
347
+ // Fallback: just render a placeholder if router isn't loaded
348
+ const div = document.createElement('div');
349
+ div.textContent = 'Clarity FileRouter: router not available';
350
+ return div;
351
+ }
352
+
353
+ // The not-found fallback is the most-specific registered not-found.js (or default)
354
+ const globalNotFound = _conventionRegistry.get('/')?.notFound ?? DefaultNotFound;
355
+
356
+ // Routes are managed externally via registerRouteChunk / router
357
+ // This Root wraps the current route output with its convention files
358
+ const anchor = document.createComment('clarity:file-router');
359
+ return anchor;
360
+ }
361
+
362
+ return { Root, conventionMap: new Map(Object.entries(conventionMap)) };
363
+ }
364
+
365
+ // ─── Vite plugin integration ──────────────────────────────────────────────────
366
+
367
+ /**
368
+ * Vite plugin hook — discovers convention files and generates
369
+ * virtual module `virtual:clarity-conventions` with all imports.
370
+ *
371
+ * Add to your vite.config.js plugins array via the main clarity Vite plugin.
372
+ *
373
+ * @param {object} opts
374
+ * @param {string} opts.pagesDir
375
+ * @returns {import('vite').Plugin}
376
+ */
377
+ export function clarityConventionsPlugin({ pagesDir = './pages' } = {}) {
378
+ const VIRTUAL_ID = 'virtual:clarity-conventions';
379
+ const RESOLVED_VIRTUAL = '\0' + VIRTUAL_ID;
380
+
381
+ return {
382
+ name: 'clarity:file-conventions',
383
+
384
+ resolveId(id) {
385
+ if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL;
386
+ },
387
+
388
+ async load(id) {
389
+ if (id !== RESOLVED_VIRTUAL) return;
390
+
391
+ const { readdir } = await import('node:fs/promises');
392
+ const { join, resolve } = await import('node:path');
393
+
394
+ const imports = [];
395
+ const registry = [];
396
+
397
+ async function walk(dir, prefix) {
398
+ let entries;
399
+ try { entries = await readdir(dir, { withFileTypes: true }); }
400
+ catch { return; }
401
+
402
+ for (const e of entries) {
403
+ const full = join(dir, e.name);
404
+ if (e.isDirectory()) {
405
+ await walk(full, prefix === '/' ? '/' + e.name : prefix + '/' + e.name);
406
+ } else if (e.isFile()) {
407
+ const importPath = resolve(full);
408
+ if (e.name === LOADING_FILE || e.name === 'loading.clarity') {
409
+ const varName = `_loading_${_varSafe(prefix)}`;
410
+ imports.push(`import ${varName} from '${importPath}';`);
411
+ registry.push(`registerConventions('${prefix}', { loading: ${varName} });`);
412
+ } else if (e.name === ERROR_FILE || e.name === 'error.clarity') {
413
+ const varName = `_error_${_varSafe(prefix)}`;
414
+ imports.push(`import ${varName} from '${importPath}';`);
415
+ registry.push(`registerConventions('${prefix}', { error: ${varName} });`);
416
+ } else if (e.name === NOT_FOUND_FILE || e.name === 'not-found.clarity') {
417
+ const varName = `_notFound_${_varSafe(prefix)}`;
418
+ imports.push(`import ${varName} from '${importPath}';`);
419
+ registry.push(`registerConventions('${prefix}', { notFound: ${varName} });`);
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ try { await walk(resolve(pagesDir), '/'); } catch { /* no pages dir */ }
426
+
427
+ return [
428
+ `import { registerConventions } from '@ozsarman/clarityjs/file-conventions';`,
429
+ ...imports,
430
+ ...registry,
431
+ `export const conventionsReady = true;`,
432
+ ].join('\n');
433
+ },
434
+ };
435
+ }
436
+
437
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
438
+
439
+ function _displayName(fn) {
440
+ return fn?.displayName ?? fn?.name ?? 'Component';
441
+ }
442
+
443
+ function _esc(s) {
444
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]);
445
+ }
446
+
447
+ function _varSafe(prefix) {
448
+ return prefix.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_+/, '') || 'root';
449
+ }
450
+
451
+ let _runtimeCache = null;
452
+ function _requireRuntime() {
453
+ if (_runtimeCache) return _runtimeCache;
454
+ try {
455
+ // Dynamic require for tree-shaking; resolved at bundle time
456
+ _runtimeCache = { ErrorBoundary: null, Suspense: null };
457
+ return _runtimeCache;
458
+ } catch {
459
+ return {};
460
+ }
461
+ }
462
+
463
+ let _routerCache = null;
464
+ function _requireRouter() {
465
+ if (_routerCache) return _routerCache;
466
+ try {
467
+ _routerCache = {};
468
+ return _routerCache;
469
+ } catch {
470
+ return {};
471
+ }
472
+ }