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. Put zfb-shim.d.ts in a directory your tsconfig includes; components/ works with the stock template. If you put the file at the project root instead, add "zfb-shim.d.ts" to include explicitly.
// components/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 used by build and preview; dev treats it as a read-only prebuilt seed rather than a live-output directory. Default:"dist". An explicit--outdiroverrides it for build and preview.publicDir?: string— static assets directory, copied verbatim. Default:"public".host?: string— dev/preview server bind host.port?: number— dev/preview server port. The built-in fallback differs per command when this is omitted:zfb devfalls back to3000,zfb previewfalls back to4321. Settingporthere overrides both commands' fallback; the CLI--portflag in turn overrides this config value. See CLI Reference.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).allowOutsideRoot?: boolean— opt in to apaththat escapes the project root via..(e.g. a monorepo-shared content directory living outside this package). Defaultfalsekeepspathconfined to the project root. Absolute paths are rejected regardless of this flag — only..-relative escapes are relaxed. See Collections outside the project root for the monorepo use case and the preset-trust and dev-watching caveats.
tailwind?: { enabled?: boolean }— Tailwind options.enableddefaults totrue; settailwind: { enabled: false }to disable Tailwind while keeping authored CSS and CSS Modules.prefetch?: { disabled?: boolean }— prefetch options. Whendisabled: true, the runtime's prefetch wiring is skipped entirely via a build-time meta tag.minifyHtml?: boolean— opt in to production HTML minification. Default:false(off). The minifier runs as Rust-side post-processing and does not spawn a Node.js minifier subprocess. The first version is conservative: rendered HTML pages are candidates, source.htmlpassthrough pages remain verbatim, and non-HTML outputs are skipped.zfb build --minify-html/--no-minify-htmlcan override the config value for one build.strictBrokenLinks?: boolean— raise broken-link diagnostics from markdown link validation to errors, failingzfb buildwith a non-zero exit instead of merely warning. Default:false(off, and the key may be omitted entirely). Whenmarkdown.features.linkValidationis absent, setting this totrueforce-enables link validation with its defaults rather than doing nothing; when it is present, only itsfailOnBrokenis overridden and every other setting is preserved.zfb build --strict-broken/--no-strict-brokencan override the config value for one build. Build-only —zfb devis never affected. Scope is thelinkValidationmechanism only; the separateresolveMarkdownLinks.onBrokenLinksknob below is unaffected. See Link validation.bundle?: BundleConfig— esbuild settings shared by the page/SSR, islands, client-script, and module-worker passes.exclude,mainFields, andexternalare escape hatches for the--platform=neutralpage/SSR pass;loadersanddefineapply across all four passes. See Bundle settings.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,previewMiddleware— 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 and object fields are key-presence-based: a key the main config provides wins even when its value equals the built-in default, while an omitted key is filled from the first preset that supplies it. Explicitnullblocks the preset value. 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 Workers Static Assets, Pages-compatible)._ 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. Fields:gfm?: GfmFlag— GFM construct toggles (strikethrough, table, autolinks, and more). See GFM.toc?: { heading?: string; maxDepth?: number }— inserts a table of contents after the first heading matchingheading(default"TOC"), up tomaxDepthlevels (default2). Wires the same underlying plugin and config shape asmarkdown.features.headingMarkerToc, documented at Heading-marker TOC.externalLinks?: { target?: string; rel?: string[] }— annotates external<a>elements withtarget/rel. See External links.cjkFriendly?: boolean— CJK-friendly emphasis and autolink-boundary handling. Defaulttrue(on). See CJK-friendly emphasis.hardBreaks?: boolean— convert soft line breaks into<br>. Defaultfalse(off). See Hard breaks.features?: MarkdownFeaturesConfig— per-feature opt-in toggles (mermaid, directives, code enrichment, reading time, and more). See the Markdown Features feature map.
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. Fields:article. mdx enabled?: boolean— must be set totruefor anything to happen. Defaultfalse: the resolver does nothing and links pass through unchanged.docsDir?: string— legacy single-directory form. Scanned against a hard-coded/route prefix. Ignored oncedocs/ dirsis non-empty.dirs?: { dir: string; routePrefix: string }[]— explicit per-directory source map (e.g. EN docs atsrc/→content/ docs/ /, JA docs atdocs/ src/→content/ docs- ja/ /). Takes precedence overja/ docs/ docsDirand is required for any project with more than one docs root (locale mirrors).onBrokenLinks?: "warn" | "error" | "ignore"— what to do when a.md/.mdxlink cannot be resolved. Default"warn"(emits a warning, build continues).
See Resolve links and the Markdown Features feature map for the full behavior.
emitRoutesManifest?: boolean— whetherzfb buildwrites the post-build route manifest to<outDir>/. Default:_ _ zfb/ routes. json true(emit). Setfalseto suppress.emitRenderArtifacts?: boolean— whetherzfb buildwrites a JSON render artifact — the content-region HTML as shipped, compiler-allocated headings, a contract version, and a raw-source digest — for every markdown/MDX-backed HTML route whose page renders exactly one top-level content region, under<outDir>/__zfb/render/. Default:false(off — unlikeemitRoutesManifest's default-on posture).zfb build --emit-render-artifacts/--no-emit-render-artifactscan override the config value for one build; CLI beats config beats the default. Build-only —zfb devnever writes render artifacts. See Render artifacts for the full JSON contract.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.watchPollFallback?: boolean— makezfb devwatch the filesystem by polling instead of by OS-native change notifications (FSEvents on macOS, inotify on Linux, …). Default:false(native). Turn it on for hosts where the native backend never delivers — network-mounted project directories, some CI/sandboxed containers, a stalledfseventsd. See Poll-based file watching.watchPollIntervalMs?: number— re-scan interval, in milliseconds, for the poll backend. Default:500. Must be between50and10000inclusive; anything outside that range is a config-load error, and a value below100is accepted with a warning. Only takes effect whenwatchPollFallbackistrue— setting it alone is accepted but dormant, with a warning rather than an error. See Poll-based file watching.
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.mode?: "inline" | "class"— token output mode."inline"(default) bakes per-token colours intostyle="color:…"(or the dual--shiki-*properties)."class"emits a semantic role class per token instead, so colours resolve through re-themeable CSS custom properties. Class mode is mutually exclusive with every theme knob (theme,themeLight,themeDark,themesDir) — themes do not affect class emission, so setting one alongsidemode: "class"is a build error. See the guide's Class mode section.classPrefix?: string— class-name prefix for class mode (default"hi-", yieldinghi-root,hi-kw, …). Must match/(first char an ASCII letter; rest letters, digits,^[A- Za- z][A- Za- z0- 9_ - ]*$/ _, or-); an empty, leading-digit, or otherwise non-matching prefix is a build error. Only meaningful in class mode. The shipped default stylesheet's.hi-*selectors are rewritten to the configured prefix; the--zfb-hi-*custom properties stay--zfb-hi-*.roleClasses?: Partial<Record<Role, string>>— per-role class overrides for class mode, e.g.{ keyword: "text-violet-600 dark:text-violet-400" }. Keys must be one of the 18 fixed role names (escape,operator,comment,string,number,constant,keyword,function,type,namespace,property,variable,tag,attribute,punctuation,inserted,deleted,heading) — an unknown key is a build error. A value may hold several space-separated classes but must not contain the bare tokenline(collides with the code-enrichment line wrapper). Mapped Tailwind utilities are auto-safelisted via@source inline; settingroleClasseswhiletailwind.enabledisfalseis allowed but emits a build warning (no safelist can be generated on the authored-CSS path).defaultStylesheet?: boolean— whether to inject the built-in--zfb-hi-*token stylesheet (zfb-hi.css) into the combinedstyles.css. Defaulttrue. Only meaningful in class mode; setfalseto supply your own stylesheet entirely.
pluginHookTimeoutSecs— timeout (seconds) for plugin hook invocations.
Bundle settings
The browser-facing islands, client-script, and module-worker passes use the same mode, loader, and define settings as the page/SSR pass. This keeps code that is shared between server and browser graphs from changing meaning as it crosses a bundle boundary.
Bundle mode
zfb owns three compile-time substitutions. They are selected by the command, not by minification:
| Command | import.meta.env.DEV | import.meta.env.PROD | process.env.NODE_ENV |
|---|---|---|---|
zfb dev | true | false | "development" |
zfb build | false | true | "production" |
These values apply to islands, client scripts, their module workers, and the page/SSR bundle. zfb does not synthesize other import.meta.env.* keys. The three keys in the table are reserved and cannot be overridden through bundle.define.
bundle.exclude, bundle.mainFields, bundle.external
The page/SSR pass bundles with esbuild's --platform=neutral, which is deliberately minimal: its main-fields resolution list is empty by default, and it rejects packages that resolve only through a CJS-only require() path. These three fields are escape hatches for CJS-only dependencies that reach the neutral pass — either directly imported or newly pulled in by an eager import.meta.glob(...) expansion. All three are absent/empty by default, which is byte-identical to a build without this knob.
bundle.exclude — project-relative glob patterns (gitignore-style, matched in POSIX form against the path relative to the project root) for source files the bundler must never pull into the esbuild graph. A matched file is neither staged into the shadow tree nor expanded by an eager import.meta.glob(...). Unrelated to CollectionDef.exclude (content filtering, not bundling).
import { defineConfig } from "zfb/config";
export default defineConfig({
bundle: {
exclude: ["components/**/*.stories.tsx"],
},
});Before: a route's import. picks up Button.stories.tsx, which imports a CJS-only test-mocking package, and the neutral-platform build fails to resolve it. After: the glob skips *.stories.tsx files entirely and the build succeeds.
bundle.mainFields — an explicit esbuild --main-fields list for the neutral pass. Setting e.g. ["main", "module"] lets a dependency that ships only main/module (no exports map) resolve, since neutral otherwise consults no main-fields at all.
import { defineConfig } from "zfb/config";
export default defineConfig({
bundle: {
mainFields: ["main", "module"],
},
});Before: The "main" field here was ignored. Main fields must be configured
explicitly when using the "neutral" platform. After: esbuild consults main then module and resolves the dependency.
bundle.external — bare specifiers to mark --external for the neutral pass, so esbuild leaves them unbundled instead of trying to resolve them. The other escape hatch for a CJS-only dependency that mainFields alone can't satisfy. Appended to the framework-provided externals.
import { defineConfig } from "zfb/config";
export default defineConfig({
bundle: {
external: ["some-cjs-only-package"],
},
});Before: esbuild tries to resolve some-cjs-only-package under --platform=neutral and fails. After: esbuild marks the bare import external instead of resolving it.
bundle.loaders
Use loaders to assign an additional esbuild loader to a file extension:
import { defineConfig } from "zfb/config";
export default defineConfig({
bundle: {
loaders: {
".fixture": "text",
".data": "binary",
},
},
});Keys must start with .. The inline-only v1 values are text, json, base64, dataurl, binary, and empty. The file and copy loaders are rejected because they emit sibling assets that these bundle pipelines do not publish. .css, .module.css, .mdx, and .md are reserved by zfb and cannot be overridden.
?raw imports do not use this map. import text from ". always loads valid UTF-8 text, regardless of the extension or its configured loader.
bundle.define
define values are raw esbuild replacement expressions, not automatically quoted strings:
import { defineConfig } from "zfb/config";
export default defineConfig({
bundle: {
define: {
__APP_NAME__: '"my-app"', // the replacement includes JSON quotes
__FEATURE_ENABLED__: "true",
__BUILD_META__: '{"channel":"preview"}',
},
},
});Without the inner JSON quotes, a value is parsed as a raw expression (and may be rejected) rather than as a string literal. Values are forwarded verbatim and are not filtered by the PUBLIC_ environment-variable policy.
Treat every bundle.define value as public code
bundle.define is trusted operator-authored build input, not a secret store or an input-sanitization boundary. Every configured definition is available to browser bundles. esbuild may remove a definition that no reachable code uses, but tree-shaking is not a secrecy guarantee. Never put credentials, private tokens, or untrusted expressions in this map.
TypeScript ambient declarations
zfb supplies the mode values, loader results, raw modules, and define replacements while bundling, but it does not generate TypeScript declarations for them. zfb check runs tsc --noEmit, so TypeScript source that uses these features needs matching ambient declarations in a .d.ts file included by your tsconfig. For example, the .fixture text loader and the three bundle.define entries above can be typed with components/, which the stock template's tsconfig already includes:
// components/zfb-env.d.ts
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "*?raw" {
const source: string;
export default source;
}
declare module "*.fixture" {
const contents: string;
export default contents;
}
declare const __APP_NAME__: string;
declare const __FEATURE_ENABLED__: boolean;
declare const __BUILD_META__: { channel: string };The filename and directory are not special; the file only has to be covered by tsconfig.json's include. If you prefer a project-root zfb-env.d.ts, add that path to include explicitly. A declaration file outside include is invisible to tsc.
These declarations are type-only. They do not configure esbuild, inject runtime values, or enable an import that zfb does not support. Keep custom module patterns and global types aligned with bundle.loaders and bundle.define; otherwise zfb check or tsc can accept the wrong runtime assumption or reject code that zfb can bundle. The *?raw declaration can always return string because that result is part of zfb's fixed raw-import contract.
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.
Watching out-of-root files is not the same as importing them
extraWatchPaths only makes the dev watcher notice changes outside the project root — it does not make those files importable. zfb builds by shadow-copying the project root into a temporary directory before running esbuild; a relative import that walks outside the project root (e.g. .) resolves outside that shadow copy, and the build fails with a Could not resolve "..." error. The error message names the real (non-shadow) path of the importing file and explains this shadow-copy boundary.
Workaround: expose the out-of-root target as a package import instead of a relative one. Add a wildcard exports entry to the target package's package.json (e.g. "./src/*": "./src/*") and import it by package specifier (e.g. @scope/) — node_modules (including file:/workspace-linked packages) IS included in the shadow copy, so package-specifier imports resolve normally. See issue #1385 for the full context; this is diagnose-only for now — the escape itself stays unsupported.
Poll-based file watching
zfb dev normally learns about edits from the operating system's own filesystem-change notifications — FSEvents on macOS, inotify on Linux, and the platform equivalent elsewhere. That is the fastest path and the default. On some hosts it never delivers: a project directory on a network mount, certain CI or sandboxed containers, or a macOS fseventsd that has stalled. Hot-reload then looks dead even though zfb dev is running fine.
watchPollFallback: true swaps that native backend for a polling one, which re-scans the watched roots on a fixed interval instead of waiting to be told about changes.
import { defineConfig } from "zfb/config";
export default defineConfig({
watchPollFallback: true,
watchPollIntervalMs: 500,
});When to reach for it
Do not enable it pre-emptively — polling costs CPU that native notifications do not, and it is slower to react. Turn it on when hot-reload is actually broken on a given machine.
The dev server tells you when that has happened. At boot it runs a watcher-liveness self-check: it makes its own filesystem change and waits to observe it. If nothing arrives within the deadline, zfb dev prints a loud hot-reload looks dead on this machine warning, lists the common causes (a stalled fseventsd, a Dropbox/OneDrive/iCloud-synced project directory intercepting events, antivirus/EDR software hooking filesystem calls), and names watchPollFallback: true as the remedy to try. That warning is the clearest signal to set this flag.
Its absence is weaker evidence. The self-check writes its markers into a scratch directory deliberately kept clear of the roots the dev server really watches — an overlap would make the probe's own writes look like project changes and trigger a rebuild storm on every check. So it measures whether the backend delivers events for that one location, not for every watched path. A failure confined to a single watched path, such as an extraWatchPaths entry on a network mount that stopped delivering, can leave hot-reload broken while the self-check still passes. If edits to a particular directory never trigger a rebuild and no warning appeared, the poll backend is still worth trying.
If the poll backend is already enabled and the same warning still appears, it says so and suggests a different remedy instead — raise watchPollIntervalMs, or check whether the watched directories have become unreachable from the process (a network mount that dropped, for instance). Enabling a fallback that is already on would not help.
The interval
Default
500ms whenwatchPollIntervalMsis omitted.Valid range
50–10000ms, inclusive. Anything outside it fails at config load — too low busy-loops the polling thread, too high makes hot-reload feel broken.Below
100ms is accepted with a warning. A re-scan that frequent can add noticeable CPU cost on a large project tree.Dormant without the flag. Setting
watchPollIntervalMswhilewatchPollFallbackisfalseis accepted, warns, and has no effect until the fallback is enabled. It is deliberately not an error, so a preset can pre-stage an interval for projects that may later opt in.
Behaviour differences from the native backend
Everything downstream of event delivery is shared between the two backends — change classification, debouncing, and dynamic watch registration all behave identically. What differs is how changes are noticed in the first place:
Latency is roughly one poll interval (plus the debounce window), instead of milliseconds after the OS event. At the 500ms default, expect edits to take up to about half a second longer to trigger a rebuild.
A file created and deleted within a single interval produces no event at all. The next scan never sees the path, so neither the creation nor the deletion is reported.
Modification detection is mtime-based at whole-second granularity. File contents are not compared. An overwrite that leaves the file's modification time in the same second goes unnoticed until a later change bumps the mtime.
Each child of a newly created directory arrives as its own creation event. This is a deliberate divergence, and an improvement: the native backend can report a created directory whose children never surface as individual events.
This flag affects zfb dev only. Production builds snapshot the filesystem once and never rely on watcher events, so neither key changes 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 — unless that collection setsallowOutsideRoot: true, which relaxes this one check (absolute paths are still rejected).bundle.loaderskeys must start with., use an inline loader, and not override an extension reserved by zfb.bundle.definecannot override the mode-ownedDEV,PROD, orNODE_ENVsubstitutions.watchPollIntervalMsmust be between50and10000(inclusive). A value below100, and a value set withoutwatchPollFallback: true, are both accepted with a warning rather than rejected.