defineConfig
Define a zfb project configuration with full type inference.
Signature
defineConfig(config: ZfbConfig): ZfbConfigdefineConfig is exported from zfb/config. The helper is identity-typed: it returns its argument unchanged. Its only job is to give your editor IntelliSense and type-checking against the ZfbConfig shape. The actual schema is enforced by Rust serde at config-load time, so the same rules apply whether you author your config in TypeScript or JSON.
Typing the zfb/config import
zfb.config.ts imports defineConfig from the bare specifier zfb/config. At config-load time zfb aliases that import to a runtime-only stub (so your config parses without the @takazudo/zfb package installed), and no real zfb/config module exists in node_modules — the published types live under the scoped subpath @takazudo/. So a plain tsc --noEmit (what zfb check runs) has nothing to type the bare import against and reports Cannot find module 'zfb/config'.
Bridge the two with a one-line ambient declaration that re-exports the scoped package's types. Add a zfb-shim.d.ts at your project root (any .d.ts your tsconfig includes works):
// zfb-shim.d.ts — types the bare `zfb/config` specifier.
declare module "zfb/config" {
export * from "@takazudo/zfb/config";
}Re-export — do not hand-copy the ZfbConfig shape into the shim. A hand-mirrored field list silently lags the engine: every new config field has to be re-added by hand, or zfb check rejects a perfectly valid field with TS2353: Object literal may only specify known properties. export * tracks the published @takazudo/ automatically, so the shim can never fall behind.
Config shape
All keys are camelCase. The shape is enforced by the Rust serde deserializer (crates/) — the TypeScript type in packages/ mirrors it for editor IntelliSense.
outDir?: string— output directory. Default:"dist".publicDir?: string— static assets directory, copied verbatim. Default:"public".host?: string— dev/preview server bind host.port?: number— dev/preview server port.allowedHosts?: string[]— Host header values the dev/preview server accepts when bound to a non-localhost interface (mirrors Vite'sserver.allowedHosts). Only consulted for non-loopback binds;localhost, the explicitly bound host, and any IP-literal Host (127.0.0.1,[::1], the LAN URLs the startup banner prints) are always allowed — DNS rebinding needs a DNS name, so raw IPs are safe. Entries match exactly (case-insensitive, request port stripped); a leading-dot entry like".example.com"also matches subdomains.framework?: "preact" | "react"— JSX framework runtime. Default:"preact".collections?: CollectionDef[]— content collections. Each entry has:name: string— identifier used ingetCollectioncalls.path: string— directory relative to the project root.schema?: Record<string, unknown>— optional JSON Schema for frontmatter validation (enforced byzfb check; the build does not validate frontmatter against it).include?: string[]— glob patterns (globset dialect, relative topath); when set, only matching entries are kept.exclude?: string[]— glob patterns; matching entries are dropped (evaluated afterinclude).idStripSuffix?: string— suffix stripped from each entry's slug and module specifier (e.g.".en"for multi-locale layouts).
tailwind?: { enabled?: boolean }— Tailwind options.prefetch?: { disabled?: boolean }— prefetch options. Whendisabled: true, the runtime's prefetch wiring is skipped entirely via a build-time meta tag.bundle?: { exclude?: string[]; mainFields?: string[]; external?: string[] }— esbuild escape hatches for the--platform=neutralpage/SSR pass.excludelists project-relative globs of source files to keep out of the esbuild graph;mainFieldsoverrides the main-fields list for deps without anexportsmap;externalmarks bare specifiers as external so esbuild leaves them unbundled. Useful for CJS-only deps that fail under the neutral platform (see issues #680/#676).plugins?: PluginConfig[]— user-supplied plugins. Each entry hasname(npm specifier or.-relative path) and optional/ options(an arbitrary JSON object passed to the plugin's hooks). See Plugins for the full hook contract —setup,preBuild,postBuild,devMiddleware— including virtual modules, import aliases, and dev-only injected routes.presets?: Partial<ZfbConfig>[]— config presets to merge before validation. Each preset is a partialZfbConfig-shaped object, typically the return value of a preset package's factory function. Array fields (plugins,collections,extraWatchPaths,allowedHosts) are prepended from presets so the main config's entries retain their position. Scalar fields are filled in by presets only when the main config leaves them at their default — the main config is always authoritative. Nestedpresetsinside a preset are not recursively expanded. Preset authors should usedefinePresetso that relative-path plugins resolve correctly.adapter?: string— deploy-target adapter package name. Omit for a pure static build. A package like"@takazudo/zfb-adapter-cloudflare"wraps the SSR bundle into a deploy-ready entry (e.g.dist/for Cloudflare Pages)._ worker. js output?: "static" | "hybrid" | "auto"— project output mode."static"errors at build start if any route exportsprerender = false;"hybrid"always enables V8/SSR even if no SSR routes currently exist;"auto"(default) detects from the route set.site?: string— canonical origin URL (e.g."https:). When set, exposes/ / example. com" globalThis.__zfb.siteso layouts can build canonical<link>tags, OpenGraph meta, sitemap absolute hrefs, and hreflang alternates. Must be an absolute HTTP/HTTPS URL; omit for builds that do not need server-side canonical URL construction. Distinct frombase— see below.base?: string— public URL prefix for asset URLs. Use when the site is deployed under a sub-path (e.g."/pj/my-site/"). Distinct fromsite:baseprefixes asset URLs;siteis the full canonical origin used in metadata.copyPublicWithBase?: boolean— whetherpublic/assets are copied under thebasesub-path segment (defaulttrue) or flat to thedist/root (false). Setfalsewhen the deploy pipeline relocates the entiredist/tree into the base path (e.g.cp -) to avoid a double-nesteda dist/ . deploy- root/ pj/ site/ dist/<base>/<base>/...path. See Static Assets — flat copy for deploy-relocation pipelines.stripMdExt?: boolean— strip.md/.mdxextensions from internal link hrefs during MDX compilation and append a trailing/. Default:false.trailingSlash?: boolean— append a trailing/to extensionless absolute hrefs when rewriting base paths. Default:false.markdown?: MarkdownConfig— Markdown/MDX parsing options (e.g. GFM toggle). See the Markdown Features section.resolveMarkdownLinks?: ResolveMarkdownLinksConfig— markdown link resolver settings. Enable to rewrite[label](.links to their rendered route URLs. Extensionless (/ other. mdx) .) and directory-style (/ other other/) targets resolve too, probing{name}.mdx→{name}.md→{name}/→index. mdx {name}/. Relative targets resolve from the source file's directory; a directory-style link written from a non-index page against its rendered URL (which sits one directory deeper —index. md .from. / sibling/ section/) resolves via a URL-space fallback when every file-space candidate misses.article. mdx emitRoutesManifest?: boolean— whetherzfb buildwrites the post-build route manifest to<outDir>/. Default:_ _ zfb/ routes. json true(emit). Setfalseto suppress.extraWatchPaths?: string[]— extra absolute filesystem paths the dev watcher follows in addition to the in-project source roots. See Watching paths outside the project root.
The following keys are part of the same ZfbConfig type — defineConfig and zfb check type-check them like any other field. They configure the Rust engine's code-rendering and plugin behaviour:
codeHighlight— syntect syntax-highlight theme options. See the syntax-highlighting guide. Fields:theme?: string— single-theme mode. A syntect built-in or user-loaded theme name (e.g."InspiredGitHub","Solarized (dark)"); defaults to"base16-ocean.dark". Tokens get an inlinecolor:. Mutually exclusive withthemeLight/themeDark. These are syntect theme names, not Shiki names like"dracula".themesDir?: string— directory of.tmThemefiles, relative to the project root. Each file becomes available by its declarednametotheme,themeLight, orthemeDark. Applies to both single- and dual-theme mode. Must be relative and must not escape the root via..; a missing directory errors at build start.themeLight?: string— light-mode syntect theme name for dual-theme highlighting. Must be set together withthemeDark— setting only one is a build error. Mutually exclusive withtheme. When the pair is set, each block is highlighted twice and tokens carry--shiki-light/--shiki-darkcustom properties instead of an inlinecolor:, the<pre>getsclass="syntect-dual"plus--shiki-light-bg/--shiki-dark-bg, and the consumer resolves the active colour with alight-dark()CSS rule.themeDark?: string— dark-mode syntect theme name for dual-theme highlighting. Must be set together withthemeLight; mutually exclusive withtheme. SeethemeLightfor the full dual-mode contract.
pluginHookTimeoutSecs— timeout (seconds) for plugin hook invocations.
Watching paths outside the project root
extraWatchPaths lets zfb dev live-reload when files outside the project tree change — useful when a project reads content from a sibling repo, a file: dep that ships content alongside code, or a shared filesystem directory.
import { defineConfig } from "zfb/config";
export default defineConfig({
extraWatchPaths: [
"/home/me/knowledge-base",
"/srv/shared-content",
],
});Semantics:
Absolute paths only. Each entry must be an absolute path. Relative paths are rejected at config-load with an
extraWatchPaths[N]: ... must be an absolute patherror — the dev watcher registers each entry verbatim, outside the project root, so it has no anchor to resolve a relative path against.Canonicalisation. Each entry is canonicalised (
Path::canonicalize) once whenzfb devboots. Symlinks are resolved; downstream events reach the rebuild logic with the canonical form, so the path the watcher emits matches the form you'd see by runningrealpathon the configured value.Missing-at-boot. If a configured path does not exist at the moment
zfb devstarts, it is skipped with a warning. The watcher does not poll for the path to appear later — if you create the directory after the dev server is already running, restartzfb devto pick it up.Recursive. Each entry is watched recursively. Sub-directories created after boot are picked up automatically by the OS-level recursive watch.
Rebuild scope. Events from these paths fall outside the dependency graph's coverage (the graph only tracks in-tree edges), so they conservatively trigger broader rebuilds than equivalent in-tree edits. The trade-off is intentional: correctness over precision for out-of-root sources.
Security note. Opt-in only — do not point this at unbounded directories like $HOME or /. On Linux the recursive watcher registers every subdirectory and can quickly hit the inotify max_user_watches ceiling (default ~8192 on many distributions) on a large tree. If you need to watch a sprawling source, watch the narrowest sub-tree that contains the files you actually edit.
This is a dev-mode feature. Production builds (zfb build) snapshot the filesystem once and do not rely on watcher events, so extraWatchPaths has no effect on shipped output.
Examples
// zfb.config.ts — the recommended form
import { defineConfig } from "zfb/config";
export default defineConfig({
outDir: "dist",
framework: "preact",
collections: [
{
name: "blog",
path: "content/blog",
},
],
tailwind: { enabled: true },
});The loader accepts zfb.config.ts (preferred) and zfb.config.json (legacy fallback). When only zfb.config.json is present it is read via serde_json; plugin paths declared as ., ., or absolute paths are resolved relative to the config file (npm specifiers like "@takazudo/some-plugin" work in both forms).
// zfb.config.json — legacy form, still supported
{
"outDir": "dist",
"framework": "preact",
"collections": [
{
"name": "blog",
"path": "content/blog"
}
],
"tailwind": { "enabled": true }
}Validation
The loader enforces the following rules and reports errors with the file path plus line:column for JSON parse failures:
Collection names must be unique.
pathcannot be an absolute path.pathcannot contain..segments that escape the project root.