UNPKG

iles

Version:

Vite & Vue powered static site generator with partial hydration

471 lines (470 loc) 17 kB
import { t as __exportAll } from "./rolldown-runtime-wcPFST8Q.mjs"; import { r as APP_PATH, u as TURBO_SCRIPT_PATH } from "./alias-Cyp8pcW6.mjs"; import { t as resolveConfig } from "./config-BEGLROoQ.mjs"; import { t as IslandsPlugins } from "./plugin-Ctdahe_A.mjs"; import fs, { existsSync, promises } from "fs"; import { dirname, extname, join, relative, resolve } from "pathe"; import { build, mergeConfig } from "vite"; import MagicString from "magic-string"; import newSpinner from "mico-spinner"; import { init, parse } from "es-module-lexer"; import glob from "fast-glob"; import { renderSSRHead } from "@unhead/ssr"; import { renderers } from "@islands/prerender"; import { renderToString } from "vue/server-renderer"; import { performance } from "perf_hooks"; import { posix } from "path"; //#region src/node/build/utils.ts const warnMark = "\x1B[33m⚠\x1B[0m"; async function withSpinner(message, fn) { const spinner = newSpinner(message).start(); const startTime = performance.now(); try { const result = await fn(); spinner.succeed(); console.info(` done in ${timeSince(startTime)}\n`); return result; } catch (e) { spinner.fail(); throw e; } } function timeSince(start) { const diff = performance.now() - start; return diff < 750 ? `${Math.round(diff)}ms` : `${(diff / 1e3).toFixed(1)}s`; } function uniq(arr) { return [...new Set(arr.filter((x) => x))]; } function flattenPath(path) { return pathToFilename(path).replace(/\//g, "_"); } function pathToFilename(path, ext = "") { if (path.endsWith(ext)) ext = ""; const decodedPath = decodeURIComponent(path); return `${(decodedPath.endsWith("/") ? `${decodedPath}index` : decodedPath).replace(/^\//g, "")}${ext}`; } function rm(dir) { fs.rmSync(dir, { recursive: true, force: true }); } //#endregion //#region src/node/build/routes.ts const DYNAMIC_PARAM = "/:"; async function getRoutesToRender(config, createApp) { const routesToRender = /* @__PURE__ */ new Map(); const { router } = await createApp(); for (const { path, ssrProps } of await resolveRoutesToRender(router)) { const outputFilename = pathToFilename(path, extname(path).slice(1) || ".html"); routesToRender.set(path, { path, ssrProps, outputFilename, rendered: "" }); } return Array.from(routesToRender.values()); } async function resolveRoutesToRender(router) { const toResolvedPath = (route) => { try { return { path: router.resolve(route).fullPath, ssrProps: route.ssrProps }; } catch (error) { throw new Error(`Could not resolve ${String(route.name)}. Params: ${JSON.stringify(route.params)}. Error: ${error.message}`); } }; return (await Promise.all(router.getRoutes().map(async (route) => { return (route.path.includes(DYNAMIC_PARAM) ? await getDynamicPaths(route) : [route]).map(toResolvedPath); }))).flat(); } async function getDynamicPaths(route) { const { components, path } = route; const file = path; const { default: component } = components || {}; const variants = await (isLazy(component) ? await component().then((m) => "default" in m ? m.default : m) : component)?.getStaticPaths?.({ route }); if (!variants) { console.warn(`'getStaticPaths' is not defined for ${file} so ${path} it won't be generated.`); return []; } if (!Array.isArray(variants)) throw new Error(`Expected array from 'getStaticPaths' in ${file}, got: ${JSON.stringify(variants)}`); return variants.map(({ params, props }) => ({ name: route.name, params, ssrProps: { ...params, ...props } })); } function isLazy(value) { return typeof value === "function"; } //#endregion //#region src/node/build/render.ts const commentsRegex = /<!--\[-->|<!--]-->|<!---->/g; async function renderPages(config, islandsByPath, { clientResult }) { const appPath = [ "js", "mjs", "cjs" ].map((ext) => join(config.tempDir, `app.${ext}`)).find(existsSync); if (!appPath) throw new Error(`Could not find the SSR build for the app in ${config.tempDir}`); const { createApp } = await import(`file://${appPath}`); const routesToRender = await withSpinner("resolving static paths", async () => await getRoutesToRender(config, createApp)); const clientChunks = clientResult.output; await withSpinner("rendering pages", async () => { for (const route of routesToRender) route.rendered = await renderPage(config, islandsByPath, clientChunks, route, createApp); }); return { routesToRender }; } async function renderPage(config, islandsByPath, clientChunks, route, createApp) { const { app, head } = await createApp({ routePath: route.path, ssrProps: route.ssrProps }); let content = await renderToString(app, { islandsByPath, renderers }); content = content.replace(commentsRegex, ""); if (!route.outputFilename.endsWith(".html")) return content; const { headTags, htmlAttrs, bodyTagsOpen, bodyTags, bodyAttrs } = await renderSSRHead(head); return `<!DOCTYPE html> <html ${htmlAttrs}> <head> ${headTags} ${stylesheetTagsFrom(config, clientChunks)} ${await scriptTagsFrom(config, islandsByPath[route.path])} </head> <body ${bodyAttrs}> ${bodyTagsOpen}<div id="app">${content}</div>${bodyTags} </body> </html>`; } function stylesheetTagsFrom(config, clientChunks) { return clientChunks.filter((chunk) => chunk.type === "asset" && chunk.fileName.endsWith(".css")).map((chunk) => `<link rel="stylesheet" href="${config.base}${chunk.fileName}">`).join("\n"); } async function scriptTagsFrom(config, islands) { if (!islands?.some((island) => island.script.includes("@islands/hydration/solid"))) return ""; return "<script>window._$HY={events:[],completed:new WeakSet(),r:{}}<\/script>"; } //#endregion //#region src/node/build/bundle.ts async function bundle(config) { const entrypoints = resolveEntrypoints(config); const [clientResult, serverResult] = await Promise.all([ bundleWithVite(config, entrypoints, { ssr: false }), bundleWithVite(config, entrypoints, { ssr: true }), bundleHtmlEntrypoints(config) ]); return { clientResult, serverResult }; } async function bundleHtmlEntrypoints(config) { const entrypoints = glob.sync(resolve(config.pagesDir, "./**/*.html"), { cwd: config.root, ignore: ["node_modules/**"] }); if (entrypoints.length > 0) await bundleWithVite(config, entrypoints, { htmlBuild: true, ssr: false }); } async function bundleWithVite(config, entrypoints, options) { const { htmlBuild = false, ssr } = options; return await build(mergeConfig(config.vite, { logLevel: config.vite.logLevel ?? "warn", ssr: { external: ["vue", "vue/server-renderer"], noExternal: ["iles"] }, plugins: [ IslandsPlugins(config), htmlBuild ? moveHtmlPagesPlugin(config) : !ssr && removeJsPlugin(), ssr && addESMPackagePlugin(config) ], build: { ssr, cssCodeSplit: htmlBuild || !ssr, minify: ssr, emptyOutDir: ssr, outDir: ssr ? config.tempDir : config.outDir, sourcemap: false, rolldownOptions: { input: entrypoints, treeshake: htmlBuild } } })); } function resolveEntrypoints(config) { return { app: APP_PATH }; } function removeJsPlugin() { return { name: "iles:client-js-removal", generateBundle(_, bundle) { for (const name in bundle) if (bundle[name].fileName.endsWith(".js")) delete bundle[name]; } }; } function moveHtmlPagesPlugin(config) { return { name: "iles:html-pages", async writeBundle(options, bundle) { const outDir = resolve(config.root, config.outDir); await Promise.all(Object.entries(bundle).map(async ([name, chunk]) => { if (name.endsWith(".html")) { const dest = resolve(outDir, relative(config.pagesDir, resolve(config.root, name))); await promises.mkdir(dirname(dest), { recursive: true }); await promises.rename(resolve(outDir, name), dest); } })); rm(resolve(outDir, relative(config.root, config.srcDir))); } }; } function addESMPackagePlugin(config) { return { name: "iles:add-common-js-package-plugin", async writeBundle() { await promises.writeFile(join(config.tempDir, "package.json"), JSON.stringify({ type: "module" })); } }; } //#endregion //#region src/node/build/chunks.ts function extendManualChunks(config) { const userChunks = config.ssg.manualChunks; const cache = /* @__PURE__ */ new Map(); const chunkForExtension = { jsx: `vendor-${config.jsx}`, tsx: `vendor-${config.jsx}`, svelte: "vendor-svelte", vue: "vendor-vue" }; return (id, api) => { if (id.includes("vite/") || id.includes("plugin-vue")) return "vite"; if (id.includes("hydration/dist")) return "iles"; const name = userChunks?.(id, api); if (name) return name; if (id.includes("node_modules")) return vendorPerFramework(chunkForExtension, id, api, cache); }; } function vendorPerFramework(chunkForExtension, id, api, cache, importStack = []) { if (cache.has(id)) return cache.get(id); if (importStack.includes(id)) { cache.set(id, void 0); return; } const mod = api.getModuleInfo(id); if (!mod) { cache.set(id, void 0); return; } if (mod.isEntry) { const queryIndex = id.lastIndexOf("?"); const idWithoutQuery = queryIndex > -1 ? id.slice(0, queryIndex) : id; const name = chunkForExtension[idWithoutQuery.slice(idWithoutQuery.lastIndexOf(".") + 1)]; cache.set(id, name); return name; } let name; for (const importer of mod.importers) { const importerChunk = vendorPerFramework(chunkForExtension, importer, api, cache, importStack.concat(id)); if (!name) name = importerChunk; if (importerChunk && importerChunk !== name) break; } cache.set(id, name); return name; } //#endregion //#region src/node/build/islands.ts const VIRTUAL_PREFIX = "virtual_ile_"; const VIRTUAL_TURBO_ID = "iles/turbo"; async function bundleIslands(config, islandsByPath) { const entrypoints = Object.create(null); const islandComponents = Object.create(null); for (const path in islandsByPath) islandsByPath[path].forEach((island) => { island.entryFilename = `${flattenPath(path)}_${island.id}`; entrypoints[island.entryFilename] = island.script; islandComponents[island.componentPath] = island.componentPath; }); const entryFiles = [...Object.keys(entrypoints), ...Object.keys(islandComponents)].sort(); if (config.turbo) entryFiles.push(resolve(VIRTUAL_TURBO_ID)); if (Object.keys(entryFiles).length === 0) return; await build(mergeConfig(config.vite, { logLevel: config.vite.logLevel ?? "warn", publicDir: false, build: { emptyOutDir: false, outDir: config.outDir, manifest: true, minify: true, rolldownOptions: { input: entryFiles, output: { entryFileNames: chunkFileNames, chunkFileNames, manualChunks: extendManualChunks(config) } } }, plugins: [virtualEntrypointsPlugin(config.root, entrypoints), IslandsPlugins(config)] })); } function virtualEntrypointsPlugin(root, entrypoints) { return { name: "iles:entrypoints", resolveId(id, importer) { if (id in entrypoints) return VIRTUAL_PREFIX + id; if (relative(root, id.split("?", 2)[0]) === "iles/turbo") return VIRTUAL_TURBO_ID; }, async load(id) { if (id.startsWith("virtual_ile_")) return entrypoints[id.slice(12)]; if (id === "iles/turbo") return await promises.readFile(TURBO_SCRIPT_PATH, "utf-8"); } }; } function chunkFileNames(chunk) { if (chunk.name.includes(".vue_vue")) return `assets/${chunk.name.split(".vue_vue")[0]}.[hash].js`; return "assets/[name].[hash].js"; } //#endregion //#region src/node/build/rebaseImports.ts async function rebaseImports({ base, assetsDir }, codeStr) { const assetsBase = posix.join(base, assetsDir); try { await init; const imports = parse(codeStr)[0]; const code = new MagicString(codeStr); imports.forEach(({ s, e, d }) => { if (d > -1) { s += 1; e -= 1; } code.overwrite(s, e, posix.join(assetsBase, code.slice(s, e)), { contentOnly: true }); }); return code.toString(); } catch (error) { console.error(error); return codeStr; } } //#endregion //#region src/node/build/write.ts async function writePages(config, islandsByPath, { routesToRender }) { const manifest = await parseManifest(config.outDir, islandsByPath); await Promise.all(routesToRender.map(async (route) => await writeRoute(config, manifest, route, islandsByPath[route.path]))); const tempIslandFiles = await glob(join(config.outDir, `**/${VIRTUAL_PREFIX}*.js`)); for (const temp of tempIslandFiles) await promises.unlink(temp); } async function writeRoute(config, manifest, route, islands = []) { let content = route.rendered; if (route.outputFilename.endsWith(".html")) { const preloadScripts = []; for (const island of islands) { const entry = manifest[`${VIRTUAL_PREFIX}${island.entryFilename}`]; if (!entry) { const message = `Unable to find entry for island '${island.entryFilename}' in manifest.json`; console.error(`${message}. Island:\n`, island, "\n\nManifest:\n", manifest); throw new Error(message); } if (entry.imports) preloadScripts.push(...entry.imports); const filename = resolve(config.outDir, entry.file); const rebasedCode = await rebaseImports(config, await promises.readFile(filename, "utf-8")); content = content.replace(`<!--${island.placeholder}-->`, () => `<script><\/script><script type="module" async>${rebasedCode}<\/script>`); } route.rendered = content.replace("</head>", () => `${stringifyScripts(config, manifest, preloadScripts)}</head>`); } route = await config.ssg.beforePageRender?.(route, config) || route; const filename = resolve(config.outDir, route.outputFilename); await promises.mkdir(dirname(filename), { recursive: true }); await promises.writeFile(filename, route.rendered, "utf-8"); } function stringifyScripts({ turbo, base }, manifest, hrefs) { return [turbo && injectNavigation(base, manifest), stringifyPreload(base, manifest, hrefs)].filter((x) => x).join(""); } function injectNavigation(base, manifest) { const src = manifest[VIRTUAL_TURBO_ID]?.file; return src && `<script type="module" async src="${base}${src}"><\/script>`; } function stringifyPreload(base, manifest, hrefs) { return uniq(resolveManifestEntries(manifest, hrefs)).map((href) => `<link rel="modulepreload" href="${base}${href}" crossorigin/>`).join(""); } function resolveManifestEntries(manifest, entryNames) { return entryNames.flatMap((entryName) => { const entry = manifest[entryName]; return [entry.file, ...resolveManifestEntries(manifest, entry.imports || [])]; }); } async function parseManifest(outDir, islandsByPath) { const manifestPath = join(outDir, ".vite", "manifest.json"); try { return JSON.parse(await promises.readFile(manifestPath, "utf-8")); } catch (err) { if (Object.keys(islandsByPath).length > 0) throw err; return {}; } } //#endregion //#region src/node/build/sitemap.ts async function createSitemap(config, routesToRender) { const { outDir, base, siteUrl, ssg: { sitemap } } = config; if (!sitemap) return; if (!siteUrl) return console.warn(warnMark, "Skipping sitemap. Configure `siteUrl` to enable sitemap generation."); withSpinner("rendering sitemap", async () => { const sitemap = sitemapFor(`${siteUrl}${base}`, routesToRender); await promises.mkdir(outDir, { recursive: true }); await promises.writeFile(join(outDir, "sitemap.xml"), sitemap, "utf8"); }); } function sitemapFor(siteUrl, routesToRender) { const pageUrls = /* @__PURE__ */ new Set(); for (const route of routesToRender) { if (!route.outputFilename.endsWith(".html")) continue; if (route.path === "/404") continue; pageUrls.add(route.path); } return `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${Array.from(pageUrls).sort((a, b) => a.localeCompare(b, "en", { numeric: true })).map((url) => ` <url><loc>${siteUrl + url.slice(1)}</loc></url>\n`).join("")} </urlset> `; } //#endregion //#region src/node/build/build.ts var build_exports = /* @__PURE__ */ __exportAll({ build: () => build$1 }); async function build$1(root) { const start = Date.now(); process.env.NODE_ENV = "production"; const appConfig = await resolveConfig(root, { command: "build", mode: "production", isSsrBuild: false }); rm(appConfig.outDir); const bundleResult = await withSpinner("building client + server bundles", async () => await bundle(appConfig)); const islandsByPath = Object.create(null); const pagesResult = await renderPages(appConfig, islandsByPath, bundleResult); await createSitemap(appConfig, pagesResult.routesToRender); await withSpinner("building islands bundle", async () => await bundleIslands(appConfig, islandsByPath)); const ssgContext = { config: appConfig, pages: pagesResult.routesToRender }; await appConfig.ssg.onSiteBundled?.(ssgContext); await withSpinner("writing pages", async () => await writePages(appConfig, islandsByPath, pagesResult)); await appConfig.ssg.onSiteRendered?.(ssgContext); rm(appConfig.tempDir); console.info(`build complete in ${((Date.now() - start) / 1e3).toFixed(2)}s.`); } //#endregion export { build_exports as n, build$1 as t };