blokd 0.1.0-beta.0 → 0.1.0-beta.2

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/vite.ts DELETED
@@ -1,413 +0,0 @@
1
- import { transformAsync, type PluginObj } from '@babel/core';
2
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
- import { dirname, extname, join, relative, resolve } from 'node:path';
4
- import type { Plugin, ViteDevServer } from 'vite';
5
-
6
- export type BlokdPluginOptions = {
7
- routesDir?: string;
8
- extensions?: string[];
9
- /** Throw when two files map to the same URL path. Enabled by default. */
10
- strictRoutes?: boolean;
11
- /** Infer whether a route needs the client entry. Enabled by default. */
12
- analyzeClient?: boolean;
13
- };
14
-
15
- const VIRTUAL_ROUTES = 'virtual:blokd/routes';
16
- const RESOLVED_VIRTUAL_ROUTES = '\0' + VIRTUAL_ROUTES;
17
-
18
- export function slash(path: string): string {
19
- return path.replaceAll('\\', '/');
20
- }
21
-
22
- export function walkRoutes(dir: string, extensions: string[], files: string[] = []): string[] {
23
- if (!existsSync(dir)) return files;
24
- for (const entry of readdirSync(dir)) {
25
- const full = join(dir, entry);
26
- const stat = statSync(full);
27
- if (stat.isDirectory()) walkRoutes(full, extensions, files);
28
- else if (extensions.includes(extname(entry))) files.push(full);
29
- }
30
- return files;
31
- }
32
-
33
- export function segmentToPath(segment: string): string {
34
- if (segment === 'index') return '';
35
- if (/^\(.+\)$/.test(segment)) return '';
36
- const rest = segment.match(/^\[\.\.\.(.+)\]$/);
37
- if (rest) return `:${rest[1]}*`;
38
- const dyn = segment.match(/^\[(.+)\]$/);
39
- if (dyn) return `:${dyn[1]}`;
40
- return segment;
41
- }
42
-
43
- export function fileToRoutePath(routesDir: string, file: string): string {
44
- const rel = slash(relative(routesDir, file));
45
- const noExt = rel.slice(0, -extname(rel).length);
46
- const segments = noExt.split('/').filter(Boolean).map(segmentToPath).filter(Boolean);
47
- return '/' + segments.join('/');
48
- }
49
-
50
- export function assertNoDuplicateRoutes(routesDir: string, files: string[]): void {
51
- const seen = new Map<string, string>();
52
- for (const file of files) {
53
- const route = fileToRoutePath(routesDir, file);
54
- const previous = seen.get(route);
55
- if (previous) throw new Error(`Duplicate Blokd route path ${route}: ${previous} and ${file}`);
56
- seen.set(route, file);
57
- }
58
- }
59
-
60
- function findSpecialFile(currentDir: string, name: '_layout' | '_error' | '_404', extensions: string[]): string | null {
61
- for (const ext of extensions) {
62
- const candidate = join(currentDir, `${name}${ext}`);
63
- if (existsSync(candidate)) return candidate;
64
- }
65
- return null;
66
- }
67
-
68
- function findLayout(currentDir: string, extensions: string[]): string | null {
69
- return findSpecialFile(currentDir, '_layout', extensions);
70
- }
71
-
72
- export function layoutFilesFor(routesDir: string, file: string, extensions: string[]): string[] {
73
- const layouts: string[] = [];
74
- let current = dirname(file);
75
- while (current.startsWith(routesDir)) {
76
- const layout = findLayout(current, extensions);
77
- if (layout) layouts.push(layout);
78
- if (current === routesDir) break;
79
- current = dirname(current);
80
- }
81
- return layouts.reverse();
82
- }
83
-
84
- export function nearestSpecialFile(routesDir: string, file: string, name: '_error' | '_404', extensions: string[]): string | null {
85
- let current = dirname(file);
86
- while (current.startsWith(routesDir)) {
87
- const found = findSpecialFile(current, name, extensions);
88
- if (found) return found;
89
- if (current === routesDir) break;
90
- current = dirname(current);
91
- }
92
- return null;
93
- }
94
-
95
- function isPrivateRouteFile(file: string): boolean {
96
- const base = file.split(/[\\/]/).pop() ?? '';
97
- return base.startsWith('_layout.') || base.startsWith('_error.') || base.startsWith('_404.');
98
- }
99
-
100
- function importPath(root: string, file: string): string {
101
- return '/' + slash(relative(root, file));
102
- }
103
-
104
- const clientMarkers = [
105
- /\bon[A-Z][A-Za-z0-9_]*\s*=/,
106
- /\bon[a-z][A-Za-z0-9_]*\s*=/,
107
- /\bsignal\s*\(/,
108
- /\bmemo\s*\(/,
109
- /\beffect\s*\(/,
110
- /\bcleanup\s*\(/,
111
- /\bIsland\s*[({<]/,
112
- /\bresumable\s*\(/,
113
- /\bstartResumability\s*\(/
114
- ];
115
-
116
- function fileNeedsClient(file: string, extensions: string[], visited = new Set<string>()): boolean {
117
- const normalized = resolve(file);
118
- if (visited.has(normalized) || !existsSync(normalized)) return false;
119
- visited.add(normalized);
120
- let source = '';
121
- try { source = readFileSync(normalized, 'utf8'); }
122
- catch { return false; }
123
- if (importsBlokdClient(source)) return true;
124
- const markerSource = stripCommentsAndStrings(source);
125
- if (clientMarkers.some(marker => marker.test(markerSource))) return true;
126
-
127
- const dir = dirname(normalized);
128
- const imports = source.matchAll(/(?:import|export)\s+(?:[^'"]*from\s+)?['"](\.\.?\/[^'"]+)['"]/g);
129
- for (const match of imports) {
130
- const target = resolveImport(dir, match[1]!, extensions);
131
- if (target && fileNeedsClient(target, extensions, visited)) return true;
132
- }
133
- return false;
134
- }
135
-
136
- function importsBlokdClient(source: string): boolean {
137
- return /^\s*import\s+[^'"]*from\s+['"]blokd\/client['"]/m.test(source)
138
- || /^\s*import\s+['"]blokd\/client['"]/m.test(source)
139
- || /^\s*export\s+[^'"]*from\s+['"]blokd\/client['"]/m.test(source);
140
- }
141
-
142
- function stripCommentsAndStrings(source: string): string {
143
- let out = '';
144
- for (let i = 0; i < source.length; i++) {
145
- const char = source[i]!;
146
- const next = source[i + 1];
147
-
148
- if (char === '/' && next === '/') {
149
- out += ' ';
150
- i += 2;
151
- while (i < source.length && source[i] !== '\n') {
152
- out += ' ';
153
- i++;
154
- }
155
- if (source[i] === '\n') out += '\n';
156
- continue;
157
- }
158
-
159
- if (char === '/' && next === '*') {
160
- out += ' ';
161
- i += 2;
162
- while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
163
- out += source[i] === '\n' ? '\n' : ' ';
164
- i++;
165
- }
166
- if (i < source.length) {
167
- out += ' ';
168
- i++;
169
- }
170
- continue;
171
- }
172
-
173
- if (char === '"' || char === "'" || char === '`') {
174
- const quote = char;
175
- out += ' ';
176
- i++;
177
- while (i < source.length) {
178
- const current = source[i]!;
179
- out += current === '\n' ? '\n' : ' ';
180
- if (current === '\\') {
181
- i++;
182
- if (i < source.length) out += source[i] === '\n' ? '\n' : ' ';
183
- } else if (current === quote) {
184
- break;
185
- }
186
- i++;
187
- }
188
- continue;
189
- }
190
-
191
- out += char;
192
- }
193
- return out;
194
- }
195
-
196
- function resolveImport(dir: string, specifier: string, extensions: string[]): string | null {
197
- const base = resolve(dir, specifier);
198
- if (existsSync(base) && statSync(base).isFile()) return base;
199
- for (const ext of extensions) {
200
- if (existsSync(base + ext)) return base + ext;
201
- const index = join(base, `index${ext}`);
202
- if (existsSync(index)) return index;
203
- }
204
- return null;
205
- }
206
-
207
- export function makeManifestCode(root: string, routesDir: string, extensions = ['.tsx', '.jsx', '.ts', '.js'], strictRoutes = true, analyzeClient = true): string {
208
- const files = walkRoutes(routesDir, extensions)
209
- .filter(file => !isPrivateRouteFile(file))
210
- .sort((a, b) => fileToRoutePath(routesDir, a).localeCompare(fileToRoutePath(routesDir, b)));
211
-
212
- if (strictRoutes) assertNoDuplicateRoutes(routesDir, files);
213
-
214
- const entries = files.map(file => {
215
- const id = slash(relative(routesDir, file)).replace(new RegExp(`${extname(file)}$`), '');
216
- const path = fileToRoutePath(routesDir, file);
217
- const layoutsForRoute = layoutFilesFor(routesDir, file, extensions);
218
- const layouts = layoutsForRoute
219
- .map(layout => `() => import(${JSON.stringify(importPath(root, layout))})`)
220
- .join(', ');
221
- const error = nearestSpecialFile(routesDir, file, '_error', extensions);
222
- const notFound = nearestSpecialFile(routesDir, file, '_404', extensions);
223
- const hasClient = analyzeClient
224
- ? [file, ...layoutsForRoute].some(candidate => fileNeedsClient(candidate, extensions))
225
- : true;
226
- const fields = [
227
- `id: ${JSON.stringify(id)}`,
228
- `path: ${JSON.stringify(path)}`,
229
- `hasClient: ${hasClient}`,
230
- `layouts: [${layouts}]`,
231
- error ? `error: () => import(${JSON.stringify(importPath(root, error))})` : '',
232
- notFound ? `notFound: () => import(${JSON.stringify(importPath(root, notFound))})` : '',
233
- `module: () => import(${JSON.stringify(importPath(root, file))})`
234
- ].filter(Boolean).join(', ');
235
- return ` { ${fields} }`;
236
- });
237
-
238
- return `const routes = [\n${entries.join(',\n')}\n];\nexport default routes;\n`;
239
- }
240
-
241
- function jsxTransformPlugin(api: any): PluginObj {
242
- const t = api.types;
243
- return {
244
- visitor: {
245
- JSXElement(path: any, state: any) {
246
- state.used = true;
247
- path.replaceWith(buildElement(path.node, t));
248
- },
249
- JSXFragment(path: any, state: any) {
250
- state.used = true;
251
- path.replaceWith(buildFragment(path.node, t));
252
- },
253
- Program: {
254
- exit(path: any, state: any) {
255
- if (!state.used) return;
256
- path.unshiftContainer('body', t.importDeclaration([
257
- t.importSpecifier(t.identifier('_bd_jsx'), t.identifier('jsx')),
258
- t.importSpecifier(t.identifier('_bd_jsxs'), t.identifier('jsxs')),
259
- t.importSpecifier(t.identifier('_bd_Fragment'), t.identifier('Fragment')),
260
- t.importSpecifier(t.identifier('_bd_lazy'), t.identifier('lazy'))
261
- ], t.stringLiteral('blokd/jsx-runtime')));
262
- }
263
- }
264
- }
265
- };
266
- }
267
-
268
- function buildElement(node: any, t: any): any {
269
- const children = buildChildren(node.children, t);
270
- const props = buildProps(node.openingElement.attributes, children, t);
271
- return t.callExpression(t.identifier('_bd_jsx'), [jsxNameToExpr(node.openingElement.name, t), props]);
272
- }
273
-
274
- function buildFragment(node: any, t: any): any {
275
- const children = buildChildren(node.children, t);
276
- return t.callExpression(t.identifier('_bd_jsx'), [t.identifier('_bd_Fragment'), buildProps([], children, t)]);
277
- }
278
-
279
- function jsxNameToExpr(name: any, t: any): any {
280
- if (t.isJSXIdentifier(name)) {
281
- const value = name.name;
282
- return /^[a-z]/.test(value) || value.includes('-') ? t.stringLiteral(value) : t.identifier(value);
283
- }
284
- if (t.isJSXMemberExpression(name)) return t.memberExpression(jsxNameToExpr(name.object, t), jsxNameToExpr(name.property, t));
285
- if (t.isJSXNamespacedName(name)) return t.stringLiteral(`${name.namespace.name}:${name.name.name}`);
286
- return t.stringLiteral('unknown');
287
- }
288
-
289
- function attrName(name: any, t: any): string {
290
- if (t.isJSXIdentifier(name)) return name.name;
291
- if (t.isJSXNamespacedName(name)) return `${name.namespace.name}:${name.name.name}`;
292
- return 'unknown';
293
- }
294
-
295
- function isEventOrRef(name: string): boolean {
296
- return /^on[A-Z]/.test(name) || /^on[a-z]/.test(name) || name === 'ref';
297
- }
298
-
299
- function wrapLazy(expr: any, t: any): any {
300
- return t.callExpression(t.identifier('_bd_lazy'), [t.arrowFunctionExpression([], expr)]);
301
- }
302
-
303
- function buildProps(attrs: any[], children: any | null, t: any): any {
304
- const props: any[] = [];
305
- for (const attr of attrs) {
306
- if (t.isJSXSpreadAttribute(attr)) {
307
- props.push(t.spreadElement(attr.argument));
308
- continue;
309
- }
310
- const name = attrName(attr.name, t);
311
- let value: any;
312
- if (!attr.value) value = t.booleanLiteral(true);
313
- else if (t.isStringLiteral(attr.value)) value = attr.value;
314
- else if (t.isJSXExpressionContainer(attr.value)) {
315
- const expr = attr.value.expression;
316
- if (t.isJSXEmptyExpression(expr)) value = t.booleanLiteral(true);
317
- else if (isEventOrRef(name) || t.isFunctionExpression(expr) || t.isArrowFunctionExpression(expr)) value = expr;
318
- else value = wrapLazy(expr, t);
319
- } else value = t.stringLiteral('');
320
- props.push(t.objectProperty(t.stringLiteral(name), value));
321
- }
322
- if (children) props.push(t.objectProperty(t.identifier('children'), children));
323
- return t.objectExpression(props);
324
- }
325
-
326
- function buildChildren(children: any[], t: any): any | null {
327
- const out: any[] = [];
328
- for (const child of children) {
329
- const expr = childToExpr(child, t);
330
- if (expr) out.push(expr);
331
- }
332
- if (out.length === 0) return null;
333
- if (out.length === 1) return out[0];
334
- return t.arrayExpression(out);
335
- }
336
-
337
- function childToExpr(child: any, t: any): any | null {
338
- if (t.isJSXText(child)) {
339
- const text = normalizeJsxText(child.value);
340
- return text ? t.stringLiteral(text) : null;
341
- }
342
- if (t.isJSXExpressionContainer(child)) {
343
- const expr = child.expression;
344
- if (t.isJSXEmptyExpression(expr)) return null;
345
- if (t.isFunctionExpression(expr) || t.isArrowFunctionExpression(expr)) return expr;
346
- return wrapLazy(expr, t);
347
- }
348
- if (t.isJSXSpreadChild(child)) return child.expression;
349
- if (t.isJSXElement(child)) return buildElement(child, t);
350
- if (t.isJSXFragment(child)) return buildFragment(child, t);
351
- return null;
352
- }
353
-
354
- function normalizeJsxText(text: string): string {
355
- const lines = text.replace(/\t/g, ' ').split(/\r?\n/);
356
- const normalized = lines.map((line, index) => {
357
- let next = line.replace(/\s+/g, ' ');
358
- if (index === 0) next = next.replace(/^\s+/, '');
359
- if (index === lines.length - 1) next = next.replace(/\s+$/, '');
360
- return next;
361
- }).filter(Boolean).join(' ');
362
- return normalized;
363
- }
364
-
365
- export function blokd(options: BlokdPluginOptions = {}): Plugin {
366
- let root = process.cwd();
367
- let routesDir = resolve(root, options.routesDir ?? 'src/routes');
368
- const extensions = options.extensions ?? ['.tsx', '.jsx', '.ts', '.js'];
369
-
370
- function invalidateRoutes(server: ViteDevServer): void {
371
- const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ROUTES);
372
- if (mod) server.moduleGraph.invalidateModule(mod);
373
- server.ws.send({ type: 'full-reload' });
374
- }
375
-
376
- return {
377
- name: 'blokd',
378
- enforce: 'pre',
379
- configResolved(config: any) {
380
- root = config.root;
381
- routesDir = resolve(root, options.routesDir ?? 'src/routes');
382
- },
383
- configureServer(server: ViteDevServer) {
384
- server.watcher.add(routesDir);
385
- server.watcher.on('add', (file: string) => { if (file.startsWith(routesDir)) invalidateRoutes(server); });
386
- server.watcher.on('unlink', (file: string) => { if (file.startsWith(routesDir)) invalidateRoutes(server); });
387
- },
388
- resolveId(id: string) {
389
- if (id === VIRTUAL_ROUTES) return RESOLVED_VIRTUAL_ROUTES;
390
- return null;
391
- },
392
- load(id: string) {
393
- if (id === RESOLVED_VIRTUAL_ROUTES) return makeManifestCode(root, routesDir, extensions, options.strictRoutes ?? true, options.analyzeClient ?? true);
394
- return null;
395
- },
396
- async transform(code: string, id: string) {
397
- if (!/\.[cm]?[jt]sx$/.test(id) || !code.includes('<')) return null;
398
- const result = await transformAsync(code, {
399
- filename: id,
400
- sourceMaps: true,
401
- babelrc: false,
402
- configFile: false,
403
- parserOpts: { sourceType: 'module', plugins: ['typescript', 'jsx', 'importMeta', 'topLevelAwait'] },
404
- generatorOpts: { jsescOption: { minimal: true } },
405
- plugins: [jsxTransformPlugin]
406
- });
407
- if (!result?.code) return null;
408
- return { code: result.code, map: result.map as any };
409
- }
410
- };
411
- }
412
-
413
- export default blokd;