vite-plugin-vanjs
Version:
An async first mini meta-framework for VanJS powered by Vite
287 lines (254 loc) • 8.25 kB
JavaScript
/** @typedef {import("./types").RouteConfig} RouteConfig */
/** @typedef {import("./types").PageFile} PageFile */
/** @typedef {import("./types").LayoutFile} LayoutFile */
/** @typedef {import("./types").RouteFile} RouteFile */
// import { normalizePath } from "vite";
import { dirname, join, posix, win32 } from "node:path";
import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import process from "node:process";
/**
* Get the file most probable route path for a given potential route.
* @type {typeof import("./types").fileToRoute}
*/
export const fileToRoute = (file, routesDir) => {
const cleanPath = file
.slice(routesDir.length + 1) // also remove initial slash
.replace(/\.(jsx|tsx|ts|js)$/, "")
.replace(/index$/, "")
.replace(/\(.*\)$/, "") // Remove (file_name) from path
.replace(/\([^)]+\)\/?/g, "") // Remove (folder_name) from path
.replace(/\[\.\.\.[^\]]+\]/g, "*")
.replace(/\[([^\]]+)\]/g, ":$1");
const slashPath = cleanPath.endsWith("/")
? cleanPath.slice(0, -1)
: cleanPath;
const path = slashPath === "*"
? slashPath
: slashPath?.length > 0
? `/${slashPath}`
: "/";
return path;
};
/**
* Identify all files in a folder.
* @type {typeof import("./types").globFiles}
*/
export const globFiles = async (dir, extensions) => {
/** @type {string[]} */
const files = [];
/** @param {string} directory */
async function scan(directory) {
if (!existsSync(directory)) {
// console.log('🍦 @vanjs/router: the "routes" folder does not exist.');
return;
}
const entries = await readdir(directory, { withFileTypes: true });
// istanbul ignore if
if (!entries.length) {
// console.warn('🍦 @vanjs/router: the "routes" folder is empty.');
return;
}
for (const entry of entries) {
const fullPath = join(directory, entry.name);
// istanbul ignore else
if (entry.isDirectory()) {
await scan(fullPath);
} else if (entry.isFile()) {
// Check if file has allowed extension
// istanbul ignore else
if (extensions.some((ext) => entry.name.endsWith(ext))) {
files.push(fullPath);
}
}
}
}
await scan(dir);
return files;
};
const normalizePathRegExp = new RegExp(`\\${win32.sep}`, "g");
/** @param {string} filename */
function normalizePath(filename) {
return filename.replace(normalizePathRegExp, posix.sep);
}
/**
* Scan routes directory and generate routes.
* @type {typeof import("./types").scanRoutes}
*/
export const scanRoutes = async (config, pluginConfig) => {
const { routesDir, extensions } = pluginConfig;
const routesPath = join(config.root, routesDir);
const files = await globFiles(routesPath, extensions);
if (!files?.length) {
return [];
}
// Filter out duplicate routes and layout files that are already used
const routes = files.map((file) => ({
path: normalizePath(file),
routePath: fileToRoute(file, routesPath),
}));
// Remove duplicate routes (prefer non-layout files)
/** @type {typeof routes} */
const uniqueRoutes = routes.reduce(
/**
* @param {typeof routes} acc
* @param {typeof routes[0]} route
* @returns {typeof routes}
*/
(acc, route) => {
const existing = acc.find((r) => r.routePath === route.routePath);
if (
!existing || (existing.path.includes("(") && !route.path.includes("("))
) {
// Remove the existing route if this is a better match
// istanbul ignore if - should not be possible
if (existing) {
acc = acc.filter((r) => r !== existing);
}
acc.push(route);
}
return acc;
},
[],
);
return uniqueRoutes;
};
/**
* Find all layout files for a given route.
* @type {typeof import("./types").findLayouts}
*/
export const findLayouts = (routePath, config, pluginConfig) => {
const { routesDir, extensions } = pluginConfig;
const layouts = [];
let dir = dirname(routePath);
const routesPath = join(config.root, routesDir);
// Walk up the directory tree looking for layout files
while (dir.startsWith(routesPath) && dir !== routesPath) { // Stop at routes dir
let layoutFile = null;
const dirName = dir.split(/[/\\]/).pop();
// istanbul ignore else
if (dirName) {
// Look for a layout file in the current directory
for (const ext of extensions) {
const layoutPaths = [
join(dirname(dir), `${dirName}${ext}`),
join(dirname(dir), `(${dirName.replace(/^\((.*)\)$/, "$1")})${ext}`),
];
for (const path of layoutPaths) {
if (existsSync(path)) {
layoutFile = path;
break;
}
}
}
}
// istanbul ignore else
if (layoutFile && layoutFile !== routePath) {
layouts.unshift({
id: `Layout${layouts.length}`,
path: layoutFile,
});
}
dir = dirname(dir);
}
return layouts;
};
/**
* Process routes and identify their layouts
* @type {typeof import("./types").processLayoutRoutes}
*/
export const processLayoutRoutes = (routes, config, pluginConfig) => {
if (!routes.length) return [];
return routes.map((route) => {
const layouts = findLayouts(route.path, config, pluginConfig);
return {
...route,
layouts,
};
});
};
/**
* Scan and process routes and return them
* @type {typeof import("./types").getRoutes}
*/
export const getRoutes = async (config, pluginConfig) => {
const routes = await scanRoutes(config, pluginConfig);
const processed = processLayoutRoutes(routes, config, pluginConfig);
// istanbul ignore next - defaults can't be checked
const { excludeRoutes = [], excludeRoutesProd = [] } = pluginConfig;
const isProd = process.env.NODE_ENV === "production";
const allExcludes = [...excludeRoutes, ...(isProd ? excludeRoutesProd : [])];
if (allExcludes.length) {
return processed.filter((r) =>
!allExcludes.some((p) =>
r.routePath === p || r.routePath.startsWith(p + "/")
)
);
}
return processed;
};
/** @type {(route: RouteFile) => string} */
export const generateRouteProloaders = (route) => {
const moduleName = "PageModule";
const layoutName = "Module";
return `{
preload: async (params) => {
${
route.layouts.map((layout) =>
`if (${layout.id + layoutName}?.route?.preload) await ${
layout.id + layoutName
}?.route?.preload(params);`
).join("\n ")
}
if (${moduleName}?.route?.preload) await ${moduleName}?.route?.preload(params);
},
load: async (params) => {
let _data;
${
route.layouts.map((layout) =>
`if (${layout.id + layoutName}?.route?.load) await ${
layout.id + layoutName
}?.route?.load(params);`
).join("\n ")
}
if (${moduleName}?.route?.load) _data = await ${moduleName}?.route?.load(params);
return _data;
}
}`;
};
/** @type {(route: RouteFile) => string} */
export const generateComponentRoute = (route) => {
if (route.layouts?.length > 0) {
// Only generate imports for unique layouts
const layoutImports = route.layouts.map(
(layout) =>
`const ${layout.id}Module = await import('${layout.path}');\n` +
`const ${layout.id}Page = ${layout.id}Module.Layout || ${layout.id}Module.Page || ${layout.id}Module.default;`,
).join("\n");
// Use both shared and unique layouts for the component chain
const pageComponent = route.layouts.reduce(
(acc, layout) => `${layout.id}Page({ children: ${acc} })`,
"Page()",
);
return `lazy(() => {
const importFn = async () => {
${layoutImports}
const PageModule = await import('${route.path}');
const Page = PageModule?.Page || PageModule?.default;
return Promise.resolve({
route: ${generateRouteProloaders(route)},
Page: () => ${pageComponent},
});
};
return importFn();
})`;
}
return `lazy(() => import('${route.path}'))`;
};
/** @type {(route: RouteFile) => string} */
export const generateRoute = (route) => {
return `Route({
path: "${route.routePath}",
component: ${generateComponentRoute(route)},
});`;
};