defuss-ssg
Version:
A simple static site generator (SSG) built with defuss.
398 lines (391 loc) • 13.3 kB
JavaScript
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 };