what-compiler 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-compiler",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "JSX compiler for What Framework - transforms JSX to optimized DOM operations",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  ".": "./src/index.js",
9
9
  "./babel": "./src/babel-plugin.js",
10
10
  "./vite": "./src/vite-plugin.js",
11
- "./runtime": "./src/runtime.js"
11
+ "./runtime": "./src/runtime.js",
12
+ "./file-router": "./src/file-router.js"
12
13
  },
13
14
  "keywords": [
14
15
  "what",
@@ -22,7 +23,7 @@
22
23
  "license": "MIT",
23
24
  "peerDependencies": {
24
25
  "@babel/core": "^7.0.0",
25
- "what-core": "^0.4.0"
26
+ "what-core": "^0.5.0"
26
27
  },
27
28
  "files": [
28
29
  "src"
@@ -1101,7 +1101,19 @@ export default function whatBabelPlugin({ types: t }) {
1101
1101
  }
1102
1102
  }
1103
1103
 
1104
- if (!existingRenderImport) {
1104
+ if (existingRenderImport) {
1105
+ const existingNames = new Set(
1106
+ existingRenderImport.specifiers
1107
+ .filter(s => t.isImportSpecifier(s))
1108
+ .map(s => s.imported.name)
1109
+ );
1110
+
1111
+ for (const spec of fgSpecifiers) {
1112
+ if (!existingNames.has(spec.imported.name)) {
1113
+ existingRenderImport.specifiers.push(spec);
1114
+ }
1115
+ }
1116
+ } else {
1105
1117
  path.unshiftContainer('body',
1106
1118
  t.importDeclaration(fgSpecifiers, t.stringLiteral('what-framework/render'))
1107
1119
  );
@@ -0,0 +1,320 @@
1
+ /**
2
+ * File-Based Router for What Framework
3
+ *
4
+ * Scans a pages directory and generates route configuration.
5
+ *
6
+ * File conventions:
7
+ * src/pages/index.jsx → /
8
+ * src/pages/about.jsx → /about
9
+ * src/pages/blog/index.jsx → /blog
10
+ * src/pages/blog/[slug].jsx → /blog/:slug
11
+ * src/pages/[...path].jsx → catch-all
12
+ * src/pages/_layout.jsx → layout for that directory
13
+ * src/pages/(auth)/login.jsx → /login (group doesn't affect URL)
14
+ * src/pages/api/users.js → API route: /api/users
15
+ *
16
+ * Page declarations (optional export in each page file):
17
+ * export const page = {
18
+ * mode: 'client', // default — SPA, JS required
19
+ * mode: 'server', // SSR on every request
20
+ * mode: 'static', // pre-rendered at build time
21
+ * mode: 'hybrid', // static HTML shell + interactive islands
22
+ * };
23
+ */
24
+
25
+ import fs from 'fs';
26
+ import path from 'path';
27
+
28
+ const PAGE_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts']);
29
+ const IGNORED_FILES = new Set(['_layout', '_error', '_loading', '_404']);
30
+
31
+ /**
32
+ * Scan a directory recursively and return all page files.
33
+ */
34
+ export function scanPages(pagesDir) {
35
+ const pages = [];
36
+ const layouts = [];
37
+ const apiRoutes = [];
38
+
39
+ function walk(dir, urlPrefix = '') {
40
+ if (!fs.existsSync(dir)) return;
41
+
42
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
43
+
44
+ for (const entry of entries) {
45
+ const fullPath = path.join(dir, entry.name);
46
+
47
+ if (entry.isDirectory()) {
48
+ // Route groups: (name)/ — strip from URL
49
+ const groupMatch = entry.name.match(/^\((.+)\)$/);
50
+ if (groupMatch) {
51
+ walk(fullPath, urlPrefix); // Same URL prefix
52
+ continue;
53
+ }
54
+
55
+ // API directory
56
+ if (entry.name === 'api' && urlPrefix === '') {
57
+ walkApi(fullPath, '/api');
58
+ continue;
59
+ }
60
+
61
+ walk(fullPath, urlPrefix + '/' + fileNameToSegment(entry.name));
62
+ continue;
63
+ }
64
+
65
+ // Only process page extensions
66
+ const ext = path.extname(entry.name);
67
+ if (!PAGE_EXTENSIONS.has(ext)) continue;
68
+
69
+ const baseName = path.basename(entry.name, ext);
70
+
71
+ // Layout files
72
+ if (baseName === '_layout') {
73
+ layouts.push({
74
+ filePath: fullPath,
75
+ urlPrefix: urlPrefix || '/',
76
+ });
77
+ continue;
78
+ }
79
+
80
+ // Error/loading/404 boundaries (reserved names)
81
+ if (IGNORED_FILES.has(baseName)) continue;
82
+
83
+ // Convert file name to URL segment
84
+ const urlSegment = fileNameToSegment(baseName);
85
+ const routePath = baseName === 'index'
86
+ ? (urlPrefix || '/')
87
+ : urlPrefix + '/' + urlSegment;
88
+
89
+ pages.push({
90
+ filePath: fullPath,
91
+ routePath: normalizePath(routePath),
92
+ isDynamic: routePath.includes(':') || routePath.includes('*'),
93
+ });
94
+ }
95
+ }
96
+
97
+ function walkApi(dir, urlPrefix) {
98
+ if (!fs.existsSync(dir)) return;
99
+
100
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
101
+
102
+ for (const entry of entries) {
103
+ const fullPath = path.join(dir, entry.name);
104
+
105
+ if (entry.isDirectory()) {
106
+ walkApi(fullPath, urlPrefix + '/' + fileNameToSegment(entry.name));
107
+ continue;
108
+ }
109
+
110
+ const ext = path.extname(entry.name);
111
+ if (!PAGE_EXTENSIONS.has(ext)) continue;
112
+
113
+ const baseName = path.basename(entry.name, ext);
114
+ const segment = fileNameToSegment(baseName);
115
+ const routePath = baseName === 'index'
116
+ ? urlPrefix
117
+ : urlPrefix + '/' + segment;
118
+
119
+ apiRoutes.push({
120
+ filePath: fullPath,
121
+ routePath: normalizePath(routePath),
122
+ });
123
+ }
124
+ }
125
+
126
+ walk(pagesDir);
127
+
128
+ // Sort: static routes first, then dynamic, then catch-all
129
+ pages.sort((a, b) => {
130
+ const aWeight = routeWeight(a.routePath);
131
+ const bWeight = routeWeight(b.routePath);
132
+ return aWeight - bWeight;
133
+ });
134
+
135
+ return { pages, layouts, apiRoutes };
136
+ }
137
+
138
+ /**
139
+ * Convert a file name to a URL segment.
140
+ * [slug] → :slug
141
+ * [...path] → *path (catch-all)
142
+ * about → about
143
+ */
144
+ function fileNameToSegment(name) {
145
+ // Catch-all: [...param]
146
+ const catchAll = name.match(/^\[\.\.\.(\w+)\]$/);
147
+ if (catchAll) return '*' + catchAll[1];
148
+
149
+ // Dynamic: [param]
150
+ const dynamic = name.match(/^\[(\w+)\]$/);
151
+ if (dynamic) return ':' + dynamic[1];
152
+
153
+ return name;
154
+ }
155
+
156
+ /**
157
+ * Normalize a route path.
158
+ */
159
+ function normalizePath(p) {
160
+ // Remove double slashes
161
+ let result = p.replace(/\/+/g, '/');
162
+ // Remove trailing slash (except root)
163
+ if (result.length > 1 && result.endsWith('/')) {
164
+ result = result.slice(0, -1);
165
+ }
166
+ return result || '/';
167
+ }
168
+
169
+ /**
170
+ * Route weight for sorting — static routes first.
171
+ */
172
+ function routeWeight(path) {
173
+ if (path.includes('*')) return 100; // Catch-all last
174
+ if (path.includes(':')) return 10; // Dynamic middle
175
+ return 0; // Static first
176
+ }
177
+
178
+ /**
179
+ * Extract `export const page = { ... }` from a file's source code.
180
+ * Uses simple regex — doesn't need a full parser for this.
181
+ */
182
+ export function extractPageConfig(source) {
183
+ // Match: export const page = { ... }
184
+ // Handles single-line and simple multi-line objects
185
+ const match = source.match(
186
+ /export\s+const\s+page\s*=\s*(\{[^}]*\})/s
187
+ );
188
+
189
+ if (!match) {
190
+ return { mode: 'client' }; // Default
191
+ }
192
+
193
+ try {
194
+ // Simple evaluation of the object literal
195
+ // Only supports string/boolean/number literals for safety
196
+ const obj = match[1]
197
+ .replace(/'/g, '"')
198
+ .replace(/(\w+)\s*:/g, '"$1":')
199
+ .replace(/,\s*}/g, '}')
200
+ .replace(/\/\/[^\n]*/g, ''); // Strip comments
201
+
202
+ return { mode: 'client', ...JSON.parse(obj) };
203
+ } catch {
204
+ return { mode: 'client' };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Generate the virtual routes module source code.
210
+ * This is what gets imported as 'virtual:what-routes'.
211
+ */
212
+ export function generateRoutesModule(pagesDir, rootDir) {
213
+ const { pages, layouts, apiRoutes } = scanPages(pagesDir);
214
+
215
+ const imports = [];
216
+ const routeEntries = [];
217
+
218
+ // Generate layout imports
219
+ const layoutMap = new Map();
220
+ layouts.forEach((layout, i) => {
221
+ const varName = `_layout${i}`;
222
+ const relPath = toImportPath(layout.filePath, rootDir);
223
+ imports.push(`import ${varName} from '${relPath}';`);
224
+ layoutMap.set(layout.urlPrefix, varName);
225
+ });
226
+
227
+ // Generate page imports and route entries
228
+ pages.forEach((page, i) => {
229
+ const varName = `_page${i}`;
230
+ const relPath = toImportPath(page.filePath, rootDir);
231
+ imports.push(`import ${varName} from '${relPath}';`);
232
+
233
+ // Read file to extract page config
234
+ let pageConfig = { mode: 'client' };
235
+ try {
236
+ const source = fs.readFileSync(page.filePath, 'utf-8');
237
+ pageConfig = extractPageConfig(source);
238
+ } catch {}
239
+
240
+ // Find matching layout (closest parent)
241
+ const layoutVar = findLayout(page.routePath, layoutMap);
242
+
243
+ const entry = {
244
+ path: page.routePath,
245
+ component: varName,
246
+ mode: pageConfig.mode || 'client',
247
+ layout: layoutVar || null,
248
+ };
249
+
250
+ routeEntries.push(entry);
251
+ });
252
+
253
+ // Generate API route entries
254
+ const apiEntries = [];
255
+ apiRoutes.forEach((route, i) => {
256
+ const varName = `_api${i}`;
257
+ const relPath = toImportPath(route.filePath, rootDir);
258
+ imports.push(`import * as ${varName} from '${relPath}';`);
259
+ apiEntries.push({
260
+ path: route.routePath,
261
+ handlers: varName,
262
+ });
263
+ });
264
+
265
+ // Build the module
266
+ const lines = [
267
+ '// Auto-generated by What Framework file router',
268
+ '// Do not edit — changes will be overwritten',
269
+ '',
270
+ ...imports,
271
+ '',
272
+ 'export const routes = [',
273
+ ...routeEntries.map(r =>
274
+ ` { path: '${r.path}', component: ${r.component}, mode: '${r.mode}'${r.layout ? `, layout: ${r.layout}` : ''} },`
275
+ ),
276
+ '];',
277
+ '',
278
+ `export const apiRoutes = [`,
279
+ ...apiEntries.map(r =>
280
+ ` { path: '${r.path}', handlers: ${r.handlers} },`
281
+ ),
282
+ '];',
283
+ '',
284
+ // Export page modes for the build system
285
+ 'export const pageModes = {',
286
+ ...routeEntries.map(r =>
287
+ ` '${r.path}': '${r.mode}',`
288
+ ),
289
+ '};',
290
+ ];
291
+
292
+ return lines.join('\n');
293
+ }
294
+
295
+ /**
296
+ * Convert absolute file path to a root-relative import path.
297
+ */
298
+ function toImportPath(filePath, rootDir) {
299
+ const rel = path.relative(rootDir, filePath);
300
+ // Ensure forward slashes and starts with /
301
+ return '/' + rel.split(path.sep).join('/');
302
+ }
303
+
304
+ /**
305
+ * Find the closest layout for a given route path.
306
+ */
307
+ function findLayout(routePath, layoutMap) {
308
+ // Walk up from the route path to find the nearest layout
309
+ const segments = routePath.split('/').filter(Boolean);
310
+
311
+ while (segments.length > 0) {
312
+ const prefix = '/' + segments.join('/');
313
+ if (layoutMap.has(prefix)) return layoutMap.get(prefix);
314
+ segments.pop();
315
+ }
316
+
317
+ // Check root layout
318
+ if (layoutMap.has('/')) return layoutMap.get('/');
319
+ return null;
320
+ }
package/src/index.js CHANGED
@@ -6,3 +6,4 @@
6
6
  export { default as babelPlugin } from './babel-plugin.js';
7
7
  export { default as vitePlugin, what } from './vite-plugin.js';
8
8
  export * from './runtime.js';
9
+ export { scanPages, extractPageConfig, generateRoutesModule } from './file-router.js';
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * What Framework Vite Plugin
3
- * Enables JSX transformation via the What babel plugin.
4
- * JSX is compiled to h() calls that go through what-core's VNode reconciler.
3
+ *
4
+ * 1. Transforms JSX via the What babel plugin
5
+ * 2. Provides file-based routing via virtual:what-routes
6
+ * 3. Watches pages directory for HMR
5
7
  */
6
8
 
9
+ import path from 'path';
7
10
  import { transformSync } from '@babel/core';
8
11
  import whatBabelPlugin from './babel-plugin.js';
12
+ import { generateRoutesModule, scanPages } from './file-router.js';
13
+
14
+ const VIRTUAL_ROUTES_ID = 'virtual:what-routes';
15
+ const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_ROUTES_ID;
9
16
 
10
17
  export default function whatVitePlugin(options = {}) {
11
18
  const {
@@ -16,12 +23,63 @@ export default function whatVitePlugin(options = {}) {
16
23
  // Enable source maps
17
24
  sourceMaps = true,
18
25
  // Production optimizations
19
- production = process.env.NODE_ENV === 'production'
26
+ production = process.env.NODE_ENV === 'production',
27
+ // Pages directory (relative to project root)
28
+ pages = 'src/pages',
20
29
  } = options;
21
30
 
31
+ let rootDir = '';
32
+ let pagesDir = '';
33
+ let server = null;
34
+
22
35
  return {
23
36
  name: 'vite-plugin-what',
24
37
 
38
+ configResolved(config) {
39
+ rootDir = config.root;
40
+ pagesDir = path.resolve(rootDir, pages);
41
+ },
42
+
43
+ configureServer(devServer) {
44
+ server = devServer;
45
+
46
+ // Watch the pages directory for file additions/removals
47
+ devServer.watcher.on('add', (file) => {
48
+ if (file.startsWith(pagesDir)) {
49
+ // Invalidate the virtual routes module
50
+ const mod = devServer.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
51
+ if (mod) {
52
+ devServer.moduleGraph.invalidateModule(mod);
53
+ devServer.ws.send({ type: 'full-reload' });
54
+ }
55
+ }
56
+ });
57
+
58
+ devServer.watcher.on('unlink', (file) => {
59
+ if (file.startsWith(pagesDir)) {
60
+ const mod = devServer.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
61
+ if (mod) {
62
+ devServer.moduleGraph.invalidateModule(mod);
63
+ devServer.ws.send({ type: 'full-reload' });
64
+ }
65
+ }
66
+ });
67
+ },
68
+
69
+ // Resolve virtual module
70
+ resolveId(id) {
71
+ if (id === VIRTUAL_ROUTES_ID) {
72
+ return RESOLVED_VIRTUAL_ID;
73
+ }
74
+ },
75
+
76
+ // Generate the routes module
77
+ load(id) {
78
+ if (id === RESOLVED_VIRTUAL_ID) {
79
+ return generateRoutesModule(pagesDir, rootDir);
80
+ }
81
+ },
82
+
25
83
  // Transform JSX files
26
84
  transform(code, id) {
27
85
  // Check if we should process this file