iles
Version:
Vite & Vue powered static site generator with partial hydration
471 lines (470 loc) • 17 kB
JavaScript
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 };