@magneticjs/cli 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.
@@ -0,0 +1,211 @@
1
+ // generator.ts — Auto-bridge generator
2
+ // Scans pages/ directory, detects state.ts, generates v8-bridge code
3
+ // so developers never write boilerplate wiring
4
+
5
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
6
+ import { join, relative, extname, basename, dirname } from 'node:path';
7
+
8
+ // ── Page scanning ───────────────────────────────────────────────────
9
+
10
+ export interface PageEntry {
11
+ /** Relative path from app dir: "pages/TasksPage.tsx" */
12
+ filePath: string;
13
+ /** Import name: "TasksPage" */
14
+ importName: string;
15
+ /** Route path: "/" or "/about" or "*" */
16
+ routePath: string;
17
+ /** Whether this is the catch-all 404 page */
18
+ isCatchAll: boolean;
19
+ }
20
+
21
+ export interface AppScan {
22
+ pages: PageEntry[];
23
+ /** Path to state module relative to app dir, or null */
24
+ statePath: string | null;
25
+ /** Whether state module exports toViewModel */
26
+ hasViewModel: boolean;
27
+ /** Path to magnetic-server package relative to app dir */
28
+ serverPkgPath: string;
29
+ }
30
+
31
+ const PAGE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
32
+
33
+ const CATCH_ALL_NAMES = ['notfound', '404', 'notfoundpage', '_404', 'error'];
34
+ const INDEX_NAMES = ['index', 'indexpage', 'home', 'homepage', 'tasks', 'taskspage', 'main', 'mainpage'];
35
+
36
+ /**
37
+ * Scan an app directory for pages and state.
38
+ */
39
+ export function scanApp(appDir: string, monorepoRoot?: string): AppScan {
40
+ const pagesDir = join(appDir, 'pages');
41
+ const pages: PageEntry[] = [];
42
+
43
+ if (existsSync(pagesDir)) {
44
+ scanDir(pagesDir, '', pages);
45
+ }
46
+
47
+ // Detect state module
48
+ const stateCandidates = [
49
+ 'state.ts', 'state.tsx',
50
+ 'server/state.ts', 'server/state.tsx',
51
+ 'store.ts', 'store.tsx',
52
+ ];
53
+ let statePath: string | null = null;
54
+ let hasViewModel = false;
55
+
56
+ for (const candidate of stateCandidates) {
57
+ const full = join(appDir, candidate);
58
+ if (existsSync(full)) {
59
+ statePath = './' + candidate;
60
+ // Check if it exports toViewModel (simple text search)
61
+ const content = readFileSync(full, 'utf-8');
62
+ hasViewModel = /export\s+(function|const)\s+toViewModel/.test(content);
63
+ break;
64
+ }
65
+ }
66
+
67
+ // Resolve path to @magneticjs/server
68
+ let serverPkgPath: string;
69
+ if (monorepoRoot) {
70
+ const relPath = relative(appDir, join(monorepoRoot, 'js/packages/magnetic-server/src'));
71
+ serverPkgPath = relPath.startsWith('.') ? relPath : './' + relPath;
72
+ } else {
73
+ serverPkgPath = '@magneticjs/server';
74
+ }
75
+
76
+ return { pages, statePath, hasViewModel, serverPkgPath };
77
+ }
78
+
79
+ function scanDir(dir: string, pathPrefix: string, pages: PageEntry[], rootPagesDir?: string) {
80
+ const pagesRoot = rootPagesDir || dir;
81
+ const entries = readdirSync(dir).sort();
82
+
83
+ for (const entry of entries) {
84
+ const fullPath = join(dir, entry);
85
+ const stat = statSync(fullPath);
86
+
87
+ if (stat.isDirectory()) {
88
+ // Nested directory → nested route
89
+ let segment = entry;
90
+ if (entry.startsWith('[') && entry.endsWith(']')) {
91
+ segment = ':' + entry.slice(1, -1);
92
+ }
93
+ scanDir(fullPath, pathPrefix + '/' + segment, pages, pagesRoot);
94
+ continue;
95
+ }
96
+
97
+ const ext = extname(entry);
98
+ if (!PAGE_EXTENSIONS.includes(ext)) continue;
99
+
100
+ // Skip special files
101
+ const nameNoExt = basename(entry, ext);
102
+ if (nameNoExt.startsWith('_') && !CATCH_ALL_NAMES.includes(nameNoExt.toLowerCase())) continue;
103
+ if (nameNoExt === 'layout') continue;
104
+
105
+ const importName = nameNoExt;
106
+ const nameLower = nameNoExt.toLowerCase().replace(/page$/, '');
107
+
108
+ let routePath: string;
109
+ let isCatchAll = false;
110
+
111
+ if (CATCH_ALL_NAMES.includes(nameLower)) {
112
+ routePath = '*';
113
+ isCatchAll = true;
114
+ } else if (INDEX_NAMES.includes(nameLower)) {
115
+ routePath = pathPrefix || '/';
116
+ } else if (nameNoExt.startsWith('[') && nameNoExt.endsWith(']')) {
117
+ // Dynamic param: [id].tsx → /:id
118
+ const param = nameNoExt.slice(1, -1);
119
+ routePath = pathPrefix + '/:' + param;
120
+ } else {
121
+ routePath = pathPrefix + '/' + nameLower;
122
+ }
123
+
124
+ // filePath relative to the app dir (e.g. "pages/AboutPage.tsx")
125
+ const relFromPagesRoot = relative(pagesRoot, fullPath).replace(/\\/g, '/');
126
+ const filePath = 'pages/' + relFromPagesRoot;
127
+
128
+ pages.push({ filePath, importName, routePath, isCatchAll });
129
+ }
130
+ }
131
+
132
+ // ── Bridge code generation ──────────────────────────────────────────
133
+
134
+ /**
135
+ * Generate the v8-bridge.tsx source code from scan results.
136
+ * This code is fed to esbuild (never written to disk as a permanent file).
137
+ */
138
+ export function generateBridge(scan: AppScan): string {
139
+ const lines: string[] = [];
140
+ lines.push('// AUTO-GENERATED by @magnetic/cli — do not edit');
141
+ lines.push(`import { createRouter } from '${scan.serverPkgPath}/router.ts';`);
142
+
143
+ // Import pages
144
+ const catchAllPage = scan.pages.find(p => p.isCatchAll);
145
+ for (const page of scan.pages) {
146
+ lines.push(`import { ${page.importName} } from './${page.filePath}';`);
147
+ }
148
+
149
+ // Import state (or generate default)
150
+ if (scan.statePath) {
151
+ lines.push(`import { initialState, reduce as _reduce${scan.hasViewModel ? ', toViewModel' : ''} } from '${scan.statePath}';`);
152
+ } else {
153
+ lines.push('');
154
+ lines.push('// No state.ts found — using minimal default state');
155
+ lines.push('function initialState() { return {}; }');
156
+ lines.push('function _reduce(state, action, payload) { return state; }');
157
+ }
158
+
159
+ if (!scan.hasViewModel && scan.statePath) {
160
+ lines.push('function toViewModel(s) { return s; }');
161
+ } else if (!scan.statePath) {
162
+ lines.push('function toViewModel(s) { return s; }');
163
+ }
164
+
165
+ // Router
166
+ lines.push('');
167
+ lines.push('const router = createRouter([');
168
+ // Non-catch-all routes first
169
+ for (const page of scan.pages) {
170
+ if (!page.isCatchAll) {
171
+ lines.push(` { path: '${page.routePath}', page: ${page.importName} },`);
172
+ }
173
+ }
174
+ // Catch-all last
175
+ if (catchAllPage) {
176
+ lines.push(` { path: '*', page: ${catchAllPage.importName} },`);
177
+ }
178
+ lines.push(']);');
179
+
180
+ // State + render/reduce
181
+ lines.push('');
182
+ lines.push('let state = initialState();');
183
+ lines.push('');
184
+ lines.push('export function render(path) {');
185
+ lines.push(' const vm = toViewModel(state);');
186
+ lines.push(' const result = router.resolve(path, vm);');
187
+ if (catchAllPage) {
188
+ lines.push(` if (!result) return ${catchAllPage.importName}({ params: {} });`);
189
+ } else {
190
+ lines.push(' if (!result) return { tag: "div", text: "Not Found" };');
191
+ }
192
+ lines.push(' if (result.kind === \'redirect\') {');
193
+ lines.push(' const r2 = router.resolve(result.to, vm);');
194
+ lines.push(' if (r2 && r2.kind === \'render\') return r2.dom;');
195
+ if (catchAllPage) {
196
+ lines.push(` return ${catchAllPage.importName}({ params: {} });`);
197
+ } else {
198
+ lines.push(' return { tag: "div", text: "Not Found" };');
199
+ }
200
+ lines.push(' }');
201
+ lines.push(' return result.dom;');
202
+ lines.push('}');
203
+ lines.push('');
204
+ lines.push('export function reduce(ap) {');
205
+ lines.push(' const { action, payload = {}, path = \'/\' } = ap;');
206
+ lines.push(' state = _reduce(state, action, payload);');
207
+ lines.push(' return render(path);');
208
+ lines.push('}');
209
+
210
+ return lines.join('\n') + '\n';
211
+ }