UNPKG

microsite

Version:
443 lines (442 loc) 19.2 kB
import { build as buildProject } from "snowpack"; import { dirname, resolve } from "path"; import glob from "globby"; import arg from "arg"; import { rollup } from "rollup"; import { performance } from "perf_hooks"; import { green, dim } from "kleur/colors"; import styles from "rollup-plugin-styles"; import esbuild from "esbuild"; import module from "module"; const { createRequire, builtinModules: builtins } = module; const require = createRequire(import.meta.url); import { STAGING_DIR, SSR_DIR, OUT_DIR_NO_BASE, OUT_DIR, importDataMethods, applyDataMethods, preactImportTransformer, proxyImportTransformer, getFileNameFromPath, hashContentSync, emitFile, renderPage, emitFinalAsset, copyAssetToFinal, stripWithHydrate, preactToCDN, setBasePath, CACHE_DIR, } from "../utils/build.js"; import { rmdir, mkdir, copyDir, copyFile } from "../utils/fs.js"; import { statSync } from "fs"; import { resolveNormalizedBasePath, loadConfiguration, } from "../utils/command.js"; function parseArgs(argv) { return arg({ "--debug-hydration": Boolean, "--no-clean": Boolean, "--no-open": Boolean, "--serve": Boolean, "--base-path": String, }, { permissive: true, argv }); } export default async function build(argvOrParsedArgs) { const args = Array.isArray(argvOrParsedArgs) ? parseArgs(argvOrParsedArgs) : argvOrParsedArgs; let basePath = resolveNormalizedBasePath(args); setBasePath(basePath); const config = await loadConfiguration("build"); const buildStart = performance.now(); await Promise.all([prepare(), buildProject({ config, lockfile: null })]); let pages = await glob(resolve(STAGING_DIR, "src/pages/**/*.js")); let globalEntryPoint = resolve(STAGING_DIR, "src/global/index.js"); let globalStyle = resolve(STAGING_DIR, "src/global/index.css"); try { globalEntryPoint = statSync(globalEntryPoint) ? globalEntryPoint : null; } catch (e) { globalEntryPoint = null; } try { globalStyle = statSync(globalStyle) ? globalStyle : null; } catch (e) { globalStyle = null; } pages = pages.filter((page) => !page.endsWith(".proxy.js")); let [manifest, routeData] = await Promise.all([ bundlePagesForSSR(globalEntryPoint ? [...pages, globalEntryPoint] : pages), fetchRouteData(pages.filter((page) => !page.endsWith("_document.js"))), ]); if (globalStyle) { manifest = manifest.map((entry) => (Object.assign(Object.assign({}, entry), { hydrateStyleBindings: [ "_static/styles/_global.css", ...(entry.hydrateStyleBindings || []), ] }))); } await Promise.all([ ssr(manifest, routeData, { basePath, debug: args["--debug-hydration"], hasGlobalScript: globalEntryPoint !== null, }), copyHydrateAssets(manifest, globalStyle), ]); const buildEnd = performance.now(); console.log(`${green("✔")} build complete ${dim(`[${((buildEnd - buildStart) / 1000).toFixed(2)}s]`)}`); // TODO: print tree of generated files if (!args["--no-clean"]) await cleanup(); if (args["--serve"]) { const toForward = ["--base-path", "--no-open"]; let forwardArgs = {}; for (const arg of toForward) { forwardArgs[arg] = args[arg]; } return import("./microsite-serve.js").then(({ default: serve }) => serve(forwardArgs)); } process.exit(0); } async function prepare() { const paths = [SSR_DIR]; await Promise.all([...paths, OUT_DIR_NO_BASE].map((p) => rmdir(p))); await Promise.all([...paths].map((p) => mkdir(p))); await copyDir(resolve(process.cwd(), "./public"), resolve(process.cwd(), `./${OUT_DIR}`)); } async function copyHydrateAssets(manifest, globalStyle) { let tasks = []; const transform = async (source) => { source = stripWithHydrate(source); source = await preactToCDN(source); const result = await esbuild.transform(source, { minify: true, minifyIdentifiers: false, }); return result.code; }; if (globalStyle) { tasks.push(copyFile(globalStyle, resolve(OUT_DIR, "_static/styles/_global.css"))); } if (manifest.some((entry) => entry.hydrateBindings && Object.keys(entry.hydrateBindings).length > 0)) { const transformInit = async (source) => { source = await preactToCDN(source); const result = await esbuild.transform(source, { minify: true, }); return result.code; }; tasks.push(copyFile(require.resolve("microsite/assets/microsite-runtime.js"), resolve(OUT_DIR, "_static/vendor/microsite.js"), { transform: transformInit })); } const jsAssets = await glob([ resolve(SSR_DIR, "_hydrate/**/*.js"), resolve(SSR_DIR, "_static/**/*.js"), ]); const hydrateStyleAssets = await glob([ resolve(SSR_DIR, "_hydrate/**/*.css"), resolve(SSR_DIR, "_static/**/*.css"), ]); await Promise.all([ ...tasks, ...jsAssets.map((asset) => copyAssetToFinal(asset, transform)), ...hydrateStyleAssets.map((asset) => copyAssetToFinal(asset)), ]); return; } async function fetchRouteData(paths) { let routeData = []; await Promise.all(paths.map((path) => importDataMethods(path) .then((handlers) => applyDataMethods(path.replace(resolve(process.cwd(), `./${STAGING_DIR}/src/pages`), ""), handlers)) .then((entry) => { routeData = routeData.concat(...entry); }))); return routeData; } /** * This function runs rollup on Snowpack's output to * extract the hydrated chunks and prepare the pages to be * server-side rendered. */ async function bundlePagesForSSR(paths) { const bundle = await rollup({ input: paths.reduce((acc, page) => { if (/pages\//.test(page)) { return Object.assign(Object.assign({}, acc), { [page.slice(page.indexOf("pages/"), -3)]: page }); } if (/global\/index\.js/.test(page)) { return Object.assign(Object.assign({}, acc), { "_static/chunks/_global": page }); } }, {}), external: (source) => { return (builtins.includes(source) || source.startsWith("microsite") || source.startsWith("preact")); }, plugins: [ rewriteCssProxies(), rewritePreact(), styles({ config: true, mode: "extract", minimize: true, autoModules: true, modules: { generateScopedName: `[local]_[hash:5]`, }, sourceMap: false, }), ], onwarn(warning, handler) { // unresolved import happens for anything just called server-side if (warning.code === "UNRESOLVED_IMPORT") return; handler(warning); }, }); let entries = new Set(); let entryHydrations = {}; let sharedModuleCssProxyEntries = new Set(); const { output } = await bundle.generate({ dir: SSR_DIR, format: "esm", sourcemap: false, hoistTransitiveImports: false, minifyInternalExports: false, chunkFileNames: "[name].js", assetFileNames: "[name][extname]", /** * This is where most of the magic happens... * We loop through all the modules and group any hydrated components * based on the entryPoint which imported them. * * Components reused for multiple routes are placed in a shared chunk. * * All code from '_snowpack/pkg' is placed in a vendor chunk. */ manualChunks(id, { getModuleInfo }) { const info = getModuleInfo(id); if (id.endsWith(".css") && !id.endsWith(".module.css")) return; if (info.importers.length === 1) { // If we only import this module in global/index.js, inline to _global chunk if (/global\/index\.js$/.test(info.importers[0])) return `_static/vendor/global`; } if (/_snowpack\/pkg/.test(info.id)) return `_static/vendor/index`; const dependentStaticEntryPoints = []; const dependentHydrateEntryPoints = []; const target = info.importedIds.includes("microsite/hydrate") ? dependentHydrateEntryPoints : dependentStaticEntryPoints; const idsToHandle = new Set([ ...info.importers, ...info.dynamicImporters, ]); let moduleInfoById = {}; for (const moduleId of idsToHandle) { const moduleInfo = getModuleInfo(moduleId); moduleInfoById[moduleId] = moduleInfo; for (const importerId of moduleInfo.importers) idsToHandle.add(importerId); } for (const moduleId of idsToHandle) { const moduleInfo = moduleInfoById[moduleId]; const { isEntry, dynamicImporters, importers } = moduleInfo; // TODO: naive check to see if module is a "facade" to only export sub-modules (something like `/components/index.ts`) // const isFacade = (basename(moduleId, extname(moduleId)) === 'index') && !isEntry && importedIds.every(m => dirname(m).startsWith(dirname(moduleId))); if (isEntry || [...importers, ...dynamicImporters].length > 0) target.push(moduleId); if (isEntry) { entries.add(moduleId); } } let manualChunkId; if (dependentHydrateEntryPoints.length > 1) { // All shared components should go in the same chunk (for now) // Eventually this could be optimized to split into a few chunks based on how many entry points rely on them manualChunkId = `_hydrate/chunks/_shared`; } if (dependentStaticEntryPoints.length > 1) { if (id.endsWith(".module.css")) { dependentStaticEntryPoints.forEach((entry) => sharedModuleCssProxyEntries.add(entry.replace(/^.*\/pages\//gim, "pages/"))); manualChunkId = `_static/chunks/_classnames`; } manualChunkId = "_static/chunks/_shared"; } if (dependentHydrateEntryPoints.length === 1) { const { code } = getModuleInfo(dependentHydrateEntryPoints[0]); const hash = hashContentSync(code, 8); const filename = `${getFileNameFromPath(dependentHydrateEntryPoints[0]).replace(/^pages\//, "")}-${hash}`; manualChunkId = `_hydrate/chunks/${filename}`; } for (const moduleId of dependentHydrateEntryPoints) { if (entries.has(moduleId)) { if (!(moduleId in entryHydrations)) { entryHydrations[moduleId] = new Set(); } entryHydrations[moduleId].add(manualChunkId); } } return manualChunkId; }, }); const hydrationExports = output.reduce((acc, chunkOrAsset) => { if (chunkOrAsset.type !== 'asset' && chunkOrAsset.name.startsWith("_hydrate/")) { return Object.assign(Object.assign({}, acc), { [chunkOrAsset.name]: chunkOrAsset.exports }); } return acc; }, {}); const manifest = []; /** * Here we're manually emitting the files so we have a chance * to generate a manifest detailing any dependent styles or * hydrated chunks per entry-point. * * Later, we'll pass the manifest to the SSR function. */ await Promise.all(output.map((chunkOrAsset) => { var _a; if (chunkOrAsset.type === "asset") { if (chunkOrAsset.name.startsWith("_hydrate")) { const finalAssetName = chunkOrAsset.name.replace(/\bchunks\b/, "styles"); manifest.forEach((entry) => { let binding = chunkOrAsset.name.replace(/\.css$/, ".js"); if (entry.hydrateBindings && entry.hydrateBindings[binding]) { entry.hydrateStyleBindings = Array.from(new Set([...entry.hydrateStyleBindings, finalAssetName])); } }); return emitFile(finalAssetName, chunkOrAsset.source); } else if (chunkOrAsset.name.endsWith("_classnames.css")) { const finalAssetName = "_static/styles/_modules.css"; for (const entryName of sharedModuleCssProxyEntries.values()) { const inManifest = manifest.find((entry) => entry.name === entryName); if (inManifest) { manifest.forEach((entry) => { if (entry.name === entryName) { entry.hydrateStyleBindings = Array.from(new Set([ ...entry.hydrateStyleBindings, `${finalAssetName}?m=${hashContentSync(chunkOrAsset.source.toString(), 8)}`, ])); } }); } else { manifest.push({ name: entryName, hydrateStyleBindings: [ `${finalAssetName}?m=${hashContentSync(chunkOrAsset.source.toString(), 8)}`, ], hydrateBindings: {}, }); } } return emitFile(finalAssetName, chunkOrAsset.source); } else { let entryName = chunkOrAsset.name.replace(/\.css$/, ".js"); let finalAssetName = chunkOrAsset.name .replace(/^pages/, "_hydrate/styles") .replace(/\bchunks\b/, "styles"); const inManifest = manifest.find((entry) => entry.name === entryName); if (inManifest) { manifest.forEach((entry) => { if (entry.name === entryName) { entry.hydrateStyleBindings = Array.from(new Set([ ...entry.hydrateStyleBindings, `${finalAssetName}?m=${hashContentSync(chunkOrAsset.source.toString(), 8)}`, ])); } }); } else { manifest.push({ name: entryName, hydrateStyleBindings: [], hydrateBindings: {}, }); } return emitFile(finalAssetName, chunkOrAsset.source); } } else { if (chunkOrAsset.name.startsWith("_hydrate/") || chunkOrAsset.name.startsWith("_static/")) { return emitFile(`${chunkOrAsset.name}.js`, chunkOrAsset.code); } else if (chunkOrAsset.isEntry) { let hydrateBindings = {}; for (const [file, exports] of Object.entries(chunkOrAsset.importedBindings)) { if (file.startsWith("_hydrate/")) { hydrateBindings = Object.assign(hydrateBindings, { [file]: exports, }); } } const id = chunkOrAsset.facadeModuleId; if (id in entryHydrations) { for (const hydration of entryHydrations[id]) { const exports = (_a = hydrationExports[hydration]) !== null && _a !== void 0 ? _a : []; const file = `${hydration}.js`; hydrateBindings = Object.assign(hydrateBindings, { [file]: exports, }); } } const entryName = `${chunkOrAsset.name}.js`; const inManifest = manifest.find((entry) => entry.name === entryName); if (inManifest) { manifest.forEach((entry) => { if (entry.name === entryName) { entry.hydrateBindings = Object.assign(entry.hydrateBindings || {}, hydrateBindings); } }); } else { manifest.push({ name: entryName, hydrateStyleBindings: [], hydrateBindings, }); } emitFile(entryName, chunkOrAsset.code); } else { console.log(`Unexpected chunk: ${chunkOrAsset.name}`, chunkOrAsset.code); } } })); return manifest .filter(({ name }) => name !== "pages/_document.js") .map((entry) => { if (Object.keys(entry.hydrateBindings).length === 0) entry.hydrateBindings = null; if (entry.hydrateStyleBindings.length === 0) entry.hydrateStyleBindings = null; return entry; }); } /** * Snowpack rewrites CSS to a `.css.proxy.js` file. * Great for dev, but we need to revert to the actual CSS file */ const rewriteCssProxies = () => { return { name: "@microsite/rollup-rewrite-css-proxies", resolveId(source, importer) { if (!proxyImportTransformer.filter(source)) return null; return resolve(dirname(importer), proxyImportTransformer.transform(source)); }, }; }; /** * Snowpack rewrites CSS to a `.css.proxy.js` file. * Great for dev, but we need to revert to the actual CSS file */ const rewritePreact = () => { return { name: "@microsite/rollup-rewrite-preact", resolveId(source) { if (!preactImportTransformer.filter(source)) return null; return preactImportTransformer.transform(source); }, }; }; async function ssr(manifest, routeData, { basePath = "/", debug = false, hasGlobalScript = false } = {}) { return Promise.all(routeData.map((entry) => renderPage(entry, manifest.find((route) => route.name.replace(/^pages/, "") === entry.name), { basePath, debug, hasGlobalScript }) .then(({ name, contents }) => { return { name, contents }; }) .then(({ name, contents }) => emitFinalAsset(name, contents)))); } async function cleanup() { const paths = [STAGING_DIR, SSR_DIR, CACHE_DIR]; await Promise.all(paths.map((p) => rmdir(p))); }