defuss-ssg
v0.7.5
Published
A simple static site generator (SSG) built with and for defuss.
Readme
defuss-ssg
Static site generation, request-time dev SSR, file-based endpoints, and production serving for defuss.
defuss-ssg is both a CLI and a library:
devstarts a Vite server and renders MD/MDX pages on demand through SSR.buildrenders static HTML, bundles client components, compiles endpoints, and copies assets.serveserves built output withdefuss-express, plus dynamic endpoints and optional RPC.
Use Bun for package management. The published package and the container runtime both target Node ^20.19.0 || >=22.12.0.
What It Supports
- Markdown and MDX pages from
pages/orsrc/pages/ - YAML or TOML frontmatter exposed as
meta - GitHub Flavored Markdown via
remark-gfm - KaTeX math via
$...$and$$...$$ - defuss components imported into MDX and HTML-like pages
- Automatic hydration boundaries for components rendered from
components/,src/components/,csr/, orsrc/csr/ - Static assets copied from
assets/orsrc/assets/ - Vite-style
public/assets copied to the output root and served at/<file> - File-based API routes from
pages/**/*.tsandpages/**/*.js - Root
index.mdx,index.md, orindex.htmlfallback when no pages directory exists - Pre-rendered endpoints via
prerender = trueandgetStaticPaths() - RPC auto-discovery from
rpc.tsorrpc.jswhendefuss-rpcis installed - Plugin hooks for
pre,page-vdom,page-dom,page-html, andpost - Multicore production serving through
--multicoreorworkers: "auto"
Install
Run directly:
bunx defuss-ssg build ./my-siteOr add it as a dev dependency:
bun add -D defuss-ssgQuick Start
my-site/
├-- pages/
| ├-- index.mdx
| └-- api/
| └-- ping.json.ts
├-- components/
| └-- button.tsx
├-- assets/
| └-- styles.css
├-- config.ts
└-- rpc.tsMinimal config:
import { rehypePlugins, remarkPlugins, type SsgConfig } from "defuss-ssg";
// all of the fields are optional; you can also skip the file itself!
const config: SsgConfig = {
pages: "pages",
output: "dist",
components: "components",
assets: "assets",
tmp: ".ssg-temp",
plugins: [],
remarkPlugins: [...remarkPlugins],
rehypePlugins: [...rehypePlugins],
rpc: true,
// e.g. set defuss-ssg to use podman even if docker is available
containerRuntime: "docker",
// override Vite config
viteConfig: {
...
},
} satisfies SsgConfig;
export default config;containerRuntime forces docker or podman for docker-* commands. viteConfig is merged into defuss-ssg's internal Vite config for both dev and build, so you can add aliases, plugins, server options, and similar Vite settings from config.ts.
If you need to extend the current defuss-ssg Vite config instead of only passing a patch object, import it from the virtual module and merge from there:
import { mergeConfig } from "vite";
import { viteConfig as currentViteConfig } from "virtual:defuss-ssg/config";
const config: SsgConfig = {
viteConfig: mergeConfig(currentViteConfig, {
resolve: {
alias: {
"@app": new URL("./src", import.meta.url).pathname,
},
},
server: {
// your custom domain, headers etc.
allowedHosts: ["example-ssg.demo.defuss.tech"],
},
}),
};
export default config;The virtual module resolves to the current internal defuss-ssg Vite config, so you can also inspect or extend existing plugin arrays and nested Vite settings without re-creating them manually.
Example page:
---
title: Home
---
import { Button } from "../components/button.js";
# {meta.title}
This page uses MDX, frontmatter, and a defuss component.
<Button label="Click me" />Example component:
import type { Props } from "defuss";
export interface ButtonProps extends Props {
label: string;
}
export function Button({ label }: ButtonProps) {
return (
<button type="button">{label}</button>
);
}Build the site:
defuss-ssg build ./my-siteStart Vite-powered development:
defuss-ssg dev ./my-siteServe the already built output:
defuss-ssg serve ./my-siteserve expects existing build output in dist/, so run build first.
For checked-out, unpublished, or locally linked package development use e.g.:
node ./dist/cli.mjs dev ../../examples/with-dson/ --port 3010Production Deployment
defuss-ssg supports podman and docker for production deployment.
The primary container workflow is built into the CLI:
bunx defuss-ssg container-dev ./my-site
bunx defuss-ssg container-build ./my-site
bunx defuss-ssg container-serve ./my-site --multicoreWhen you are working from this monorepo before the next npm publish, use the built local CLI instead of bunx defuss-ssg. bunx defuss-ssg resolves the last published package, so it will not see unreleased docker-* commands from this checkout:
cd packages/ssg
bun run build
node ./dist/cli.mjs container-dev ../../example-ssg --port 3111 --container-args --network hostEach docker-* command writes a temporary Dockerfile, builds a local defuss-ssg image, mounts your project at /workspace, mounts a deterministic container-managed /workspace/node_modules volume, and then runs the matching inner defuss-ssg command. The mounted project still owns its dependency graph: the container reads that project's package.json, uses its declared package manager, installs the project's dependencies, and then starts dev, build, or serve.
container-dev and container-serve automatically bind the inner service to 0.0.0.0 and publish the selected port. One published port is enough even with --multicore; defuss-express keeps worker ports internal and load-balances behind the public port.
Runtime selection:
dockeris preferred automatically when availablepodmanis used automatically when Docker is unavailable- Set
containerRuntimeinconfig.tsto force one runtime explicitly
export default {
containerRuntime: "podman",
};Outer container runtime arguments can be forwarded with --container-args. Everything after that marker is appended to the outer docker run or podman run call, while the arguments before it still go to the inner defuss-ssg command:
bunx defuss-ssg container-dev ./my-site --port 3000 --container-args --network host
bunx defuss-ssg container-serve ./my-site --multicore --container-args --env NODE_ENV=productionGood to know:
- The container runs the same
setup()flow as local CLI usage. - Use the generated container-managed
/workspace/node_modulesvolume instead of bind-mounting hostnode_modulesacross OS or architecture boundaries. - For manual container management, a static, minimal Dockerfile is available as
Dockerfiletoo.
How It Works
Dev Mode
defuss-ssg dev starts a Vite server rooted at your project. Requests for MD and MDX pages are resolved through Vite's transform pipeline and rendered on demand with SSR. Page, component, endpoint, RPC, config, and asset changes are coalesced before reload. CSS assets are hot-swapped when possible, and hydration boundaries restore local form and scroll state across component updates.
By default, the CLI keeps dist/ refreshed during dev as a compatibility fallback for middleware paths. Programmatic users can disable that bridge with writeDevOutput: false.
Build Mode
defuss-ssg build loads config.ts, copies the project into .ssg-temp, renders each page through a temporary Vite SSR server, applies automatic hydration wrapping, bundles client components, compiles endpoints into .endpoints, copies assets into dist, and removes the temp directory unless debug mode is enabled.
Serve Mode
defuss-ssg serve reads the built output from dist/ and serves it with defuss-express. Dynamic endpoint modules are registered at runtime, and rpc.ts or rpc.js is compiled and initialized automatically when RPC is enabled and defuss-rpc is installed.
Content Discovery
For generation-time listings such as blog archives, defuss-ssg now exposes a small glob() API.
Use the virtual module inside MDX or other code that Vite evaluates for SSR:
import { glob } from "virtual:defuss-ssg/content";
export const posts = (await glob("pages/blog/**/*.mdx")).sort((left, right) =>
String(right.meta.date || "").localeCompare(String(left.meta.date || "")),
);Use the direct package export in non-Vite server-side contexts such as config.ts or custom plugins:
import { glob } from "defuss-ssg";
const posts = await glob("pages/blog/**/*.mdx", {
cwd: process.cwd(),
});Each entry contains:
filePath: absolute file pathrelativePath: path relative tocwdslug: route-like identifier without a leading slashroute: public route when the file lives under the configuredpagesdirectorymeta: parsed YAML or TOML frontmatter
Notes:
cwddefaults to the current working directory for the direct helper.- The virtual module binds
cwdandpagesto the active SSG project automatically. routeis only derived for files inside the configuredpagesdirectory.- Returns metadata records only; it does not eagerly execute every matched MDX module.
Endpoints
Endpoint source files live under pages/ and export HTTP method handlers.
import type { APIRoute } from "defuss-ssg";
export const GET: APIRoute = async () => {
return Response.json({ ok: true, ts: Date.now() });
};Supported method exports are GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, and ALL.
Dynamic routes use bracket syntax:
pages/api/[id].json.ts -> /api/:id.json
pages/feed.xml.ts -> /feed.xmlTo pre-render an endpoint during build, export prerender = true. Dynamic routes can also export getStaticPaths().
import type { APIRoute } from "defuss-ssg";
export const prerender = true;
export const getStaticPaths = () => [
{ params: { slug: "hello-world" } },
{ params: { slug: "release-notes" } },
];
export const GET: APIRoute = async ({ params }) => {
return new Response(`Post: ${params.slug}`);
};RPC
RPC is optional and discovered automatically from rpc.ts or rpc.js in the project root. Install defuss-rpc to enable it.
export default {
mathApi: {
add: async (a: number, b: number) => a + b,
},
greetApi: {
hello: async (name: string) => `Hello, ${name}!`,
},
};When RPC is active, defuss-ssg exposes:
POST /rpcPOST /rpc/schema
Set rpc: false in config.ts to disable RPC auto-discovery.
To use the RPC in your client components, import the generated client helper:
import { createRpcClient } from "defuss-ssg/rpc";
const rpc = createRpcClient();
const sum = await rpc.mathApi.add(2, 3);
console.log(sum); // 5Custom MDX plugins
defuss-ssg plugins run in build order and can modify the pipeline at distinct phases.
import type { SsgPlugin } from "defuss-ssg";
const htmlStampPlugin: SsgPlugin = {
name: "html-stamp",
phase: "page-html",
mode: "both",
fn: (html, relativeOutputHtmlFilePath) => {
return html.replace(
"</body>",
`<!-- built:${relativeOutputHtmlFilePath} --></body>`,
);
},
};
export default {
plugins: [htmlStampPlugin],
};Available phases:
pre: before a full build startspage-vdom: after page VDOM creation and before renderpage-dom: after DOM render and before serializationpage-html: after HTML serialization and before writepost: after the build completes
page-vdom hooks receive the page props/module exports as their fifth argument.
Programmatic API
import { build, dev, serve, setup } from "defuss-ssg";
const projectDir = "./my-site";
const setupStatus = await setup(projectDir);
if (setupStatus.code !== "OK") {
throw new Error(setupStatus.message);
}
await build({
projectDir,
mode: "build",
debug: true,
});
await dev({
projectDir,
port: 3000,
host: true,
writeDevOutput: true,
});
await serve({
projectDir,
port: 3000,
workers: "auto",
});The main package exports build, dev, serve, setup, config defaults, endpoint types, RPC helpers, and plugin types.
Advanced subpath exports:
defuss-ssg/vite: exposesdefussSsg()for custom Vite integrationdefuss-ssg/runtime: exposes the client runtime used for navigation, hydration, and live reload
Most projects only need the main package export.
CLI Reference
defuss-ssg [dev|build|serve|container-dev|container-build|container-serve] [folder] [options]
No args -> dev .
Single path -> dev <path>
Single command -> <command> .
Command + folder -> <command> <folder>When pages, components, or assets are not configured explicitly, defuss-ssg prefers src/pages, src/components, src/csr, and src/assets before falling back to their project-root equivalents. If no pages directory exists, it falls back to root index.mdx, then index.md, then index.html. A project-root public/ directory is treated like Vite/Astro public: its files are available at the site root.
Commands:
dev: starts the Vite dev server on port3000by defaultbuild: generates the static site intodist/serve: serves the built output fromdist/container-dev: builds the generated image, mounts the project, and startsdevinside Docker or Podmancontainer-build: builds the generated image, mounts the project, and runsbuildinside Docker or Podmancontainer-serve: builds the generated image, mounts the project, and runsserveinside Docker or Podman
Flags:
--debugor-d: enable verbose logging--multicore: useworkers: "auto"forserve--hostor-H <host>: override the dev or serve bind host--portor-p <port>: override the dev or serve port--skip-setupor--no-setup: skip project dependency installation for prepared environments and containers--container-args <args...>: forward everything after the marker to the outerdocker runorpodman runinvocation used bydocker-*
Local Package Development
To work on defuss-ssg inside this monorepo:
git clone https://github.com/kyr0/defuss.git
cd defuss/packages/ssg
bun install
bun run build
bun run cli-devThe example project used by the package scripts lives in ../../example-ssg/.
Benchmarking
The benchmark scripts are for local experiments, not committed performance guarantees.
bun run bench
bun run bench:rpcBenchmark result snapshots are written to .tmp/bench-results.json by default. Override that path with RESULTS_FILE=/path/to/file.json if needed.
For local load-balancing experiments, use scripts/lb.ts directly.
