@anaemia/bundler 0.0.1
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/LICENSE +201 -0
- package/README.md +3 -0
- package/dist/aliases.d.ts +8 -0
- package/dist/aliases.d.ts.map +1 -0
- package/dist/aliases.js +10 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +153 -0
- package/dist/optimization.d.ts +41 -0
- package/dist/optimization.d.ts.map +1 -0
- package/dist/optimization.js +35 -0
- package/dist/plugins/babel-hash-injector-server.d.ts +7 -0
- package/dist/plugins/babel-hash-injector-server.d.ts.map +1 -0
- package/dist/plugins/babel-hash-injector-server.js +17 -0
- package/dist/plugins/babel-transform-client.d.ts +6 -0
- package/dist/plugins/babel-transform-client.d.ts.map +1 -0
- package/dist/plugins/babel-transform-client.js +49 -0
- package/dist/plugins/babel-transform-server.d.ts +11 -0
- package/dist/plugins/babel-transform-server.d.ts.map +1 -0
- package/dist/plugins/babel-transform-server.js +60 -0
- package/dist/plugins/rspack-manifest-hydration.d.ts +11 -0
- package/dist/plugins/rspack-manifest-hydration.d.ts.map +1 -0
- package/dist/plugins/rspack-manifest-hydration.js +38 -0
- package/dist/plugins/server-function-id.d.ts +2 -0
- package/dist/plugins/server-function-id.d.ts.map +1 -0
- package/dist/plugins/server-function-id.js +3 -0
- package/dist/router/generate-entry.d.ts +3 -0
- package/dist/router/generate-entry.d.ts.map +1 -0
- package/dist/router/generate-entry.js +243 -0
- package/dist/router/generate-server-routes.d.ts +3 -0
- package/dist/router/generate-server-routes.d.ts.map +1 -0
- package/dist/router/generate-server-routes.js +30 -0
- package/dist/router/manifest.d.ts +12 -0
- package/dist/router/manifest.d.ts.map +1 -0
- package/dist/router/manifest.js +23 -0
- package/dist/router/scan.d.ts +22 -0
- package/dist/router/scan.d.ts.map +1 -0
- package/dist/router/scan.js +164 -0
- package/dist/rules.d.ts +47 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +42 -0
- package/dist/server-function-id.d.ts +2 -0
- package/dist/server-function-id.d.ts.map +1 -0
- package/dist/server-function-id.js +3 -0
- package/package.json +36 -0
- package/src/aliases.ts +11 -0
- package/src/index.ts +170 -0
- package/src/optimization.ts +37 -0
- package/src/plugins/babel-hash-injector-server.ts +19 -0
- package/src/plugins/babel-transform-server.ts +144 -0
- package/src/plugins/rspack-manifest-hydration.ts +56 -0
- package/src/router/generate-entry.ts +306 -0
- package/src/router/generate-server-routes.ts +36 -0
- package/src/router/manifest.ts +42 -0
- package/src/router/scan.ts +228 -0
- package/src/rules.ts +58 -0
- package/src/runtime/empty-module.cjs +10 -0
- package/src/server-function-id.ts +3 -0
- package/test/dev-config.test.mjs +30 -0
- package/test/server-functions.test.mjs +77 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { RouteManifestEntry } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
type LayoutNode = {
|
|
6
|
+
kind: "layout";
|
|
7
|
+
layoutFile: string;
|
|
8
|
+
layoutIdx: number;
|
|
9
|
+
relativePath: string;
|
|
10
|
+
prefixPath: string;
|
|
11
|
+
children: TreeNode[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type PageNode = {
|
|
15
|
+
kind: "page";
|
|
16
|
+
route: RouteManifestEntry;
|
|
17
|
+
routeIdx: number;
|
|
18
|
+
relativePath: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type TreeNode = LayoutNode | PageNode;
|
|
22
|
+
|
|
23
|
+
function buildTree(routes: RouteManifestEntry[], strippedLayouts: string[][], routeIndices: number[], routesDir: string, allLayouts: Map<string, number>, parentPrefix: string): TreeNode[] {
|
|
24
|
+
const nodes: TreeNode[] = [];
|
|
25
|
+
const leafIndices = strippedLayouts.map((l, i) => (l.length === 0 ? i : -1)).filter((i) => i !== -1);
|
|
26
|
+
|
|
27
|
+
for (const i of leafIndices) {
|
|
28
|
+
const route = routes[routeIndices[i]];
|
|
29
|
+
const relativePath = toRelativePath(route.urlPattern, parentPrefix);
|
|
30
|
+
nodes.push({
|
|
31
|
+
kind: "page",
|
|
32
|
+
route,
|
|
33
|
+
routeIdx: routeIndices[i],
|
|
34
|
+
relativePath,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const byLayout = new Map<string, { ri: number[]; sl: string[][] }>();
|
|
39
|
+
for (let i = 0; i < strippedLayouts.length; i++) {
|
|
40
|
+
if (strippedLayouts[i].length === 0) continue;
|
|
41
|
+
const nextLayout = strippedLayouts[i][0];
|
|
42
|
+
if (!byLayout.has(nextLayout)) byLayout.set(nextLayout, { ri: [], sl: [] });
|
|
43
|
+
byLayout.get(nextLayout)!.ri.push(routeIndices[i]);
|
|
44
|
+
byLayout.get(nextLayout)!.sl.push(strippedLayouts[i].slice(1));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const [layoutFile, { ri, sl }] of byLayout) {
|
|
48
|
+
const layoutIdx = allLayouts.get(layoutFile)!;
|
|
49
|
+
const layoutDir = path.dirname(path.relative(routesDir, layoutFile));
|
|
50
|
+
const prefixPath = layoutDir === "." ? "/" : `/${layoutDir.replace(/\\/g, "/")}`;
|
|
51
|
+
const relativePath = toRelativePath(prefixPath, parentPrefix);
|
|
52
|
+
|
|
53
|
+
const children = buildTree(routes, sl, ri, routesDir, allLayouts, prefixPath);
|
|
54
|
+
nodes.push({ kind: "layout", layoutFile, layoutIdx, prefixPath, children, relativePath });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return nodes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toRelativePath(absolute: string, parentPrefix: string): string {
|
|
61
|
+
if (parentPrefix === "/" || parentPrefix === "") return absolute;
|
|
62
|
+
if (absolute.startsWith(parentPrefix)) {
|
|
63
|
+
const rel = absolute.slice(parentPrefix.length) || "/";
|
|
64
|
+
return rel.startsWith("/") ? rel : `/${rel}`;
|
|
65
|
+
}
|
|
66
|
+
return absolute;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderTree(nodes: TreeNode[], indent = 6): string {
|
|
70
|
+
const pad = " ".repeat(indent);
|
|
71
|
+
return nodes
|
|
72
|
+
.map((node) => {
|
|
73
|
+
if (node.kind === "page") {
|
|
74
|
+
let routePath = node.relativePath;
|
|
75
|
+
|
|
76
|
+
if (node.route.type === "catch-all") {
|
|
77
|
+
const paramName = node.route.params[0] || "any";
|
|
78
|
+
if (routePath.endsWith("*")) {
|
|
79
|
+
routePath = routePath.slice(0, -1) + `*${paramName}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (indent > 6 && routePath === "/") {
|
|
84
|
+
routePath = "";
|
|
85
|
+
} else if (indent > 6 && routePath.startsWith("/") && routePath !== "/") {
|
|
86
|
+
routePath = routePath.slice(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (routePath === "") {
|
|
90
|
+
return `${pad}<Route component={Route${node.routeIdx}Wrapped} />`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return `${pad}<Route path="${routePath}" component={Route${node.routeIdx}Wrapped} />`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let layoutPath = (node as any).relativePath;
|
|
97
|
+
const inner = renderTree(node.children, indent + 2);
|
|
98
|
+
|
|
99
|
+
return [`${pad}<Route path="${layoutPath}" component={Layout${node.layoutIdx}}>`, inner, `${pad}</Route>`].join("\n");
|
|
100
|
+
})
|
|
101
|
+
.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildPreloadMapString(routes: RouteManifestEntry[], allLayouts: Map<string, number>): string {
|
|
105
|
+
const mapLines = routes.map((r, i) => {
|
|
106
|
+
const layoutTokens = r.layouts.map((l) => `Layout${allLayouts.get(l.filePath)}`);
|
|
107
|
+
const pageToken = `Route${i}`;
|
|
108
|
+
const tokensArrayString = `[${[...layoutTokens, pageToken].join(", ")}]`;
|
|
109
|
+
return ` "${r.urlPattern}": ${tokensArrayString}`;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return `const chunkPreloadRegistry = {\n${mapLines.join(",\n")}\n};`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function generateRouterEntry(appRoot: string, routes: RouteManifestEntry[]): string {
|
|
116
|
+
const routesDir = path.resolve(appRoot, "./src/routes");
|
|
117
|
+
const outDir = path.resolve(appRoot, "./.anaemia");
|
|
118
|
+
const outPath = path.resolve(outDir, "./__anaemia_entry__.tsx");
|
|
119
|
+
|
|
120
|
+
const conventionalRoutes = routes.filter((r) => !r.filePath.endsWith("404.tsx") && !r.filePath.endsWith("500.tsx"));
|
|
121
|
+
const errorRoutes = routes.filter((r) => r.filePath.endsWith("404.tsx") || r.filePath.endsWith("500.tsx"));
|
|
122
|
+
|
|
123
|
+
const allLayouts = new Map<string, number>();
|
|
124
|
+
|
|
125
|
+
let layoutIndex = 0;
|
|
126
|
+
for (const entry of conventionalRoutes) {
|
|
127
|
+
for (const layout of entry.layouts) {
|
|
128
|
+
if (!allLayouts.has(layout.filePath)) {
|
|
129
|
+
allLayouts.set(layout.filePath, layoutIndex++);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const routeImports = conventionalRoutes
|
|
135
|
+
.map((r, i) => {
|
|
136
|
+
const relativeToRoutes = path.relative(routesDir, r.filePath);
|
|
137
|
+
const chunkName = relativeToRoutes
|
|
138
|
+
.replace(/\.[jt]sx?$/, "")
|
|
139
|
+
.replace(/[^a-zA-Z0-9-_\[\]]/g, "-")
|
|
140
|
+
.toLowerCase();
|
|
141
|
+
|
|
142
|
+
const guardSources = [...r.layouts.map((l) => l.filePath), r.filePath]
|
|
143
|
+
.map((fp) => {
|
|
144
|
+
const configPath = fp.replace(/\.(tsx|jsx)$/, ".config.ts");
|
|
145
|
+
const guardPath = fs.existsSync(configPath) ? configPath : fp.replace(/\.(jsx)$/, ".config.js");
|
|
146
|
+
const resolvedGuardPath = fs.existsSync(guardPath) ? guardPath : fp;
|
|
147
|
+
return `() => import("${resolvedGuardPath.replace(/\\/g, "/")}").then(m => m?.config?.guards ?? [])`;
|
|
148
|
+
})
|
|
149
|
+
.join(",\n ");
|
|
150
|
+
|
|
151
|
+
return `
|
|
152
|
+
const Route${i} = lazy(() => import(/* webpackChunkName: "${chunkName}" */ "${r.filePath.replace(/\\/g, "/")}"));
|
|
153
|
+
const Route${i}Loader = async (args) => {
|
|
154
|
+
const _guardSources = [
|
|
155
|
+
${guardSources}
|
|
156
|
+
];
|
|
157
|
+
for (const loadGuards of _guardSources) {
|
|
158
|
+
const guards = await loadGuards();
|
|
159
|
+
for (const guard of guards) {
|
|
160
|
+
const result = await guard({ params: args.params, request: args.request, url: args.location?.pathname ?? "" });
|
|
161
|
+
if (result?.redirect) {
|
|
162
|
+
throw Object.assign(new Error("guard:redirect"), { redirect: result.redirect, status: result.status ?? 302 });
|
|
163
|
+
}
|
|
164
|
+
if (result?.status) {
|
|
165
|
+
throw Object.assign(new Error("guard:error"), { status: result.status });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return import(/* webpackChunkName: "${chunkName}" */ "${r.filePath.replace(/\\/g, "/")}").then(mod => {
|
|
170
|
+
return mod.loader ? mod.loader(args) : null;
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
const Route${i}Wrapped = (props) => (
|
|
174
|
+
<RouteDataController loader={Route${i}Loader}>
|
|
175
|
+
<Route${i} {...props} />
|
|
176
|
+
</RouteDataController>
|
|
177
|
+
);`.trim();
|
|
178
|
+
})
|
|
179
|
+
.join("\n");
|
|
180
|
+
|
|
181
|
+
const layoutImports = [...allLayouts.entries()]
|
|
182
|
+
.map(([file, i]) => {
|
|
183
|
+
const relativeToRoutes = path.relative(routesDir, file);
|
|
184
|
+
const chunkName = ("layout-" + relativeToRoutes.replace(/\.[jt]sx?$/, "").replace(/[^a-zA-Z0-9-_\[\]]/g, "-")).toLowerCase();
|
|
185
|
+
|
|
186
|
+
return `import Layout${i} from "${file.replace(/\\/g, "/")}";`;
|
|
187
|
+
})
|
|
188
|
+
.join("\n");
|
|
189
|
+
|
|
190
|
+
const tree = buildTree(
|
|
191
|
+
conventionalRoutes,
|
|
192
|
+
conventionalRoutes.map((r) => r.layouts.map((l) => l.filePath)),
|
|
193
|
+
conventionalRoutes.map((_, i) => i),
|
|
194
|
+
routesDir,
|
|
195
|
+
allLayouts,
|
|
196
|
+
"/"
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
let routeJsx = renderTree(tree, 6);
|
|
200
|
+
|
|
201
|
+
const has404 = errorRoutes.some((r) => r.filePath.endsWith("404.tsx"));
|
|
202
|
+
if (has404) {
|
|
203
|
+
const idx = routes.findIndex((r) => r.filePath.endsWith("404.tsx"));
|
|
204
|
+
routeJsx += `\n <Route path="*any" component={Route${idx}} />`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const rootWrapperPath = path.resolve(appRoot, "./src/root.tsx");
|
|
208
|
+
const hasRootWrapper = fs.existsSync(rootWrapperPath);
|
|
209
|
+
|
|
210
|
+
const rootImport = hasRootWrapper ? `import RootWrapper from "../src/root.tsx";` : ``;
|
|
211
|
+
|
|
212
|
+
const rootWrapperCode = hasRootWrapper
|
|
213
|
+
? `const RootWrapperComponent = (props) => (
|
|
214
|
+
<RootWrapper {...props} />
|
|
215
|
+
);`
|
|
216
|
+
: ``;
|
|
217
|
+
|
|
218
|
+
const finalJsx = hasRootWrapper
|
|
219
|
+
? ` <Route component={RootWrapperComponent}>
|
|
220
|
+
${routeJsx}
|
|
221
|
+
</Route>`
|
|
222
|
+
: routeJsx;
|
|
223
|
+
|
|
224
|
+
const registryEntries = routes
|
|
225
|
+
.map((r) => {
|
|
226
|
+
return ` ["${r.urlPattern}", async (args) => {
|
|
227
|
+
const mod = await import("${r.filePath.replace(/\\/g, "/")}");
|
|
228
|
+
return mod.loader ? mod.loader(args) : null;
|
|
229
|
+
}]`;
|
|
230
|
+
})
|
|
231
|
+
.join(",\n");
|
|
232
|
+
|
|
233
|
+
const guardRegistryEntries = conventionalRoutes
|
|
234
|
+
.map((r) => {
|
|
235
|
+
const sources = [...r.layouts.map((l) => l.filePath), r.filePath];
|
|
236
|
+
|
|
237
|
+
const loaders = sources
|
|
238
|
+
.map((fp) => {
|
|
239
|
+
const configPath = fp.replace(/\.(tsx|jsx)$/, ".config.ts");
|
|
240
|
+
const guardPath = fs.existsSync(configPath) ? configPath : fp.replace(/\.jsx$/, ".config.js");
|
|
241
|
+
const resolvedGuardPath = fs.existsSync(guardPath) ? guardPath : fp;
|
|
242
|
+
return `async () => { const m = await import("${resolvedGuardPath.replace(/\\/g, "/")}"); return m?.config?.guards ?? []; }`;
|
|
243
|
+
})
|
|
244
|
+
.join(",\n ");
|
|
245
|
+
|
|
246
|
+
return ` ["${r.urlPattern}", [\n ${loaders}\n ]]`;
|
|
247
|
+
})
|
|
248
|
+
.join(",\n");
|
|
249
|
+
|
|
250
|
+
const chunkPreloadRegistryCode = buildPreloadMapString(conventionalRoutes, allLayouts);
|
|
251
|
+
|
|
252
|
+
const preloadFnCode = `
|
|
253
|
+
export async function preloadActiveClientRoute(pathname: string) {
|
|
254
|
+
// Simple match logic against parameters or route patterns
|
|
255
|
+
const pattern = Object.keys(chunkPreloadRegistry).find(p => {
|
|
256
|
+
if (p === pathname) return true;
|
|
257
|
+
const regexStr = p.replace(/:([a-zA-Z0-9_-]+)/g, "([^/]+)").replace(/\\*([a-zA-Z0-9_-]*)/g, "(.*)");
|
|
258
|
+
return new RegExp("^" + regexStr + "$").test(pathname);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const componentsToPreload = pattern ? chunkPreloadRegistry[pattern] : [];
|
|
262
|
+
const preloads = componentsToPreload
|
|
263
|
+
.filter(c => typeof c.preload === "function")
|
|
264
|
+
.map(c => c.preload());
|
|
265
|
+
|
|
266
|
+
await Promise.all(preloads);
|
|
267
|
+
}
|
|
268
|
+
`.trim();
|
|
269
|
+
|
|
270
|
+
const code = `
|
|
271
|
+
// @ts-nocheck
|
|
272
|
+
// auto-generated by anaemia - do not edit!!
|
|
273
|
+
import { lazy } from "solid-js";
|
|
274
|
+
import { Route } from "@solidjs/router";
|
|
275
|
+
import { RouteDataController } from "@anaemia/core";
|
|
276
|
+
|
|
277
|
+
${rootImport}
|
|
278
|
+
|
|
279
|
+
${rootWrapperCode}
|
|
280
|
+
|
|
281
|
+
${routeImports}
|
|
282
|
+
|
|
283
|
+
${layoutImports}
|
|
284
|
+
|
|
285
|
+
${chunkPreloadRegistryCode}
|
|
286
|
+
|
|
287
|
+
${preloadFnCode}
|
|
288
|
+
|
|
289
|
+
export const serverLoaderRegistry = new Map([\n${registryEntries}\n]);
|
|
290
|
+
|
|
291
|
+
export const serverGuardRegistry = new Map([\n${guardRegistryEntries}\n]);
|
|
292
|
+
|
|
293
|
+
export default function AnaemiaRoutes() {
|
|
294
|
+
return (
|
|
295
|
+
${finalJsx}
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
`.trimStart();
|
|
299
|
+
|
|
300
|
+
if (!fs.existsSync(outDir)) {
|
|
301
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
fs.writeFileSync(outPath, code);
|
|
305
|
+
return outPath;
|
|
306
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { ServerRouteEntry } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
export function generateServerRoutes(appRoot: string, routes: ServerRouteEntry[]): string {
|
|
6
|
+
const outDir = path.resolve(appRoot, "./.anaemia");
|
|
7
|
+
const outPath = path.resolve(outDir, "./__anaemia_server_routes__.ts");
|
|
8
|
+
|
|
9
|
+
const imports = routes.map((r, i) => `import * as ServerRoute${i} from "${r.filePath}";`).join("\n");
|
|
10
|
+
|
|
11
|
+
const registrations = routes.map((r, i) => ` registerRoute(app, "${r.urlPattern}", ServerRoute${i});`).join("\n");
|
|
12
|
+
|
|
13
|
+
const code = `
|
|
14
|
+
// @ts-nocheck
|
|
15
|
+
// auto-generated by anaemia - do not edit!!
|
|
16
|
+
import type { Hono } from "hono";
|
|
17
|
+
|
|
18
|
+
${imports}
|
|
19
|
+
|
|
20
|
+
function registerRoute(app: any, pattern: string, mod: any) {
|
|
21
|
+
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
22
|
+
for (const m of methods) {
|
|
23
|
+
if (m in mod && typeof mod[m] === "function") {
|
|
24
|
+
app[m.toLowerCase()](pattern, mod[m]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerServerRoutes(app: Hono) {
|
|
30
|
+
${registrations}
|
|
31
|
+
}
|
|
32
|
+
`.trimStart();
|
|
33
|
+
|
|
34
|
+
fs.writeFileSync(outPath, code);
|
|
35
|
+
return outPath;
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { RouteManifestEntry } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
export interface BuildManifest {
|
|
6
|
+
routes: RouteManifestEntry[];
|
|
7
|
+
// filled in after rspack build - maps chunkName to hashed filename
|
|
8
|
+
chunks: Record<string, { js: string; css?: string }>;
|
|
9
|
+
errors: Record<string, string>;
|
|
10
|
+
buildTime: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function writeManifest(appRoot: string, routes: RouteManifestEntry[]): void {
|
|
14
|
+
const errors: Record<string, string> = {};
|
|
15
|
+
|
|
16
|
+
for (const route of routes) {
|
|
17
|
+
if (route.filePath.endsWith("404.tsx")) {
|
|
18
|
+
errors["404"] = route.urlPattern;
|
|
19
|
+
}
|
|
20
|
+
if (route.filePath.endsWith("500.tsx")) {
|
|
21
|
+
errors["500"] = route.urlPattern;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const conventionalRoutes = routes.filter(
|
|
26
|
+
(r) => !r.filePath.endsWith("404.tsx") && !r.filePath.endsWith("500.tsx")
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const manifest: BuildManifest = {
|
|
30
|
+
routes: conventionalRoutes,
|
|
31
|
+
errors,
|
|
32
|
+
chunks: {}, // rspack fills this in via ManifestPlugin
|
|
33
|
+
buildTime: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const outDir = path.resolve(appRoot, "./dist");
|
|
37
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
38
|
+
fs.writeFileSync(
|
|
39
|
+
path.resolve(outDir, "route-manifest.json"),
|
|
40
|
+
JSON.stringify(manifest, null, 2)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { glob } from "glob";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { getAliases } from "../aliases.js";
|
|
6
|
+
|
|
7
|
+
export function createScanJiti(appRoot: string) {
|
|
8
|
+
return createJiti(import.meta.url, {
|
|
9
|
+
interopDefault: true,
|
|
10
|
+
alias: getAliases(appRoot),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RouteType = "page" | "layout" | "catch-all";
|
|
15
|
+
|
|
16
|
+
export interface LayoutManifestEntry {
|
|
17
|
+
filePath: string;
|
|
18
|
+
guards: any[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RouteManifestEntry {
|
|
22
|
+
// the URL pattern this route matches
|
|
23
|
+
// e.g. /blog/:slug, /dashboard, /
|
|
24
|
+
urlPattern: string;
|
|
25
|
+
|
|
26
|
+
// absolute path to the route file
|
|
27
|
+
filePath: string;
|
|
28
|
+
|
|
29
|
+
// rspack chunk name derived from the path
|
|
30
|
+
chunkName: string;
|
|
31
|
+
|
|
32
|
+
// ordered list of layout files that wrap this route
|
|
33
|
+
// e.g. [root layout, dashboard layout]
|
|
34
|
+
layouts: LayoutManifestEntry[];
|
|
35
|
+
|
|
36
|
+
// holds page level guards
|
|
37
|
+
guards: any[];
|
|
38
|
+
// what type of file this is
|
|
39
|
+
type: RouteType;
|
|
40
|
+
|
|
41
|
+
// the dynamic params this route exposes
|
|
42
|
+
// e.g. /blog/[slug] -> ["slug"]
|
|
43
|
+
params: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ServerRouteEntry {
|
|
47
|
+
urlPattern: string;
|
|
48
|
+
filePath: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const LAYOUT_FILE = /^_layout\.(tsx|jsx)$/;
|
|
52
|
+
const CATCH_ALL_FILE = /^\[\.\.\.(.+?)\]\.(tsx|jsx)$/;
|
|
53
|
+
const DYNAMIC_SEGMENT = /^\[(.+?)\]\.(tsx|jsx)$/;
|
|
54
|
+
|
|
55
|
+
export function scanServerRoutes(appRoot: string): ServerRouteEntry[] {
|
|
56
|
+
const routesDir = path.resolve(appRoot, "./src/routes");
|
|
57
|
+
const files = glob.sync("**/_route.{ts,tsx}", { cwd: routesDir, posix: true });
|
|
58
|
+
|
|
59
|
+
return files.map((file) => {
|
|
60
|
+
const dir = path.dirname(file);
|
|
61
|
+
const urlPattern = dir === "." ? "/" : `/${dir}`;
|
|
62
|
+
return {
|
|
63
|
+
urlPattern,
|
|
64
|
+
filePath: path.resolve(routesDir, file),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function scanRoutes(appRoot: string): Promise<RouteManifestEntry[]> {
|
|
70
|
+
const jiti = createScanJiti(appRoot);
|
|
71
|
+
const routesDir = path.resolve(appRoot, "./src/routes");
|
|
72
|
+
const files = glob.sync("**/*.{tsx,jsx}", { cwd: routesDir, posix: true });
|
|
73
|
+
|
|
74
|
+
const layoutMap = new Map<string, LayoutManifestEntry>();
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const filename = path.basename(file);
|
|
77
|
+
if (LAYOUT_FILE.test(filename)) {
|
|
78
|
+
const dir = path.dirname(file);
|
|
79
|
+
const absolutePath = path.resolve(routesDir, file);
|
|
80
|
+
|
|
81
|
+
let layoutGuards: any[] = [];
|
|
82
|
+
const configPath = absolutePath
|
|
83
|
+
.replace(/\.(tsx|jsx)$/, ".config.$1")
|
|
84
|
+
.replace(".config.tsx", ".config.ts")
|
|
85
|
+
.replace(".config.jsx", ".config.js");
|
|
86
|
+
|
|
87
|
+
const moduleToScan = fs.existsSync(configPath) ? configPath : absolutePath;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const layoutModule = jiti.import(moduleToScan) as any;
|
|
91
|
+
if (layoutModule?.config?.guards) {
|
|
92
|
+
layoutGuards = layoutModule.config.guards;
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.warn(`[anaemia bundler warning]: Failed parsing config flags on layout: ${file}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
layoutMap.set(dir, {
|
|
99
|
+
filePath: absolutePath,
|
|
100
|
+
guards: layoutGuards,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entries: RouteManifestEntry[] = [];
|
|
106
|
+
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const filename = path.basename(file);
|
|
109
|
+
const dir = path.dirname(file);
|
|
110
|
+
|
|
111
|
+
if (LAYOUT_FILE.test(filename)) continue;
|
|
112
|
+
if (filename.includes(".config.")) continue;
|
|
113
|
+
|
|
114
|
+
const absolutePagePath = path.resolve(routesDir, file);
|
|
115
|
+
const { urlPattern, chunkName, params, type } = parseFilePath(file);
|
|
116
|
+
|
|
117
|
+
let pageGuards: any[] = [];
|
|
118
|
+
|
|
119
|
+
const pageConfigPath = absolutePagePath
|
|
120
|
+
.replace(/\.(tsx|jsx)$/, ".config.$1")
|
|
121
|
+
.replace(".config.tsx", ".config.ts")
|
|
122
|
+
.replace(".config.jsx", ".config.js");
|
|
123
|
+
|
|
124
|
+
const pageModuleToScan = fs.existsSync(pageConfigPath) ? pageConfigPath : absolutePagePath;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const pageModule = (await jiti.import(pageModuleToScan)) as any;
|
|
128
|
+
if (pageModule?.config?.guards) {
|
|
129
|
+
pageGuards = pageModule.config.guards;
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// quietly ignore parsing problems for pure components lacking a config wrapper
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const layouts = resolveLayoutChain(dir, layoutMap);
|
|
136
|
+
|
|
137
|
+
entries.push({
|
|
138
|
+
urlPattern,
|
|
139
|
+
filePath: absolutePagePath,
|
|
140
|
+
chunkName,
|
|
141
|
+
layouts,
|
|
142
|
+
guards: pageGuards,
|
|
143
|
+
type,
|
|
144
|
+
params,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return entries;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseFilePath(file: string): {
|
|
152
|
+
urlPattern: string;
|
|
153
|
+
chunkName: string;
|
|
154
|
+
params: string[];
|
|
155
|
+
type: RouteType;
|
|
156
|
+
} {
|
|
157
|
+
const segments = file.split("/");
|
|
158
|
+
const urlParts: string[] = [];
|
|
159
|
+
const params: string[] = [];
|
|
160
|
+
let type: RouteType = "page";
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < segments.length; i++) {
|
|
163
|
+
const segment = segments[i];
|
|
164
|
+
const isLast = i === segments.length - 1;
|
|
165
|
+
|
|
166
|
+
if (isLast) {
|
|
167
|
+
const cleanName = segment.replace(/\.(tsx|jsx)$/, "");
|
|
168
|
+
|
|
169
|
+
if (CATCH_ALL_FILE.test(segment)) {
|
|
170
|
+
const match = segment.match(CATCH_ALL_FILE)!;
|
|
171
|
+
params.push(match[1]);
|
|
172
|
+
urlParts.push(`*`);
|
|
173
|
+
type = "catch-all";
|
|
174
|
+
} else if (DYNAMIC_SEGMENT.test(segment)) {
|
|
175
|
+
const match = segment.match(DYNAMIC_SEGMENT)!;
|
|
176
|
+
params.push(match[1]);
|
|
177
|
+
urlParts.push(`:${match[1]}`);
|
|
178
|
+
} else if (cleanName !== "index") {
|
|
179
|
+
urlParts.push(cleanName);
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
if (segment.startsWith("[...") && segment.endsWith("]")) {
|
|
183
|
+
const param = segment.slice(4, -1);
|
|
184
|
+
params.push(param);
|
|
185
|
+
urlParts.push(`*`);
|
|
186
|
+
type = "catch-all";
|
|
187
|
+
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
188
|
+
const param = segment.slice(1, -1);
|
|
189
|
+
params.push(param);
|
|
190
|
+
urlParts.push(`:${param}`);
|
|
191
|
+
} else {
|
|
192
|
+
urlParts.push(segment);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const filteredParts = urlParts.filter((part) => part !== "" && part !== ".");
|
|
198
|
+
let urlPattern = "/" + filteredParts.join("/");
|
|
199
|
+
|
|
200
|
+
if (urlPattern === "") {
|
|
201
|
+
urlPattern = "/";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const chunkName = file
|
|
205
|
+
.replace(/\.(tsx|jsx)$/, "")
|
|
206
|
+
.replace(/\[\.\.\.(.+?)\]/g, "catchall-$1")
|
|
207
|
+
.replace(/\[(.+?)\]/g, "param-$1")
|
|
208
|
+
.replace(/\//g, "-")
|
|
209
|
+
.replace(/^-+|-+$/g, "")
|
|
210
|
+
.toLowerCase();
|
|
211
|
+
|
|
212
|
+
return { urlPattern, chunkName, params, type };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveLayoutChain(dir: string, layoutMap: Map<string, LayoutManifestEntry>): LayoutManifestEntry[] {
|
|
216
|
+
const layouts: LayoutManifestEntry[] = [];
|
|
217
|
+
let current = dir;
|
|
218
|
+
|
|
219
|
+
while (true) {
|
|
220
|
+
const layoutEntry = layoutMap.get(current);
|
|
221
|
+
if (layoutEntry) layouts.unshift(layoutEntry);
|
|
222
|
+
|
|
223
|
+
if (current === "." || current === "") break;
|
|
224
|
+
current = path.dirname(current);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return layouts;
|
|
228
|
+
}
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AnaemiaConfig } from "@anaemia/core";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
export function createStyleRules(config: AnaemiaConfig) {
|
|
8
|
+
const useSass = config.styles?.sass !== false;
|
|
9
|
+
const useModules = config.styles?.modules ?? true;
|
|
10
|
+
|
|
11
|
+
const baseLoaders = useSass ? [{ loader: require.resolve("sass-loader"), options: { api: "modern" } }] : [];
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
client: {
|
|
15
|
+
test: /\.(c|sc|sa)ss$/,
|
|
16
|
+
type: useModules ? "css/auto" : ("css" as const),
|
|
17
|
+
use: baseLoaders,
|
|
18
|
+
},
|
|
19
|
+
server: {
|
|
20
|
+
test: /\.(c|sc|sa)ss$/,
|
|
21
|
+
type: useModules ? "css/auto" : ("css" as const),
|
|
22
|
+
generator: {
|
|
23
|
+
css: {
|
|
24
|
+
exportOnlyLocals: true,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
use: baseLoaders,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createBabelRule({
|
|
33
|
+
isServer,
|
|
34
|
+
isDev,
|
|
35
|
+
plugins = [],
|
|
36
|
+
}: {
|
|
37
|
+
isServer: boolean;
|
|
38
|
+
isDev: boolean;
|
|
39
|
+
plugins: any[];
|
|
40
|
+
}) {
|
|
41
|
+
const generateMode = isServer ? "ssr" : "dom";
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
test: /\.[jt]sx?$/,
|
|
45
|
+
use: [
|
|
46
|
+
{
|
|
47
|
+
loader: require.resolve("babel-loader"),
|
|
48
|
+
options: {
|
|
49
|
+
presets: [
|
|
50
|
+
[require.resolve("babel-preset-solid"), { generate: generateMode, hydratable: true, dev: isDev }],
|
|
51
|
+
require.resolve("@babel/preset-typescript"),
|
|
52
|
+
],
|
|
53
|
+
plugins: plugins,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|