@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.
456 lines (411 loc) • 14.9 kB
text/typescript
import nodePath from "node:path";
import { type PluginOption, createRunnableDevEnvironment, loadEnv } from "vite";
import { cleanUrl } from "./utils/cleanUrl.js";
import { createSsgId, isSsgId, removeSsgQuery } from "./modules/isSsgId.js";
import {
createSsgServerEntryId,
isSsgServerEntryId,
} from "./modules/isSsgServerEntryId.js";
import {
createSsgClientEntryId,
isSsgClientEntryId,
} from "./modules/isSsgClientEntryId.js";
import {
addMainQuery,
isMainId,
removeMainQuery,
} from "./modules/mainQuery.js";
import { addQueryParam, parseQueryParams } from "./utils/queryParam.js";
import { cleanSsgAssetId } from "./modules/isSsgAssetId.js";
import { isSsgHtmlTagsId } from "./utils/ssgHtmlTags.js";
import {
createSsgPageUrlId,
isSsgPageUrlId,
removeSsgPageUrlId,
} from "./modules/isSsgPageUrlId.js";
import { createSsgUrl, isSsgUrl, removeSsgUrl } from "./modules/isSsgUrl.js";
import { getPathsToLookup } from "./utils/getPathsToLookup.js";
import { existsSync } from "node:fs";
import { loadServerApi } from "./api/loadServerApi.js";
import {
createSsgEntryQuery,
isSsgEntryQuery,
removeSsgEntryQuery,
} from "./modules/ssgEntryQuery.js";
import { createVirtualHtmlEntry } from "./modules/isVirtualHtmlEntry.js";
import { createSsgComponentId } from "./modules/isSsgComponentId.js";
import { getPageName } from "./utils/getPageName.js";
import { ssgComponentResolution } from "./resolvers/ssgComponentResolution.js";
import { ssgAssetsResolutionPlugin } from "./resolvers/ssgAssetsResolution.js";
import { viteFsFallbackResolutionPlugin } from "./resolvers/viteFsFallbackResolution.js";
import { changePathExt } from "./utils/changePathExt.js";
import type { SsgPluginOptions } from "./SsgPluginOptions.js";
import { pagesMiddleware } from "./server/pages-middleware.js";
import { getHrefFromPath } from "./utils/getHrefFromPath.js";
import { mapBaseToUrl } from "./utils/mapBaseToUrl.js";
import { ssrBundlePlugin } from "./resolvers/ssrBundlePlugin.js";
import { clientBundlePlugin } from "./resolvers/clientBundlePlugin.js";
import { stripBase } from "./utils/stripBase.js";
import { createNodeImportMeta } from "vite/module-runner";
export * from "./react/IndexComponent.js";
export const ssgPlugin = (
pluginOptions: SsgPluginOptions = {
renderTimeout: 10000,
},
): PluginOption => {
const emittedPages = new Set<string>();
return [
viteFsFallbackResolutionPlugin(),
ssgAssetsResolutionPlugin(),
ssrBundlePlugin(),
clientBundlePlugin(pluginOptions.renderTimeout),
{
name: "@wroud/vite-plugin-ssg",
enforce: "post",
config(userConfig, env) {
userConfig.environments = {
...userConfig.environments,
client: {
...userConfig.environments?.["client"],
optimizeDeps: {
...userConfig.environments?.["client"]?.optimizeDeps,
include: [
...(userConfig.environments?.["client"]?.optimizeDeps
?.include ?? []),
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
"@wroud/vite-plugin-ssg > react",
"@wroud/vite-plugin-ssg > react-dom",
"@wroud/vite-plugin-ssg/react/client",
"@wroud/vite-plugin-ssg/react/components",
"@wroud/vite-plugin-ssg/app",
],
},
},
ssr: {
...userConfig.environments?.["ssr"],
dev: {
...userConfig.environments?.["ssr"]?.dev,
createEnvironment(name, config) {
return createRunnableDevEnvironment(name, config, {
runnerOptions: {
hmr: { logger: false },
createImportMeta: createNodeImportMeta,
},
});
},
},
// resolve: {
// dedupe: ["@wroud/vite-plugin-ssg"],
// },
build: {
...userConfig.environments?.["ssr"]?.build,
ssr: true,
rolldownOptions: {
...userConfig.environments?.["ssr"]?.build?.rolldownOptions,
// external:
// env.command === "build"
// ? [/^@wroud\/vite-plugin-ssg.*/]
// : undefined,
},
outDir:
(userConfig.environments?.["ssr"]?.build?.outDir ||
userConfig.build?.outDir ||
"dist") + "-server",
},
},
};
// userConfig.resolve = {
// ...userConfig.resolve,
// dedupe: [
// ...(userConfig.resolve?.dedupe || []),
// "@wroud/vite-plugin-ssg",
// ],
// };
userConfig.builder = {
async buildApp(builder) {
await builder.build(builder.environments["ssr"]!);
await builder.build(builder.environments["client"]!);
},
};
},
buildStart: {
handler() {
emittedPages.clear();
},
},
configureServer: {
order: "pre",
async handler(server) {
return () => {
server.middlewares.use(pagesMiddleware(server, pluginOptions));
};
},
},
resolveId: {
order: "pre",
async handler(source, importer, options) {
const config = this.environment.config;
if (
isSsgClientEntryId(source) ||
isSsgServerEntryId(source) ||
isSsgHtmlTagsId(source) ||
(importer &&
isMainId(source) &&
(isSsgClientEntryId(importer) || isSsgServerEntryId(importer)))
) {
const params = parseQueryParams(source);
const componentResolved = await this.resolve(
createSsgComponentId(cleanUrl(source)),
source,
options,
);
if (!componentResolved) {
return null;
}
return addQueryParam(
cleanUrl(componentResolved.id),
Object.keys(params)[0]!,
);
}
if (isSsgId(source)) {
if (this.environment.name === "ssr") {
source = createSsgServerEntryId(removeSsgQuery(source));
} else {
source = createSsgClientEntryId(removeSsgQuery(source));
}
return await this.resolve(source, importer, {
...options,
skipSelf: false,
});
}
if (isSsgPageUrlId(source)) {
const alreadyResolved =
options.custom?.["@wroud/vite-plugin-ssg:page-url-id"]?.resolved;
if (alreadyResolved) {
return alreadyResolved;
}
const resolved = await this.resolve(
removeSsgPageUrlId(source),
importer,
options,
);
if (!resolved) {
this.error(`Failed to resolve SSG page URL: ${source}`);
//@ts-ignore
return null;
}
if (config.command === "build") {
const name = nodePath.posix.relative(
config.root,
changePathExt(resolved.id, ""),
);
this.emitFile({
id: createSsgEntryQuery(changePathExt(resolved.id, "")),
name,
fileName:
this.environment.name === "ssr"
? changePathExt(name, ".js")
: undefined,
type: "chunk",
importer: source,
preserveSignature: "strict",
});
}
return this.resolve(
changePathExt(createSsgPageUrlId(resolved.id), ""),
importer,
{
...options,
custom: {
...options.custom,
"@wroud/vite-plugin-ssg:page-url-id": {
resolved: changePathExt(
createSsgPageUrlId(resolved.id),
"",
),
},
},
},
);
}
if (isSsgEntryQuery(source)) {
source = nodePath.posix.resolve(config.root, source);
let resolvedId = await this.resolve(
createSsgId(removeSsgEntryQuery(source)),
importer,
{
...options,
skipSelf: false,
},
);
if (!resolvedId) {
this.error(`Failed to resolve SSG entry query: ${source}`);
}
if (
config.command === "build" &&
this.environment.name === "client"
) {
let name = nodePath.posix.relative(
config.root,
changePathExt(removeSsgEntryQuery(source), ""),
);
this.emitFile({
id: createVirtualHtmlEntry(
nodePath.posix.join(config.root, name),
),
name,
type: "chunk",
});
try {
const ssrConfig = config.environments["ssr"]!;
const lookUpPaths = getPathsToLookup(name);
let serverModulePath: string | undefined;
for (const possiblePath of lookUpPaths) {
const exists = existsSync(
nodePath.join(
config.root,
ssrConfig.build.outDir,
possiblePath + ".js",
),
);
if (exists) {
serverModulePath = possiblePath;
break;
}
}
if (!serverModulePath) {
this.error(`No SSG chunk found for: ${name}`);
}
const serverApiProvider = await loadServerApi(
nodePath.join(
config.root,
ssrConfig.build.outDir,
serverModulePath + `.js`,
),
// Empty prefix loads ALL keys from .env files, not just VITE_*.
// SSR fork needs server-only values (DB creds, API keys); they
// never leak to the client bundle.
loadEnv(config.mode, config.envDir, ""),
);
const serverApi = await serverApiProvider.create({
base: mapBaseToUrl("/", config),
href: getHrefFromPath(name, config),
});
const routes = await serverApi.getPathsToPrerender();
await serverApi.dispose();
await serverApiProvider.dispose();
for (let route of routes) {
route = stripBase(route, config.base);
const id = createSsgUrl(route);
if (emittedPages.has(id)) {
continue;
}
emittedPages.add(id);
const name = getPageName(route);
this.emitFile({
id,
name,
type: "chunk",
});
}
} catch (error) {
this.error(`Failed to import routes prerender: ${error}`);
}
}
return resolvedId;
}
if (isSsgUrl(source)) {
return this.resolve(
createSsgEntryQuery(removeSsgUrl(source)),
importer,
{
...options,
skipSelf: false,
},
);
}
return undefined;
},
},
load: {
order: "pre",
async handler(id) {
const config = this.environment.config;
if (isMainId(id)) {
if (config.command === "serve") {
if (id.startsWith(config.root)) {
id = nodePath.posix.relative(config.root, id);
this.debug(`Transformed to server URL: ${id}`);
} else {
id = mapBaseToUrl("/@fs" + id, config);
this.debug(`Transformed to fs path: ${id}`);
}
id = JSON.stringify(createSsgClientEntryId(removeMainQuery(id)));
} else {
id = `import.meta.ROLLUP_FILE_URL_${this.emitFile({
type: "chunk",
id: createSsgClientEntryId(removeMainQuery(id)),
})}`;
}
return {
code: `
export default ${id};
`,
moduleType: "js",
};
}
if (isSsgServerEntryId(id)) {
return {
code: `
import { create as createServer } from "@wroud/vite-plugin-ssg/react/server";
import Index from "${addQueryParam(cleanUrl(id), "server")}";
import mainScriptUrl from "${addMainQuery(cleanUrl(id))}";
export async function create(context) {
return await createServer(Index, context, mainScriptUrl);
}
`,
moduleType: "js",
};
}
if (isSsgClientEntryId(id)) {
return {
code: `
import { create } from "@wroud/vite-plugin-ssg/react/client";
import htmlTags from "${addQueryParam(cleanUrl(id), "ssg-html-tags")}";
import Index from "${addQueryParam(cleanUrl(id), "client")}";
import mainScriptUrl from "${addMainQuery(cleanUrl(id))}";
const context = {}
const api = await create(Index, context, mainScriptUrl);
await api.hydrate(htmlTags);
`,
moduleSideEffects: true,
moduleType: "js",
};
}
if (isSsgHtmlTagsId(id)) {
return {
code: `export default __VITE_SSG_HTML_TAGS__;`,
moduleType: "js",
};
}
if (isSsgPageUrlId(id)) {
id = removeSsgPageUrlId(cleanSsgAssetId(id));
id = changePathExt(
nodePath.posix.relative(config.root, id),
".html",
);
return {
code: `export default ${JSON.stringify(id)};`,
moduleType: "js",
};
}
return undefined;
},
},
},
ssgComponentResolution(),
];
};