@wroud/vite-plugin-ssg
Version:
A Vite plugin for static site generation (SSG) with React. Renders React applications to static HTML for faster load times and improved SEO.
206 lines • 11.6 kB
JavaScript
import nodePath from "node:path";
import {} from "vite";
import { readFile, unlink, writeFile } from "node:fs/promises";
import { changePathExt } from "../utils/changePathExt.js";
import { loadServerApi } from "../api/loadServerApi.js";
import { getPageName } from "../utils/getPageName.js";
import { removeVirtualHtmlEntry } from "../modules/isVirtualHtmlEntry.js";
import { getPathsToLookup } from "../utils/getPathsToLookup.js";
import { isSsgClientEntryId } from "../modules/isSsgClientEntryId.js";
import { createSsgPageUrlId } from "../modules/isSsgPageUrlId.js";
import { cleanUrl } from "../utils/cleanUrl.js";
import { glob } from "tinyglobby";
import { existsSync } from "node:fs";
import { parseHtmlTagsFromHtml } from "../parseHtmlTagsFromHtml.js";
import MagicString from "magic-string";
import { getHrefFromPath } from "../utils/getHrefFromPath.js";
import { getBaseInHTML } from "../utils/getBaseInHTML.js";
import { mapHtmlTagsToReactTags } from "../react/mapHtmlTagsToReactTags.js";
import { htmlVirtualEntryResolution } from "./htmlVirtualEntryResolution.js";
import { stripBase } from "../utils/stripBase.js";
export const clientBundlePlugin = (renderTimeout = 10000) => {
const virtualHtmlEntryPlugin = htmlVirtualEntryResolution();
return [
virtualHtmlEntryPlugin,
{
name: "@wroud/vite-plugin-ssg/client",
enforce: "post",
apply: "build",
applyToEnvironment: (env) => env.name === "client",
generateBundle: {
order: "post",
async handler(options, bundle) {
const config = this.environment.config;
const ssrConfig = config.environments["ssr"];
const assetsMapping = new Map();
const serverApiProviderCache = new Map();
const getCachedServerApi = async (modulePath) => {
if (!serverApiProviderCache.has(modulePath)) {
serverApiProviderCache.set(modulePath, await loadServerApi(modulePath));
}
return serverApiProviderCache.get(modulePath);
};
const virtualChunks = virtualHtmlEntryPlugin.virtualHtmlChunks ||
new Map();
for (const chunk of Object.values(bundle)) {
if (chunk.type === "asset") {
for (const fileName of chunk.originalFileNames) {
let absoluteName = fileName;
if (fileName.startsWith(".")) {
absoluteName = nodePath.posix.join(config.root, fileName);
}
assetsMapping.set(absoluteName, chunk.fileName);
}
}
else if (chunk.type === "chunk") {
if (chunk.facadeModuleId) {
assetsMapping.set(chunk.facadeModuleId, chunk.fileName);
}
}
}
function resolveMainIdFromVirtualChunk(virtualHtmlChunk) {
let mainName = getPageName(removeVirtualHtmlEntry(nodePath.posix.relative(config.root, virtualHtmlChunk.originalFileNames[0] ?? ""))) || "";
const lookUpPaths = getPathsToLookup(mainName);
for (const possiblePath of lookUpPaths) {
const mainChunk = Object.values(bundle).find((c) => c.type === "chunk" &&
c.name === possiblePath &&
c.isEntry &&
isSsgClientEntryId(c.facadeModuleId ?? ""));
if (mainChunk) {
return {
name: mainName,
chunkName: possiblePath,
chunk: mainChunk,
};
}
}
return null;
}
for (const virtualChunk of virtualChunks.values()) {
const mainChunk = resolveMainIdFromVirtualChunk(virtualChunk);
if (mainChunk) {
assetsMapping.set(createSsgPageUrlId(changePathExt(cleanUrl(mainChunk.chunk.facadeModuleId), "")), mainChunk.chunkName + ".html");
}
}
const ssgFiles = await glob(["**/*.ssg"], {
cwd: nodePath.join(config.root, ssrConfig.build.outDir),
});
for (let ssg of ssgFiles) {
ssg = nodePath.join(config.root, ssrConfig.build.outDir, ssg);
const ssgIds = JSON.parse(await readFile(ssg, { encoding: "utf-8" }));
const serverChunkFileName = ssg.slice(0, -4);
let serverChunk = await readFile(serverChunkFileName, {
encoding: "utf-8",
});
for (const ssgId of ssgIds) {
const asset = assetsMapping.get(ssgId);
if (asset) {
serverChunk = serverChunk.replaceAll(ssgId, asset);
}
else {
this.error(new Error(`Asset not found: ${ssgId}`));
}
}
await writeFile(serverChunkFileName, serverChunk);
await unlink(ssg);
}
try {
for (const [, virtualHtmlChunk] of virtualChunks) {
const mainChunk = resolveMainIdFromVirtualChunk(virtualHtmlChunk);
if (!mainChunk) {
this.warn(`No main chunk found for: ${virtualHtmlChunk.fileName}`);
continue;
}
const href = mainChunk.name.replace(/\/index$/, "/");
let serverModulePath = stripBase(mainChunk.chunkName, config.base);
const exists = existsSync(nodePath.join(config.root, ssrConfig.build.outDir, serverModulePath + ".js"));
if (!exists) {
this.error(`No SSG chunk found for: ${mainChunk.name}`);
//@ts-ignore
return;
}
const serverModuleFullPath = nodePath.join(config.root, ssrConfig.build.outDir, serverModulePath + ".js");
const serverApiProvider = await getCachedServerApi(serverModuleFullPath);
const analyzedChunk = new Set();
const cssFiles = new Set();
const htmlTags = parseHtmlTagsFromHtml(String(virtualHtmlChunk.source));
function collectCssForChunk(chunk) {
const chunkId = chunk.fileName;
if (analyzedChunk.has(chunkId))
return;
analyzedChunk.add(chunkId);
if (chunk.imports.length > 0) {
for (const importFile of chunk.imports) {
const importedChunk = bundle[importFile];
if (importedChunk?.type === "chunk") {
collectCssForChunk(importedChunk);
}
}
}
if (chunk.viteMetadata?.importedCss) {
for (const cssFile of chunk.viteMetadata.importedCss) {
cssFiles.add(cssFile);
}
}
}
if (mainChunk) {
collectCssForChunk(mainChunk.chunk);
}
for (const cssFile of cssFiles) {
htmlTags.push({
tag: "link",
injectTo: "head",
attrs: {
rel: "stylesheet",
crossorigin: true,
href: cssFile,
},
});
}
function replaceSsgHtmlTagsInChunk(chunk) {
const s = new MagicString(chunk.code);
s.replace("__VITE_SSG_HTML_TAGS__", JSON.stringify(htmlTags));
chunk.code = s.toString();
if (chunk.map) {
chunk.map = s.generateMap();
}
}
if (mainChunk.chunk.moduleIds.some((m) => m.includes("?ssg-html-tag"))) {
replaceSsgHtmlTagsInChunk(mainChunk.chunk);
}
else {
for (const importedModule of mainChunk.chunk.imports) {
const importedModuleChunk = bundle[importedModule];
if (importedModuleChunk?.type === "chunk" &&
importedModuleChunk.moduleIds.some((m) => m.includes("?ssg-html-tag"))) {
replaceSsgHtmlTagsInChunk(importedModuleChunk);
break;
}
}
}
const serverApi = await serverApiProvider.create({
href: getHrefFromPath(href, config),
cspNonce: config.html?.cspNonce,
base: getBaseInHTML(href, config),
});
const source = await serverApi.render(mapHtmlTagsToReactTags(htmlTags), renderTimeout);
await serverApi.dispose();
this.emitFile({
type: "asset",
fileName: mainChunk.name + ".html",
source,
});
}
}
finally {
for (const provider of serverApiProviderCache.values()) {
await provider.dispose();
}
serverApiProviderCache.clear();
}
},
},
},
];
};
//# sourceMappingURL=clientBundlePlugin.js.map