@close-by/clay 0.1.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/src/pages.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { fileURLToPath, pathToFileURL } from 'url';
2
+ import { join } from 'path';
3
+ import {
4
+ clearPages,
5
+ getRegisteredPaths,
6
+ type PageFn,
7
+ } from '@close-by/clay-core';
8
+ import type { AppNavItem } from '@close-by/clay-components';
9
+
10
+ /**
11
+ * Dynamic `import` that forces Bun to re-execute the module.
12
+ * Query-string cache busting is ignored by Bun; clear `require.cache` instead
13
+ * so `--reload` / repeated `loadPages` re-run top-level `ui.page(...)`.
14
+ */
15
+ export async function importFresh(absPath: string): Promise<unknown> {
16
+ const href = pathToFileURL(absPath).href;
17
+ try {
18
+ // Bun populates require.cache for ESM too (key = absolute path).
19
+ if (typeof require !== 'undefined' && require.cache) {
20
+ delete require.cache[absPath];
21
+ delete require.cache[href];
22
+ }
23
+ } catch {
24
+ // ignore
25
+ }
26
+ return import(href);
27
+ }
28
+
29
+ /**
30
+ * Optional metadata for a registered page — used by `navFromPages` / `loadPages`.
31
+ * Export as `pageMeta` from a page module, or pass via `attachPageMeta`.
32
+ *
33
+ * @see docs/api.md — page discovery & nav
34
+ */
35
+ export type PageMeta = {
36
+ /** Sidebar / nav label. Default: title-cased last path segment (`/` → `"Home"`). */
37
+ label?: string;
38
+ /** Lucide icon name for nav. Default `"boxes"`. */
39
+ icon?: string;
40
+ /** Lower sorts first. Default `100`. Path `/` is forced to `0`. */
41
+ order?: number;
42
+ /** When `false`, omit from `navFromPages()` (route still registers). Default `true`. */
43
+ nav?: boolean;
44
+ /**
45
+ * If set, `navFromPages({ role })` / `navFromPages({ roles })` only includes this
46
+ * item when the viewer has at least one matching role. Omit = visible to everyone.
47
+ * UX only — still call `requireRole` in the page builder.
48
+ */
49
+ roles?: string[];
50
+ };
51
+
52
+ const pageMetaByPath = new Map<string, PageMeta>();
53
+
54
+ /** Clear all collected page meta (does not unregister routes). */
55
+ export function clearPageMeta(): void {
56
+ pageMetaByPath.clear();
57
+ }
58
+
59
+ /** Meta attached for a registered path, if any. */
60
+ export function getPageMeta(path: string): PageMeta | undefined {
61
+ return pageMetaByPath.get(path);
62
+ }
63
+
64
+ /** Attach or replace meta for a registered path (tests / advanced wiring). */
65
+ export function attachPageMeta(path: string, meta: PageMeta): void {
66
+ pageMetaByPath.set(path, meta);
67
+ }
68
+
69
+ function titleCaseSegment(segment: string): string {
70
+ return segment
71
+ .split('-')
72
+ .filter(Boolean)
73
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
74
+ .join(' ');
75
+ }
76
+
77
+ function labelFromPath(path: string): string {
78
+ if (path === '/') return 'Home';
79
+ const segment = path.split('/').filter(Boolean).pop() ?? path;
80
+ return titleCaseSegment(segment);
81
+ }
82
+
83
+ function shouldSkipPageFile(relativePath: string): boolean {
84
+ const base = relativePath.split('/').pop() ?? relativePath;
85
+ if (base === 'index.ts' || base === 'index.tsx') return true;
86
+ if (base.startsWith('_')) return true;
87
+ if (base.includes('.test.')) return true;
88
+ if (relativePath.split('/').some((part) => part.startsWith('_'))) return true;
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * Dynamically import page modules under `dir`.
94
+ * Clears prior registrations first, then fresh-imports each module (cache-busted)
95
+ * so `--reload` picks up edits. Each module should call `ui.page` once; optional
96
+ * `pageMeta` is attached to the new path.
97
+ */
98
+ export async function loadPages(dir: string | URL): Promise<string[]> {
99
+ const absDir = typeof dir === 'string' ? dir : fileURLToPath(dir);
100
+ const glob = new Bun.Glob('**/*.{ts,tsx}');
101
+ const files = [...glob.scanSync({ cwd: absDir })].filter((f) => !shouldSkipPageFile(f)).sort();
102
+
103
+ clearPages();
104
+ clearPageMeta();
105
+
106
+ const loaded: string[] = [];
107
+
108
+ for (const relative of files) {
109
+ const fullPath = join(absDir, relative);
110
+ const pathsBefore = new Set(getRegisteredPaths());
111
+ const mod = (await importFresh(fullPath)) as { pageMeta?: PageMeta };
112
+ const added = getRegisteredPaths().filter((p) => !pathsBefore.has(p));
113
+ if (added.length === 1 && mod.pageMeta) {
114
+ pageMetaByPath.set(added[0]!, mod.pageMeta);
115
+ } else if (added.length > 1 && mod.pageMeta) {
116
+ // Ambiguous: attach meta to all newly registered paths
117
+ for (const p of added) pageMetaByPath.set(p, mod.pageMeta);
118
+ }
119
+ for (const p of added) loaded.push(p);
120
+ }
121
+
122
+ return loaded;
123
+ }
124
+
125
+ /** Filter roles when building nav from page meta (`navFromPages`). */
126
+ export type NavFromPagesOptions = {
127
+ /** Single viewer role (sugar for `roles: [role]`). */
128
+ role?: string;
129
+ /** Viewer roles — page is shown if it has no `roles` meta, or any overlap. */
130
+ roles?: string[];
131
+ };
132
+
133
+ /**
134
+ * Build primary sidebar nav from registered pages + collected `pageMeta`.
135
+ * Filters by `nav: false` and optional role overlap; sorts by `order` then path.
136
+ *
137
+ * @example
138
+ * ```ts
139
+ * const nav = ui.navFromPages({ role: user.role });
140
+ * ui.app({ nav }, () => ui.label('Home'));
141
+ * ```
142
+ */
143
+ export function navFromPages(opts?: NavFromPagesOptions): AppNavItem[] {
144
+ const viewer = new Set<string>();
145
+ if (opts?.role) viewer.add(opts.role);
146
+ if (opts?.roles) {
147
+ for (const r of opts.roles) viewer.add(r);
148
+ }
149
+
150
+ const paths = getRegisteredPaths();
151
+ const ranked = paths
152
+ .map((href) => {
153
+ const meta = pageMetaByPath.get(href) ?? {};
154
+ const order = href === '/' ? 0 : (meta.order ?? 100);
155
+ return {
156
+ href,
157
+ label: meta.label ?? labelFromPath(href),
158
+ icon: meta.icon ?? 'boxes',
159
+ order,
160
+ nav: meta.nav !== false,
161
+ roles: meta.roles,
162
+ };
163
+ })
164
+ .filter((item) => {
165
+ if (!item.nav) return false;
166
+ if (!item.roles || item.roles.length === 0) return true;
167
+ if (viewer.size === 0) return false;
168
+ return item.roles.some((r) => viewer.has(r));
169
+ });
170
+ ranked.sort((a, b) => a.order - b.order || a.href.localeCompare(b.href));
171
+ return ranked.map(({ href, label, icon }) => ({ href, label, icon }));
172
+ }
173
+
174
+ /** Test helper: clear page registry + meta map. */
175
+ export function resetPageDiscovery(): void {
176
+ clearPages();
177
+ clearPageMeta();
178
+ }
179
+
180
+ export type { PageFn };