UNPKG

vite-plugin-vue-layouts-next

Version:
384 lines (352 loc) 12 kB
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 };