UNPKG

microsite

Version:
741 lines (729 loc) 29.3 kB
import { join, resolve, extname, dirname, basename } from "path"; import { rollup } from "rollup"; import globby from "globby"; import crypto from "crypto"; import cache from "cacache"; import { createRequire } from "module"; const require = createRequire(import.meta.url); const typescriptPaths = require("rollup-plugin-typescript-paths") .typescriptPaths; const multi = require("rollup-plugin-multi-input").default; import styles from "rollup-plugin-styles"; import esbuild from "rollup-plugin-esbuild"; import nodeResolve from "@rollup/plugin-node-resolve"; import autoExternal from "rollup-plugin-auto-external"; import cjs from "@rollup/plugin-commonjs"; import replace from "@rollup/plugin-replace"; import inject from "@rollup/plugin-inject"; import { terser } from "rollup-plugin-terser"; import { Document, __hydratedComponents } from "../document.js"; import { h } from "preact"; import render from "preact-render-to-string"; import { promises as fsp, readFileSync } from "fs"; import { createPrefetch, isKeyValid } from "../utils/prefetch.js"; const { readdir, readFile, writeFile, mkdir, copyFile, stat, rmdir } = fsp; const hashFileSync = (p, len) => { const hash = crypto.createHash("sha256"); hash.update(readFileSync(p)); let res = hash.digest("hex"); if (typeof len === "number") res = res.slice(0, len); return res; }; const hashContentSync = (content, len) => { const hash = crypto.createHash("sha256"); hash.update(Buffer.from(content)); let res = hash.digest("hex"); if (typeof len === "number") res = res.slice(0, len); return res; }; const createHydrateInitScript = ({ isDebug = false, } = {}) => { return `import { h, hydrate as mount } from 'https://unpkg.com/preact@latest?module'; const createObserver = (hydrate) => { if (!('IntersectionObserver') in window) return null; const io = new IntersectionObserver((entries) => { entries.forEach((entry) => { const isIntersecting = entry.isIntersecting || entry.intersectionRatio > 0; if (!isIntersecting) return; hydrate(); io.disconnect(); }) }); return io; } function attach($cmp, { key, name, source }) { const method = $cmp.dataset.method; const hydrate = async () => { if ($cmp.dataset.hydrate === '') return; ${isDebug ? 'console.log(`[Hydrate] <${key} /> hydrated via "${method}"`);' : ""} const { [name]: Component } = await import(source); const props = $cmp.dataset.props ? JSON.parse(atob($cmp.dataset.props)) : {}; mount(h(Component, props, null), $cmp); delete $cmp.dataset.props; delete $cmp.dataset.method; $cmp.dataset.hydrate = ''; } switch (method) { case 'idle': { if (!('requestIdleCallback' in window) || !('requestAnimationFrame' in window)) return hydrate(); requestIdleCallback(() => { requestAnimationFrame(hydrate); }, { timeout: 2000 }); break; } case 'interaction': { const events = ['focus', 'click', 'touchstart', 'pointerenter']; function handleEvent(event) { hydrate().then(() => { if (event.type === 'focus') event.target.focus(); for (const e of events) { event.target.removeEventListener(e, handleEvent); } }) } for (const e of events) { $cmp.addEventListener(e, handleEvent, { once: true, passive: true, capture: true }); } break; } case 'visible': { if (!('IntersectionObserver') in window) return hydrate(); const observer = createObserver(hydrate); Array.from($cmp.children).forEach(child => observer.observe(child)) break; } } } export default (manifest) => { const $cmps = Array.from(document.querySelectorAll('[data-hydrate]')); for (const $cmp of $cmps) { const key = $cmp.dataset.hydrate; const [name, source] = manifest[key]; attach($cmp, { key, name, source }); } }`; }; const createHydrateScript = (components, manifest) => { if (components.length === 0) return null; const imports = manifest .map(({ name, exports }) => { return { name, exports: exports.filter(([_key, name]) => components.findIndex((n) => n === name) > -1), }; }) .filter(({ exports }) => exports.length > 0) .map(({ name, exports }) => exports .map(([key, comp]) => ` '${comp}': ['${key}', '/_hydrate/chunks/${name}'],`) .join("\n")) .join("\n"); return `import hydrate from '/_hydrate/index.js'; hydrate({ ${imports.slice(0, -1)} });`; }; const requiredPlugins = [ inject({ fetch: "microsite/utils/fetch.js", h: ["preact", "h"], Fragment: ["preact", "Fragment"], exclude: [/\.css$/], }), nodeResolve({ preferBuiltins: true, mainFields: ["module", "main"], dedupe: ["preact/compat"], extensions: [".mjs", ".js", ".json", ".node", ".jsx", ".ts", ".tsx"], }), cjs({ extensions: [".mjs", ".js", ".json", ".node", ".jsx", ".ts", ".tsx"], }), typescriptPaths({ transform(filename) { return filename.replace(/\.js$/i, ".tsx"); }, }), ]; const globalPlugins = [ styles({ config: true, mode: "extract", autoModules: true, minimize: true, sourceMap: false, }), ]; const createPagePlugins = () => [ styles({ config: true, mode: "extract", minimize: true, autoModules: true, modules: { generateScopedName: `[local]_[hash:5]`, }, sourceMap: false, }), ]; const OUTDIR = "./.microsite"; const OUTPUT_DIR = join(OUTDIR, "build"); const CACHE_DIR = join(OUTDIR, "cache"); const outputOptions = { format: "esm", sourcemap: false, hoistTransitiveImports: false, minifyInternalExports: false, }; const EXTERNALS = [ "microsite/head", "microsite/document", "microsite/global", "microsite/page", "microsite/hydrate", "microsite/head.js", "microsite/document.js", "microsite/global.js", "microsite/page.js", "microsite/hydrate.js", "microsite/utils/fetch.js", "preact", "preact/hooks", "preact/jsx-runtime", "preact-render-to-string", ]; const internalRollupConfig = { context: "globalThis", external: EXTERNALS, treeshake: true, onwarn(message) { if (/empty chunk/.test(`${message}`)) return; if (message.pluginCode === "TS2686") return; console.error(message); }, manualChunks(id, { getModuleInfo }) { const info = getModuleInfo(id); const dependentEntryPoints = []; if (info.importedIds.includes("microsite/hydrate")) { const idsToHandle = new Set([ ...info.importers, ...info.dynamicImporters, ]); for (const moduleId of idsToHandle) { const { isEntry, dynamicImporters, importers } = getModuleInfo(moduleId); // naive check to see if module is a "facade" to only export sub-modules // const isFacade = (basename(moduleId, extname(moduleId)) === 'index') && !isEntry && importedIds.every(m => dirname(m).startsWith(dirname(moduleId))); if (isEntry || [...importers, ...dynamicImporters].length > 0) dependentEntryPoints.push(moduleId); for (const importerId of importers) idsToHandle.add(importerId); } } if (dependentEntryPoints.length > 1) { const hash = hashContentSync(info.code, 7); return `hydrate/shared-${hash}`; } else if (dependentEntryPoints.length === 1) { const { code } = getModuleInfo(dependentEntryPoints[0]); const hash = hashContentSync(code, 7); return `hydrate/${dependentEntryPoints[0] .split("/") .slice(-1)[0] .split(".")[0] .replace(/([\[\]])/gi, "")}-${hash}`; } }, }; async function writeGlobal() { try { (await stat("./src/global.ts")).isFile(); } catch (e) { return; } const global = await rollup(Object.assign(Object.assign({}, internalRollupConfig), { plugins: [ autoExternal(), esbuild({ target: "es2018", jsxFactory: "h", jsxFragment: "Fragment" }), ...requiredPlugins, ...globalPlugins, ], input: "src/global.ts" })); const legacy = await rollup(Object.assign(Object.assign({}, internalRollupConfig), { plugins: [ autoExternal(), esbuild({ target: "es2015", jsxFactory: "h", jsxFragment: "Fragment" }), ...requiredPlugins, ...globalPlugins, ], input: "src/global.ts" })); try { return Promise.all([ global.write({ format: "esm", sourcemap: false, assetFileNames: "global.css", dir: OUTPUT_DIR, name: "global", }), legacy.write({ format: "system", sourcemap: false, file: join(OUTPUT_DIR, "global.legacy.js"), }), ]); } catch (e) { console.log(e); } } async function writePages() { try { const input = await globby("src/pages/**/*.tsx"); const bundle = await rollup(Object.assign(Object.assign({}, internalRollupConfig), { plugins: [ autoExternal(), esbuild({ target: "es2018", jsxFactory: "h", jsxFragment: "Fragment" }), ...requiredPlugins, ...createPagePlugins(), { name: "microsite-manifest", generateBundle(_opts, bundle) { let manifest = []; for (const [_file, info] of Object.entries(bundle)) { if (info.type === "asset") { manifest.push({ file: info.fileName, type: info.type, hash: hashContentSync(info.source.toString()), }); } else { manifest.push({ file: info.fileName, type: info.type, hash: hashContentSync(info.code.toString()), }); } } this.emitFile({ type: "asset", fileName: "microsite-manifest.json", source: JSON.stringify(manifest, null, 2), }); }, }, ], input: input.reduce((acc, page) => { let entryName = page.split("pages")[1].slice(1); entryName = entryName.slice(0, extname(entryName).length * -1); return Object.assign(Object.assign({}, acc), { [`pages/${entryName}`]: page }); }, {}) })); const result = await bundle.write(Object.assign(Object.assign({}, outputOptions), { chunkFileNames: "[name].js", assetFileNames: "[name][extname]", dir: OUTPUT_DIR, paths: { "microsite/head": "microsite/head.js", "microsite/document": "microsite/document.js", "microsite/global": "microsite/global.js", "microsite/page": "microsite/page.js", "microsite/hydrate": "microsite/hydrate.js", } })); return result; } catch (e) { console.log(e); } } async function readDir(dir) { try { const entries = await readdir(dir, { withFileTypes: true }); return Promise.all(entries.map((entry) => entry.isDirectory() ? readDir(join(dir, entry.name)) : join(dir, entry.name))).then((arr) => arr.flat(Infinity)); } catch (e) { return []; } } async function prepare() { const paths = [resolve("./dist"), resolve(OUTPUT_DIR)]; await Promise.all(paths.map((p) => rmdir(p, { recursive: true }))); await Promise.all([...paths, CACHE_DIR].map((p) => mkdir(p, { recursive: true }))); try { if ((await stat("./src/public")).isDirectory()) { const files = await readDir("./src/public"); await Promise.all(files.map((file) => copyFile(resolve(process.cwd(), file), resolve(process.cwd(), "./dist/" + file.slice("src/public/".length))))); } } catch (e) { } } async function cleanup({ err = false } = {}) { const paths = [OUTPUT_DIR]; await Promise.all(paths.map((p) => rmdir(p, { recursive: true }))); if (err) { await rmdir("./dist", { recursive: true }); } } const DYNAMIC_ROUTE = /\[[^/]+?\](?=\/|$)/; function isDynamicRoute(route) { return DYNAMIC_ROUTE.test(route); } const routeToSegments = (route) => route.split("/").map((text) => { const isDynamic = isDynamicRoute(text); const isCatchAll = isDynamic && text.slice(1, -1).startsWith("..."); return { text, isDynamic, isCatchAll }; }); const validateStaticPath = (staticPath, { segments }) => { if (typeof staticPath === "string") { if (segments.find((v) => v.isCatchAll)) { return staticPath.replace(/^\//, "").split("/").length >= segments.length; } else { return staticPath.replace(/^\//, "").split("/").length >= segments.length; } } else if (typeof staticPath === "object" && typeof staticPath.params === "object") { const { params } = staticPath; return (JSON.stringify(Object.keys(params)) === JSON.stringify(Object.keys(params))); } return false; }; const validateStaticPaths = (staticPaths, { segments, params }) => { if (typeof staticPaths === "object" && Array.isArray(staticPaths.paths)) { const paths = staticPaths.paths; return paths.every((path) => validateStaticPath(path, { segments, params })); } return false; }; const getParamsFromRoute = (route, segments) => { const parts = route.replace(/^\//, "").split("/"); return parts.reduce((acc, part, i) => { var _a, _b; const segment = (_a = segments[i]) !== null && _a !== void 0 ? _a : segments[segments.length - 1]; if (segment.isCatchAll) { const key = segment.text.slice(4, -1); return Object.assign(Object.assign({}, acc), { [key]: [...((_b = acc[key]) !== null && _b !== void 0 ? _b : []), part] }); } if (segment.isDynamic) { const key = segment.text.slice(1, -1); return Object.assign(Object.assign({}, acc), { [key]: part }); } return acc; }, {}); }; const staticPathToStaticPropsContext = (staticPath, { segments }) => { if (typeof staticPath === "string") return { path: staticPath, params: getParamsFromRoute(staticPath, segments), }; return Object.assign(Object.assign({}, staticPath), { path: segments .map((segment) => { const key = segment.text.slice(1, -1); return segment.isDynamic ? staticPath.params[key] : segment.text; }) .join("/") }); }; async function renderPage(page, { styles, hydrateExportManifest, hasGlobalScript, globalStyle, isDebug }) { var _a; let baseHydrate = false; let routeHydrate = false; const output = []; let { default: Page, getStaticProps = () => { }, getStaticPaths, __name, __hash, } = page; if (typeof Page === "object") { if (Page.path && Page.path.replace(/^\//, "") !== __name) { console.warn(`"/${__name}" uses \`definePage\` with a \`path\` value of \`${Page.path}\`.\n\nDid you mean to update your file structure?\nNote that \`path\` is used for type inference only and has no effect on the build process.`); } getStaticProps = (_a = Page.getStaticProps) !== null && _a !== void 0 ? _a : (() => { }); getStaticPaths = Page.getStaticPaths; Page = Page.Component; } const { content: style = null } = styles.find((style) => style.__name === __name) || {}; let staticPaths = [ { path: __name, params: {}, meta: null }, ]; if (typeof getStaticPaths === "function") { if (!isDynamicRoute(__name)) throw new Error(`Error building /${__name}!\nExported \`getStaticPaths\`, but ${__name} is not a dynamic route`); const routeSegments = routeToSegments(__name); const baseParams = getParamsFromRoute(__name, routeSegments); const routeInfo = { segments: routeSegments, params: baseParams, }; const catchAllIndex = routeSegments.findIndex((v) => v.isCatchAll); if (catchAllIndex !== -1 && catchAllIndex < routeSegments.length - 1) throw new Error(`Error building /${__name}!\n\`${routeSegments[catchAllIndex].text}\` must be the final segment of the route`); const cacheKey = `microsite:getStaticPaths:${__name}`; const { data = null, metadata: { key: previousKey = null, file: previousFile = null } = {}, } = await cache.get(CACHE_DIR, cacheKey).catch(() => ({})); const currentFile = __hash; let staticPathsOrKey = await getStaticPaths({ prefetch: createPrefetch(previousKey), }); if (typeof staticPathsOrKey === "string") { const currentKey = staticPathsOrKey; if (previousFile && previousFile === currentFile && isKeyValid(previousKey, currentKey)) { staticPaths = JSON.parse(data.toString()); } else { if (previousKey && previousFile) await cache.rm(CACHE_DIR, cacheKey); staticPaths = await getStaticPaths({ prefetch: undefined }); if (currentKey) { await cache.put(CACHE_DIR, cacheKey, JSON.stringify(staticPaths), { metadata: { name: __name, key: currentKey, file: currentFile }, }); } } } else { staticPaths = staticPathsOrKey; } if (!staticPaths) throw new Error(`Error building /${__name}!\n\`getStaticPaths\` must return a value`); if (!validateStaticPaths(staticPaths, routeInfo)) throw new Error(`Error building /${__name}!\nOne or more return values from \`getStaticPaths\` has an incorrect shape.\nEnsure that the returned values have the same number of segments as the route. Static path strings must begin from the site root.`); const { paths } = staticPaths; staticPaths = paths.map((staticPath) => staticPathToStaticPropsContext(staticPath, routeInfo)); } else if (isDynamicRoute(__name)) { throw new Error(`Error building /${__name}!\n${__name} is a dynamic route, but \`getStaticPaths\` is missing. Did you forget to \`export\` it?`); } async function fetchSingle({ params, path, meta }) { var _a; let staticProps; let props = {}; try { const cacheKey = `microsite:getStaticProps:${path}`; const { data = null, metadata: { key: previousKey = null, file: previousFile = null } = {}, } = await cache.get(CACHE_DIR, cacheKey).catch(() => ({})); const currentFile = __hash; let staticPropsOrKey = await getStaticProps({ path, params: JSON.parse(JSON.stringify(params)), meta, prefetch: createPrefetch(previousKey), }); if (typeof staticPropsOrKey === "string" || staticPropsOrKey === null) { const currentKey = staticPropsOrKey; if (previousFile && previousFile === currentFile && isKeyValid(previousKey, currentKey)) { staticProps = JSON.parse(data.toString()); } else { if (previousKey && previousFile) await cache.rm(CACHE_DIR, cacheKey); staticProps = await getStaticProps({ path, params: JSON.parse(JSON.stringify(params)), meta, prefetch: undefined, }); if (currentKey) { await cache.put(CACHE_DIR, cacheKey, JSON.stringify(staticProps), { metadata: { name: __name, path: path, key: currentKey, file: currentFile, }, }); } } } else { staticProps = staticPropsOrKey; } props = (_a = staticProps === null || staticProps === void 0 ? void 0 : staticProps.props) !== null && _a !== void 0 ? _a : {}; } catch (e) { console.error(`Error getting static props for "${path}"`); console.error(e); } return { path, props }; } async function renderSingle({ path, props }) { var _a; try { const content = "<!DOCTYPE html>\n<!-- Generated by microsite -->\n" + render(h(Document, { hydrateExportManifest: hydrateExportManifest, page: __name, hasScripts: hasGlobalScript, globalStyle: globalStyle !== null && globalStyle !== void 0 ? globalStyle : null, styles: [style].filter((v) => v) }, h(Page, Object.assign({}, props))), {}, { pretty: true }); const { components } = (_a = __hydratedComponents.find((s) => s.page === __name)) !== null && _a !== void 0 ? _a : {}; if (components) { if (!baseHydrate) { output.push({ name: `_hydrate/index.js`, content: createHydrateInitScript({ isDebug }), }); baseHydrate = true; } if (!routeHydrate) { output.push({ name: `_hydrate/pages/${__name}.js`, content: createHydrateScript(components, hydrateExportManifest), }); routeHydrate = true; } } let cleanOutput = content .replace(/^\s+$/gm, "\n") .replace(/(?<=<pre><code.*?>)([\s\S]+?)(?=<\/code>)/gi, (match) => { let mindent = 0; return match .split("\n") .map((ln) => { let diff = ln.length - ln.trimStart().length; if (diff > 0 && !mindent) mindent = diff; return ln.slice(mindent); }) .join("\n") .trim(); }); output.push({ name: `${path === "/" ? "/index" : path}.html`, content: cleanOutput, }); } catch (e) { console.log(`Error building /${__name}.html`); console.error(e); await cleanup({ err: true }); return; } } const pages = await Promise.all(staticPaths.map((ctx) => fetchSingle(ctx))); await Promise.all(pages.map(({ path, props }) => renderSingle({ path, props }))); return output; } export async function build(args) { console.time("Build"); const isDebug = args["--debug-hydration"]; const noClean = args["--no-clean"]; await prepare(); await Promise.all([writeGlobal(), writePages()]); const micrositeManifest = await fsp .readFile(join(OUTPUT_DIR, "microsite-manifest.json")) .then((res) => JSON.parse(res.toString())); let globalStyle = null; let hasGlobalScript = false; try { if (!(await stat(join(OUTPUT_DIR, "global.css"))).isFile()) throw new Error(); await mkdir(resolve(`dist/_hydrate/styles`), { recursive: true }); await copyFile(join(OUTPUT_DIR, "global.css"), join("dist", "_hydrate", "styles", "global.css")); globalStyle = `global.css?v=${hashFileSync(join(OUTPUT_DIR, "global.css"), 7)}`; } catch (e) { } try { hasGlobalScript = await readFile(join(OUTPUT_DIR, "global.js")).then((v) => !!v.toString().trim()); } catch (e) { } if (hasGlobalScript) { await Promise.all([ copyFile(resolve(join(OUTPUT_DIR, "global.js")), "dist/index.js"), copyFile(resolve(join(OUTPUT_DIR, "global.legacy.js")), "dist/index.legacy.js"), ]); } const files = await readDir(join(OUTPUT_DIR, "pages")); const getName = (f, base = "pages") => f.slice(f.indexOf(`${base}/`) + base.length + 1, extname(f).length * -1); const styles = await Promise.all(files .filter((f) => f.endsWith(".css")) .map((f) => readFile(f).then((buff) => ({ __name: getName(f), content: buff.toString(), })))); const pages = await Promise.all(files .filter((f) => f.endsWith(".js")) .map((f) => import(join(process.cwd(), f)).then((mod) => { const entry = micrositeManifest.find((entry) => f.indexOf(entry.file) > -1); return Object.assign(Object.assign({}, mod), { __name: getName(f), __hash: entry.hash }); }))); let hydrateFiles = []; let hydrateExportManifest = []; try { hydrateFiles = await readDir(join(OUTPUT_DIR, "hydrate")); hydrateExportManifest = await Promise.all(hydrateFiles .filter((f) => extname(f) === ".js") .map((file) => { const style = basename(file, ".js") + ".css"; const styleFile = resolve(join(".", dirname(file), style)); let styles = null; if (hydrateFiles.includes(".microsite/" + styleFile.split(".microsite/")[1])) { styles = styleFile.split("hydrate/")[1]; } return import(join(process.cwd(), file)).then((mod) => ({ name: basename(file), styles, exports: Object.keys(mod).map((key) => [key, mod[key].name]), })); })); const input = await globby(join(OUTPUT_DIR, "hydrate", "**", "*.js")); const styles = await globby(join(OUTPUT_DIR, "hydrate", "**", "*.css")); if (styles.length > 0) { await mkdir(resolve(`dist/_hydrate/styles`), { recursive: true }); await Promise.all(styles.map((file) => { copyFile(resolve(file), resolve(`dist/_hydrate/styles/${file.split("hydrate")[1]}`)); })); } if (input.length > 0) { const hydrateBundle = await rollup({ treeshake: true, input, external: ["preact", "preact/hooks"], plugins: [ nodeResolve(), multi(), replace({ values: { "import { withHydrate } from 'microsite/hydrate';": "const withHydrate = v => v;", }, delimiters: ["", ""], }), terser(), ], onwarn(warning, handler) { if (warning.code === "UNUSED_EXTERNAL_IMPORT") return; handler(warning); }, }); await hydrateBundle.write({ paths: { preact: "https://unpkg.com/preact@latest?module", "preact/hooks": "https://unpkg.com/preact@latest/hooks/dist/hooks.module.js?module", }, minifyInternalExports: true, dir: resolve("dist/_hydrate/chunks"), entryFileNames: (info) => `${basename(info.name).replace(/([\[\]])/gi, "")}.js`, }); } } catch (e) { console.error(e); } let output = []; try { output = await Promise.all(pages.map((page) => renderPage(page, { styles, hydrateExportManifest, hasGlobalScript, globalStyle, isDebug, }))); } catch (e) { console.error(e); } await Promise.all([ ...output.flat().map(({ name, content }) => mkdir(resolve(`./dist/${dirname(name)}`), { recursive: true, }).then(() => writeFile(resolve(`./dist/${name}`), content))), ]); if (!noClean) await cleanup(); await cache.verify(CACHE_DIR); console.timeEnd("Build"); }