vite-plugin-vue-layouts-next
Version:
Router-based layout plugin for Vite 7 and Vue 3.
384 lines (352 loc) • 12 kB
JavaScript
import { join, parse, posix, resolve } from "node:path";
import process from "node:process";
import fg from "fast-glob";
import Debug from "debug";
//#region src/clientSide.ts
function normalizePath$1(path) {
path = path.startsWith("/") ? path : `/${path}`;
return posix.normalize(path);
}
async function createVirtualGlob(target, isSync) {
return `import.meta.glob(${`"${target}/**/*.vue"`}, { eager: ${isSync} })`;
}
async function createVirtualModuleCode(options) {
const { layoutDir, defaultLayout, importMode, inheritDefaultLayout = true } = options;
const normalizedTarget = normalizePath$1(layoutDir);
const isSync = importMode === "sync";
return `
export const createGetRoutes = (router, withLayout = false) => {
const routes = router.getRoutes()
if (withLayout) {
return routes
}
return () => routes.filter(route => !route.meta.isLayout)
}
export const setupLayouts = routes => {
const layouts = {}
const inheritDefaultLayout = ${inheritDefaultLayout}
const modules = ${await createVirtualGlob(normalizedTarget, isSync)}
Object.entries(modules).forEach(([name, module]) => {
let key = name.replace("${normalizedTarget}/", '').replace('.vue', '')
layouts[key] = ${isSync ? "module.default" : "module"}
})
function hasChildWithLayout(route) {
if (!route.children || route.children.length === 0) {
return false
}
return route.children.some(child => {
// Check if child has layout in meta (before transformation)
if (child.meta?.layout && child.meta.layout !== false) {
return true
}
// Also check if child is already a layout route (after transformation)
if (child.meta?.isLayout) {
return true
}
return hasChildWithLayout(child)
})
}
function deepSetupLayout(routes, top = true) {
return routes.map(route => {
// Check if child has layout before transforming children (only when inheritDefaultLayout is false)
const childHasLayout = top && !inheritDefaultLayout && route.children?.length > 0
? hasChildWithLayout(route)
: false
if (route.children?.length > 0) {
route.children = deepSetupLayout(route.children, false)
}
if (top) {
// unplugin-vue-router adds a top-level route to the routing group, which we should skip.
const skipLayout = !route.component && route.children?.find(r => (r.path === '' || r.path === '/') && r.meta?.isLayout)
if (skipLayout) {
return route
}
if (route.meta?.layout !== false) {
// If inheritDefaultLayout is true, always apply default layout (original behavior)
// If inheritDefaultLayout is false, only apply if child doesn't have its own layout
const shouldApplyDefaultLayout = inheritDefaultLayout || !childHasLayout
if (shouldApplyDefaultLayout) {
return {
path: route.path,
component: layouts[route.meta?.layout || '${defaultLayout}'],
children: route.path === '/' ? [route] : [{...route, path: ''}],
meta: {
isLayout: true
}
}
}
}
}
if (route.meta?.layout) {
return {
path: route.path,
component: layouts[route.meta?.layout],
children: [ {...route, path: ''} ],
meta: {
isLayout: true
}
}
}
return route
})
}
return deepSetupLayout(routes)
}`;
}
//#endregion
//#region src/utils.ts
function extensionsToGlob(extensions) {
return extensions.length > 1 ? `{${extensions.join(",")}}` : extensions[0] || "";
}
function normalizePath(str) {
return str.replace(/\\/g, "/");
}
const debug = Debug("vite-plugin-layouts-next");
function resolveDirs(dirs, root) {
if (dirs === null) return [];
const dirsArray = Array.isArray(dirs) ? dirs : [dirs];
const dirsResolved = [];
for (const dir of dirsArray) if (dir.includes("**")) {
const matches = fg.sync(dir, { onlyDirectories: true });
for (const match of matches) dirsResolved.push(normalizePath(resolve(root, match)));
} else dirsResolved.push(normalizePath(resolve(root, dir)));
return dirsResolved;
}
//#endregion
//#region src/files.ts
/**
* Resolves the files that are valid pages for the given context.
*/
async function getFilesFromPath(path, options) {
const { exclude, extensions } = options;
const ext = extensionsToGlob(extensions);
debug(extensions);
return await fg(`**/*.${ext}`, {
ignore: [
"node_modules",
".git",
"**/__*__/*",
...exclude
],
onlyFiles: true,
cwd: path
});
}
//#endregion
//#region src/importCode.ts
function getImportCode(files, options) {
const imports = [];
const head = [];
let id = 0;
for (const __ of files) for (const file of __.files) {
const path = __.path.startsWith("/") ? `${__.path}/${file}` : `/${__.path}/${file}`;
const parsed = parse(file);
const name = join(parsed.dir, parsed.name).replace(/\\/g, "/");
if (options.importMode(name) === "sync") {
const variable = `__layout_${id}`;
head.push(`import ${variable} from '${path}'`);
imports.push(`'${name}': ${variable},`);
id += 1;
} else imports.push(`'${name}': () => import('${path}'),`);
}
return `
${head.join("\n")}
export const layouts = {
${imports.join("\n")}
}`;
}
//#endregion
//#region src/RouteLayout.ts
function getClientCode(importCode, options) {
return `
${importCode}
export const createGetRoutes = (router, withLayout = false) => {
const routes = router.getRoutes()
if (withLayout) {
return routes
}
return () => routes.filter(route => !route.meta.isLayout)
}
export function setupLayouts(routes) {
const inheritDefaultLayout = ${options.inheritDefaultLayout ?? true}
function hasChildWithLayout(route) {
if (!route.children || route.children.length === 0) {
return false
}
return route.children.some(child => {
// Check if child has layout in meta (before transformation)
if (child.meta?.layout && child.meta.layout !== false) {
return true
}
// Also check if child is already a layout route (after transformation)
if (child.meta?.isLayout) {
return true
}
return hasChildWithLayout(child)
})
}
function deepSetupLayout(routes, top = true) {
return routes.map(route => {
// Check if child has layout before transforming children (only when inheritDefaultLayout is false)
const childHasLayout = top && !inheritDefaultLayout && route.children?.length > 0
? hasChildWithLayout(route)
: false
if (route.children?.length > 0) {
route.children = deepSetupLayout(route.children, false)
}
if (top) {
// unplugin-vue-router adds a top-level route to the routing group, which we should skip.
const skipLayout = !route.component && route.children?.find(r => (r.path === '' || r.path === '/') && r.meta?.isLayout)
if (skipLayout) {
return route
}
if (route.meta?.layout !== false) {
// If inheritDefaultLayout is true, always apply default layout (original behavior)
// If inheritDefaultLayout is false, only apply if child doesn't have its own layout
const shouldApplyDefaultLayout = inheritDefaultLayout || !childHasLayout
if (shouldApplyDefaultLayout) {
return {
path: route.path,
component: layouts[route.meta?.layout || '${options.defaultLayout}'],
children: route.path === '/' ? [route] : [{...route, path: ''}],
meta: {
isLayout: true
}
}
}
}
}
if (route.meta?.layout) {
return {
path: route.path,
component: layouts[route.meta?.layout],
children: [ {...route, path: ''} ],
meta: {
isLayout: true
}
}
}
return route
})
}
return deepSetupLayout(routes)
}
`;
}
var RouteLayout_default = getClientCode;
//#endregion
//#region src/index.ts
const MODULE_ID = "virtual:generated-layouts";
const MODULE_ID_VIRTUAL = "/@vite-plugin-vue-layouts-next/generated-layouts";
function defaultImportMode(name) {
if (process.env.VITE_SSG) return "sync";
return name === "default" ? "sync" : "async";
}
function resolveOptions(userOptions) {
return Object.assign({
defaultLayout: "default",
layoutsDirs: "src/layouts",
pagesDirs: "src/pages",
extensions: ["vue"],
exclude: [],
importMode: defaultImportMode,
inheritDefaultLayout: true
}, userOptions);
}
function Layout(userOptions = {}) {
if (canEnableClientLayout(userOptions)) return ClientSideLayout({
defaultLayout: userOptions.defaultLayout,
layoutDir: userOptions.layoutsDirs,
inheritDefaultLayout: userOptions.inheritDefaultLayout
});
let config;
const options = resolveOptions(userOptions);
let layoutsDirs;
let pagesDirs;
return {
name: "vite-plugin-vue-layouts-next",
enforce: "pre",
configResolved(_config) {
config = _config;
layoutsDirs = resolveDirs(options.layoutsDirs, config.root);
pagesDirs = resolveDirs(options.pagesDirs, config.root);
},
configureServer({ moduleGraph, watcher, ws }) {
watcher.add(options.layoutsDirs);
const reloadModule = (module, path = "*") => {
if (module) {
moduleGraph.invalidateModule(module);
if (ws) ws.send({
path,
type: "full-reload"
});
}
};
const updateVirtualModule = (path) => {
path = normalizePath(path);
if (pagesDirs.length === 0 || pagesDirs.some((dir) => path.startsWith(dir)) || layoutsDirs.some((dir) => path.startsWith(dir))) {
debug("reload", path);
reloadModule(moduleGraph.getModuleById(MODULE_ID_VIRTUAL));
}
};
watcher.on("add", (path) => {
updateVirtualModule(path);
});
watcher.on("unlink", (path) => {
updateVirtualModule(path);
});
watcher.on("change", async (path) => {
updateVirtualModule(path);
});
},
resolveId(id) {
return id === MODULE_ID || id.startsWith(MODULE_ID) ? MODULE_ID_VIRTUAL : null;
},
async load(id) {
if (id === MODULE_ID_VIRTUAL) {
const container = [];
for (const dir of layoutsDirs) {
const layoutsDirPath = dir.startsWith("/") ? normalizePath(dir) : normalizePath(resolve(config.root, dir));
debug("Loading Layout Dir: %O", layoutsDirPath);
const _f = await getFilesFromPath(layoutsDirPath, options);
container.push({
path: layoutsDirPath,
files: _f
});
}
const clientCode = RouteLayout_default(getImportCode(container, options), options);
debug("Client code: %O", clientCode);
return clientCode;
}
}
};
}
function ClientSideLayout(options) {
const { layoutDir = "src/layouts", defaultLayout = "default", importMode = process.env.VITE_SSG ? "sync" : "async", inheritDefaultLayout = true } = options || {};
return {
name: "vite-plugin-vue-layouts-next",
resolveId(id) {
if (id === MODULE_ID) return `\0${MODULE_ID}`;
},
load(id) {
if (id === `\0${MODULE_ID}`) return createVirtualModuleCode({
layoutDir,
importMode,
defaultLayout,
inheritDefaultLayout
});
}
};
}
function canEnableClientLayout(options) {
const keys = Object.keys(options);
if (keys.length > 3 || keys.some((key) => ![
"layoutsDirs",
"defaultLayout",
"inheritDefaultLayout"
].includes(key))) return false;
if (options.layoutsDirs && (Array.isArray(options.layoutsDirs) || options.layoutsDirs.includes("*"))) return false;
return true;
}
//#endregion
export { ClientSideLayout, Layout as default, defaultImportMode };