UNPKG

defuss-ssg

Version:

A simple static site generator (SSG) built with defuss.

398 lines (391 loc) 13.3 kB
import chokidar from 'chokidar'; import express from 'express'; import serveStatic from 'serve-static'; import { join, resolve, dirname, sep } from 'node:path'; import { existsSync, statSync, rmdirSync, mkdirSync } from 'node:fs'; import esbuild from 'esbuild'; import rehypeKatex from 'rehype-katex'; import rehypeStringify from 'rehype-stringify'; import remarkFrontmatter from 'remark-frontmatter'; import remarkMath from 'remark-math'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import remarkMdxFrontmatter from 'remark-mdx-frontmatter'; import { t as tailwindPlugin } from './tailwind-DV23JSh-.mjs'; import mdx from '@mdx-js/esbuild'; import glob from 'fast-glob'; import { getBrowserGlobals, getDocument, renderSync, renderToString } from 'defuss/server'; import { cp, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; const remarkPlugins = [ remarkParse, // Parse both YAML and TOML (or omit options to default to YAML) [remarkFrontmatter, ["yaml", "toml"]], // Export each key as an ESM binding: export const title = "…" [remarkMdxFrontmatter, { name: "meta" }], // GitHub Flavored Markdown (tables, task lists, strikethrough, etc.) remarkGfm, remarkRehype, // Convert $…$ and $$…$$ into math nodes for KaTeX remarkMath ]; const rehypePlugins = [ //rehypeMdxTitle, rehypeKatex, rehypeStringify ]; const readConfig = async (projectDir, debug) => { const configPath = join(projectDir, "config.ts"); let config = {}; if (existsSync(configPath)) { if (debug) { console.log(`Using config from ${configPath}`); } const result = await esbuild.build({ entryPoints: [configPath], format: "esm", bundle: true, target: ["esnext"], write: false }); const code = result.outputFiles[0].text; const encoded = Buffer.from(code).toString("base64"); const dataUrl = `data:text/javascript;base64,${encoded}`; const module = await import(dataUrl); config = module.default; } config.pages = config.pages || configDefaults.pages; config.output = config.output || configDefaults.output; config.components = config.components || configDefaults.components; config.assets = config.assets || configDefaults.assets; config.plugins = config.plugins || configDefaults.plugins; config.tmp = config.tmp || configDefaults.tmp; config.remarkPlugins = config.remarkPlugins || configDefaults.remarkPlugins; config.rehypePlugins = config.rehypePlugins || configDefaults.rehypePlugins; return config; }; const configDefaults = { pages: "pages", output: "dist", components: "components", assets: "assets", tmp: ".ssg-temp", plugins: [tailwindPlugin], remarkPlugins, rehypePlugins }; const validateProjectDir = (projectDir) => { try { if (!statSync(projectDir).isDirectory()) { return { code: "MISSING_PROJECT_DIR", message: `Project directory is not a directory: ${projectDir}` }; } } catch (error) { return { code: "INVALID_PROJECT_DIR", message: `Error accessing project directory: ${error.message}` }; } return { code: "OK", message: "Project directory is valid." }; }; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const build = async ({ projectDir, debug = false, mode = "build" }) => { const projectDirStatus = validateProjectDir(projectDir); if (projectDirStatus.code !== "OK") return projectDirStatus; const startTime = performance.now(); const config = await readConfig(projectDir, debug); if (debug) { console.log("PRE config", config); } if (debug) { console.log("Using config:", config); } const inputPagesDir = join(projectDir, config.pages); const inputComponentsDir = join(projectDir, config.components); const inputAssetsDir = join(projectDir, config.assets); const tmpPagesDir = join(config.tmp, config.pages); const tmpComponentsDir = join(config.tmp, config.components); const outputProjectDir = join(projectDir, config.output); const outputPagesDir = join(projectDir, config.output, config.pages); const outputComponentsDir = join( projectDir, config.output, config.components ); const outputAssetsDir = join(projectDir, config.output, config.assets); if (debug) { console.log("Input pages dir:", inputPagesDir); console.log("Input components dir:", inputComponentsDir); console.log("Input assets dir:", inputAssetsDir); console.log("Temp pages dir:", tmpPagesDir); console.log("Temp components dir:", tmpComponentsDir); console.log("Output pages dir:", outputPagesDir); console.log("Output components dir:", outputComponentsDir); console.log("Output assets dir:", outputAssetsDir); } if (!existsSync(inputPagesDir)) { throw new Error(`Input pages directory does not exist: ${inputPagesDir}`); } else if (debug) { console.log(`Input pages directory exists: ${inputPagesDir}`); } if (!existsSync(inputComponentsDir)) { console.warn( `There is no components directory: ${inputComponentsDir}. You may not be able to use any custom components.` ); } if (!existsSync(inputAssetsDir)) { console.warn( `There is no assets directory: ${inputAssetsDir}. You may not be able to serve any custom assets.` ); } for (const plugin of config.plugins || []) { if (plugin.phase === "pre" && (plugin.mode === mode || plugin.mode === "both")) { if (debug) { console.log(`Running pre-plugin: ${plugin.name}`); } await plugin.fn(projectDir, config); } } if (existsSync(config.tmp)) { if (debug) { console.log(`Removing existing temp folder: ${config.tmp}`); } rmdirSync(config.tmp, { recursive: true }); } await cp(projectDir, config.tmp, { recursive: true, filter: (src) => { const relative = src.replace(join(projectDir, ""), ""); if (relative.startsWith(join("assets", "")) || relative.startsWith(join("node_modules", ""))) { return false; } return true; } }); await cp( // in a built situation, __dirname is the dist folder of defuss-ssg resolve(join(__dirname, "components", "index.mjs")), join(tmpComponentsDir, "hydrate.tsx") // any valid JS is always a valid TS file ); await cp( // in a built situation, __dirname is the dist folder of defuss-ssg resolve(join(__dirname, "runtime.mjs")), join(tmpComponentsDir, "runtime.ts") // any valid JS is always a valid TS file ); await esbuild.build({ entryPoints: [join(tmpPagesDir, "**/*.mdx")], format: "esm", bundle: true, sourcemap: true, target: ["esnext"], outdir: tmpPagesDir, plugins: [ mdx({ // using the defuss jsxImportSource so that the output code contains JSX runtime calls // and can be rendered to HTML here on the server (in Node.js). jsxImportSource: "defuss", // We also use any remark/rehype plugins specified in the config file. remarkPlugins: config.remarkPlugins, rehypePlugins: config.rehypePlugins }) ] }); await esbuild.build({ entryPoints: [ join(tmpComponentsDir, "**/*.tsx"), join(tmpComponentsDir, "**/*.ts") ], format: "esm", bundle: true, // making sure we can do code splitting for shared dependencies (e.g. defuss lib) splitting: true, target: ["esnext"], outdir: tmpComponentsDir }); const outputFiles = await glob.async(join(tmpPagesDir, "**/*.js")); if (!existsSync(outputProjectDir)) { mkdirSync(outputProjectDir, { recursive: true }); } for (const outputFile of outputFiles) { const outputHtmlFilePath = outputFile.replace(".js", ".html"); const relativeOutputHtmlFilePath = outputHtmlFilePath.replace( `${tmpPagesDir}${sep}`, "" ); if (debug) { console.log("Processing output file (JS):", outputFile); console.log("Output HTML file path:", outputHtmlFilePath); console.log("Relative output HTML path:", relativeOutputHtmlFilePath); } const code = await readFile(outputFile, "utf-8"); const encoded = Buffer.from(code).toString("base64"); const dataUrl = `data:text/javascript;base64,${encoded}`; const exports = await import(dataUrl); if (debug) { console.log("exports", exports); } let vdom = exports.default(exports); for (const plugin of config.plugins || []) { if (plugin.phase === "page-vdom" && (plugin.mode === mode || plugin.mode === "both")) { if (debug) { console.log(`Running page-vdom plugin: ${plugin.name}`); } vdom = await plugin.fn( vdom, relativeOutputHtmlFilePath, projectDir, config ); } } const browserGlobals = getBrowserGlobals(); const document = getDocument(false, browserGlobals); browserGlobals.document = document; let el = renderSync(vdom, document.documentElement, { browserGlobals }); for (const plugin of config.plugins || []) { if (plugin.phase === "page-dom" && (plugin.mode === mode || plugin.mode === "both")) { if (debug) { console.log(`Running page-dom plugin: ${plugin.name}`); } el = await plugin.fn( el, relativeOutputHtmlFilePath, projectDir, config ); } } let html = renderToString(el); for (const plugin of config.plugins || []) { if (plugin.phase === "page-html" && (plugin.mode === mode || plugin.mode === "both")) { if (debug) { console.log(`Running page-html plugin: ${plugin.name}`); } html = await plugin.fn( html, relativeOutputHtmlFilePath, projectDir, config ); } } if (debug) { console.log("Writing HTML file", outputHtmlFilePath); } if (debug) { console.log("Relative HTML path:", relativeOutputHtmlFilePath); } const finalOutputFile = join( projectDir, config.output, relativeOutputHtmlFilePath ); if (debug) { console.log("Full HTML output path:", finalOutputFile); } const finalOutputDir = dirname(finalOutputFile); if (!existsSync(finalOutputDir)) { mkdirSync(finalOutputDir, { recursive: true }); } await writeFile(finalOutputFile, html); } await cp(tmpComponentsDir, outputComponentsDir, { recursive: true }); await cp(inputAssetsDir, outputAssetsDir, { recursive: true }); for (const plugin of config.plugins || []) { if (plugin.phase === "post" && (plugin.mode === mode || plugin.mode === "both")) { if (debug) { console.log(`Running post-plugin: ${plugin.name}`); } await plugin.fn(projectDir, config); } } if (!debug) { rmdirSync(config.tmp, { recursive: true }); } const endTime = performance.now(); const totalTime = (endTime - startTime) / 1e3; console.log(`Build completed in ${totalTime.toFixed(2)} seconds.`); return { code: "OK", message: "Build completed successfully" }; }; const serve = async ({ projectDir, debug = false }) => { const config = await readConfig(projectDir, debug); const outputDir = join(projectDir, config.output); const pagesDir = join(projectDir, config.pages); const componentsDir = join(projectDir, config.components); const assetsDir = join(projectDir, config.assets); await build({ projectDir, debug, mode: "serve" }); const app = express(); const port = 3e3; app.use(serveStatic(outputDir)); app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); }); let isBuilding = false; let pendingBuild = false; const triggerBuild = async () => { if (isBuilding) { pendingBuild = true; if (debug) { console.log("Build scheduled after current one completes"); } return; } isBuilding = true; try { await build({ projectDir, debug, mode: "serve" }); } catch (error) { console.error("Build failed. Waiting for code change to fix that..."); } finally { isBuilding = false; if (pendingBuild) { pendingBuild = false; if (debug) { console.log("Running pending build"); } await triggerBuild(); } } }; const watcher = chokidar.watch([pagesDir, componentsDir, assetsDir], { ignored: /(^|[\/\\])\../, // Ignore dotfiles persistent: true, ignoreInitial: true // Ignore initial add events }); watcher.on("change", async (path) => { if (debug) { console.log(`File changed: ${path}`); } await triggerBuild(); }); watcher.on("add", async (path) => { if (debug) { console.log(`File added: ${path}`); } await triggerBuild(); }); watcher.on("unlink", async (path) => { if (debug) { console.log(`File removed: ${path}`); } await triggerBuild(); }); return { code: "OK", message: "Server is running" }; }; export { rehypePlugins as a, readConfig as b, configDefaults as c, build as d, remarkPlugins as r, serve as s, validateProjectDir as v };