Every module in fcc, with a grounded example. fcc is procedural TypeScript on Bun: one package, a flat src/, every function is fn(ctx, config, opts), no classes. Plugins are step descriptors { hook, fn, ...config }; loaders sit on cfg.sources[].loader; the site renderer is seven flat-ns namespaces. See architecture.md for the model and ../CLAUDE.md for conventions.
The core build engine: a one-shot/incremental runner over a persistent BuildState, a defineConfig helper, and the PluginContext (ctx) every plugin step and loader receives.
build({ projectRoot, configPath, config, targetName? }) creates fresh state and runs a full build; runBuild(state, filter?) re-runs all targets over existing state; runIncremental(state, changedFiles, filter?) rebuilds only the changed resources' transitive closure. All return a BuildResult ({ diagnostics, bundles, ok, state, durationMs }). defineConfig(c) is an identity helper for type-checking a Config. A plugin is one Step or Step[] — { hook, fn, ...config } — where fn(ctx, config, opts) runs at its hook stage with config as inline static data and opts as the per-call payload ({ resource } / { bundle } / { hot } / {}). A Loader ({ name, extensions, load(ctx, {file}), invalidate? }) lives on cfg.sources[].loader, not as a step.
import { defineConfig } from "fcc";
export default defineConfig({
id: "hl7.fhir.us.core", canonical: "http://hl7.org/fhir/us/core", version: "9.0.0",
targets: [{ name: "r4", fhir: "4.0.1", out: "dist/r4", plugins: [/* generators */] }],
sources: [{ dir: "input/resources", loader: json() }],
plugins: [snapshot({ packagesDir }), validator({ validators: [...] })],
});- Hook stages run in this order:
buildStarttransformbeforeSnapshotafterSnapshotbeforeValidateafterValidategenerateBundlewriteBundlebuildEndcloseBundle, plus dev-onlyhandleHotUpdateandwatchPaths. ctxsurface: indexesresources/byType.<RT>/canonicals.<RT>/byCanonical,issues, cross-pluginshared, renderstate, fn registryfns; queriesquery(type, where?)/byUrl/byId/sql(query, params?); outputsemitResource(r)(returns id) /emitFile(f); diagnosticswarn(d)anderror(d)(the latter throws —never).ctx.sql(query, params?)— ad-hoc SQL over the graph, backed by a lazily-built in-memory SQLite index (resourcestable:id,resourceType,rid,url,version,example, and ajsoncolumn forjson_extract). Built on first call, cached until a resource is added/dropped; returns plain rows. Also exposed in the dev REPL assql(...):bun src/bin/repl.ts "sql(\"SELECT resourceType, count(*) n FROM resources GROUP BY resourceType ORDER BY n DESC\")" bun src/bin/repl.ts "sql(\"SELECT id, json_extract(json,'\$.status') s FROM resources WHERE resourceType=?\", ['Observation'])"
Pure, ctx-free byte/model helpers exported from fcc that emitter plugins (npm, sqlite) import directly — the one project import the flat-ns rule permits.
zip(entries, { store? }) is a hand-rolled PKZIP writer (raw Bun.deflateSync method 8, or store for method 0) using Bun.hash.crc32 and a fixed 1980-01-01 timestamp for byte-reproducible archives. indexEntry(resource) builds one FHIR .index.json row (filename/resourceType/id plus any present scalar from INDEX_FIELDS: url, version, kind, type, supplements, content, valueSet, derivation). packageEntries(bundle) is the shared resource selection — every non-Page resource flagged example (via __wasExample) plus the synthesized placeholder ImplementationGuide — so .index.json and .index.db never drift. These are plain functions, not plugin steps; the emitter plugin that calls them runs at its own hook (e.g. writeBundle).
import { zip, indexEntry, packageEntries } from "fcc";
const items = packageEntries(bundle); // { resource, example }[]
const index = { "index-version": 2, files: items.map(i => indexEntry(i.resource)) };
const bytes = zip([{ name: ".index.json", bytes: enc.encode(JSON.stringify(index)) }]);zip()is single-disk / no ZIP64: it throws past 4 GB sizes/offsets or > 65535 entries rather than emit a corrupt archive.indexEntryfilename is${resourceType}-${id}.jsonwhereidisdata.id, falling back to the graph-id tail; non-scalar fields (objects/arrays/null) are omitted, booleans/numbers stringified.
fcc dev serves the site by lazily rendering one page on demand from the in-memory BuildState and pushes SSE live-reload after every rebuild; a sibling HTTP REPL lets you eval JS against the live state.
These are engine helpers (src/engine/devServer.ts, src/engine/repl.ts), not plugins — the fcc dev command starts both. startDevServer runs Bun.serve (port SITE_PORT, default 4321): for any path it calls state.byTarget.get(target).shared.site.render(path) (published by the site plugin each incremental build — no dist/ writes), and injects an EventSource("/__fcc/events") client into HTML so broadcastReload() triggers location.reload(). startRepl serves POST /repl, writing its chosen free port to <projectRoot>/.fcc/repl-port; it runs your code as an AsyncFunction with state, cfg (state.cfg), T(name?) (target by name, default first), and cdp in scope, returning a JSON-safe (safeJson) result. The CLI client src/bin/repl.ts walks up for .fcc/repl-port and POSTs.
// from the dev server: lazy renderer the site plugin publishes
const render = state.byTarget.get(targetName)?.shared?.site?.render;
const out = render && await render(url.pathname); // { contentType, body } | nullbun src/bin/repl.ts 'T().resources.size' # expression
bun src/bin/repl.ts 'await cdp.screenshot({ session: "fcc", path: "/tmp/x.png" })'- A one-liner is auto-wrapped as
return (code); code containingreturn/throw(or that fails to parse wrapped) runs as a statement body. - Renderer returning
null→ 404; before the first buildshared.site.renderis absent → 503.
A Loader for .json files that parses each file into a single FHIR resource.
json() is a config-time loader factory (not a hook plugin) — attach it to a source via cfg.sources[].loader. Its load(ctx, { file }) reads and JSON.parses the file, skipping it with a warning if parsing fails or resourceType is missing (the latter suppressed when permissive: true). The resource id is data.id or derived from the filename (stripping a leading <ResourceType>-). When detectExamples is on (default), it flags examples via looksLikeExample — any file under an /example(s)/ dir, or any resource whose resourceType is NOT a conformance type (StructureDefinition, ValueSet, CodeSystem, CapabilityStatement, etc.) — and marks them by setting data.__wasExample = true and meta.example = true (so IG-resource emits exampleBoolean=true). File→resource is 1:1, so invalidate is a no-op.
import json from "fcc/json";
// in sources:
{ dir: "../../vendor/us-core/input/resources", loader: json() },
{ dir: "../../vendor/us-core/input/examples", loader: json() },- Opts are only
detectExamples(defaulttrue) andpermissive(defaultfalse) — no other options exist. detectExamples: falsedisables both the/examples/-dir and non-conformance-type heuristics, so nothing gets the__wasExample/meta.examplemarking.
A Loader that compiles .fsh files into FHIR resources by running fsh-sushi off-thread in a persistent worker.
Returned by the fsh(opts) factory and attached to a source's loader field (not a step/hook — loaders live on cfg.sources[].loader, no lifecycle hook). Sushi needs the whole FSH tank in one call so cross-references between Aliases/Profiles/Instances resolve, so load() compiles the entire batch once (caching one Promise<Batch> per target, keyed by name|canonical|version) and serves every .fsh from it; the heavy compile runs in worker.ts off the main thread to keep fcc dev responsive. invalidate() exists because fsh-sushi gives no per-file source map: any .fsh edit drops the whole batch cache and re-invalidates every resource whose source.kind === "fsh". Errors/warnings from sushi are reported via ctx.warn. Opts are { snapshot?, quietInfo? }.
import fsh from "fcc/fsh";
sources: [
{ dir: "input/fsh", loader: fsh({ snapshot: false, quietInfo: true }) },
],- All FSH output buckets to the first
.fshfile found, so a single edit recompiles and re-emits the entire FSH-produced set — not just the changed file. worker.ts(the only place fsh-sushi is imported) must sit besidefsh.ts; the worker is unref'd while idle sofcc buildcan still exit.
A source loader that turns an authored .ts resource file into a FHIR resource by running its default export through fcc's authoring helpers.
It's a Loader ({ name, extensions: [".ts"], load }), placed on a source via loader: ts() — not a lifecycle plugin/step. On load, it dynamic-imports the file (Bun runs .ts natively), takes the default export, checks isAuthored(def), then calls materializeAuthored(def, ctx.config, ctx.target) to produce the concrete resource (stamping source = { kind: "ts", path }). The default export must be an fcc authored object — built by helpers exported from fcc such as profile(), valueSet(), codeSystem(), capability(), and example(); a missing or non-authored default export is skipped with a warning and yields no resource. On incremental rebuilds (ctx.cycle > 1) it cache-busts the import URL with ?t=<now> so edits re-evaluate.
import ts from "fcc/ts";
sources: [
{ dir: "input/profiles", loader: ts() }, // authored .ts → FHIR resources
],- Each file contributes exactly one resource (the materialized default export); a file with no default export or a non-authored default is dropped with a
warning, not an error. - The factory takes no options — all behavior comes from the file's authored object plus
ctx.config/ctx.targetat materialize time.
A .md source loader that turns each input/pagecontent markdown file into a Page resource in the build graph.
pages() returns a Loader (name: "fcc/pages", extensions: [".md"]) you attach to a source dir, not a plugin step — it runs through the normal source/loader/incremental machinery, so one .md edit invalidates exactly one Page. Each file becomes { resourceType: "Page", id, slug, title, md, role }: the slug is the basename without .md, the title is the first # H1 (falling back to a title-cased slug), and role is "landing" for index.md or "page" otherwise. Because Page is a non-FHIR resourceType, these resources are filtered out of FHIR consumers (npm, ig-resource, validator) and only feed the site renderer.
sources: [
{ dir: "../../vendor/us-core/input/pagecontent", loader: pages() }, // .md → Page resources
]index.md→role: "landing"; everything else →role: "page".- Skips (returns
nullfor)ImplementationGuide-*.mdand<RT>-<id>-intro.md/-notes.mdfiles — those are handled by the site's intro/notes mechanism, not turned intoPages. pages()takes no options.
Generates StructureDefinition snapshots from differentials using @atomic-ehr/fhirschema's generateSnapshot.
Runs at the afterValidate hook. For each StructureDefinition in the bundle lacking a snapshot, it merges the profile's differential against its base-definition chain into a full element set. The resolver indexes the IG's dependency packages (the FHIR install cache) plus in-bundle SDs, where current-build SDs override cached packages. The dependency base index is stable across rebuilds, so it's cached in ctx.shared.__snapBase. Profiles whose base can't be resolved are kept as differential-only and counted as failed.
import snapshot from "fcc/snapshot";
snapshot({ packagesDir: "../../vendor/us-core/input-cache/.fhir/packages" })- Options:
packagesDir(defaults toinput-cache/.fhir/packages, resolved againstprojectRoot) andquiet(suppresses the info warning). No other options exist. - The base index always seeds
hl7.fhir.r4.core#4.0.1plus every entry inconfig.deps; it backfills inherited elements' missingmin→0andmax→"1"(a fhirschema round-trip quirk).
Auto-fills Resource.text with a generated XHTML narrative for any FHIR resource that lacks one.
Runs as a single transform-hook step (narrative() → [{ hook: "transform", fn }]). For each incoming resource it skips Page (not a FHIR resource) and any resource that already has a text, then synthesizes { status: "generated", div } where div is an XHTML paragraph wrapping a display string. The display falls back through data.title → data.name → "<resourceType> <id>", and the value is HTML-escaped before insertion. The mutated resource is returned (others return null, i.e. unchanged).
import narrative from "fcc/narrative";
// in plugins:
narrative(),- Takes no options —
narrative()ignores its argument; there is nothing to configure. - Only fills in a narrative when
data.textis absent; existingtextis never overwritten.
Composes a list of validator descriptors { fn, ...config } and runs them at afterValidate, writing per-resource issues into ctx.issues.
The factory validator({ validators }) returns a single afterValidate step whose fn(ctx, config, opts) runs each descriptor's fn(ctx, config, {}) (defaulting to [{ fn: structural }]), flattens the issues into ctx.issues keyed by resource id, and publishes a summary to ctx.shared.validate = { issues, summary: { errors, warnings, resources, total } } (surfaced as errors.html). A descriptor that throws is caught and warned, contributing no issues. Three validators ship: structural (global lint — missing/duplicate id & url, unresolved refs), schema (@atomic-ehr/fhirschema against meta.profile, packagesDir-configurable), and fhirpathConstraints (snapshot invariants via @atomic-ehr/fhirpath). schema and fhirpathConstraints are per-resource and incremental: results cache in ctx.shared and are reused unless the resource is in ctx.changedIds; dropped resources are evicted.
validator({ validators: [
{ fn: structural },
{ fn: schema, packagesDir: "input-cache/.fhir/packages" },
{ fn: fhirpathConstraints },
] })- Lives in the shared
plugins(data pipeline), not per-target; place it aftersnapshot()sincefhirpathConstraintsreads generated snapshots. structuralruns over the full set every build (its dup/url checks need the whole graph); onlyschema/fhirpathConstraintsare gated bychangedIds. Passquiet: trueto suppress the summary warning.
Synthesizes the IG's ImplementationGuide resource from the current resource graph at beforeValidate.
igRes({ pagecontent }) registers one beforeValidate step (igResourceFn). Each run deletes any prior ImplementationGuide/<cfg.id>, then rebuilds it from cfg (id, canonical, version, title, status, deps → dependsOn, target.fhir → fhirVersion) and emits it as a virtual resource. definition.resource[] is filled from every resource that isn't an ImplementationGuide or Page, each entry carrying a reference, a name, and exampleBoolean (true when the resource was flagged __wasExample). It recomputes on every build so the resource list stays in sync in watch mode.
import igRes from "fcc/ig-resource";
// in the plugins list:
igRes({ pagecontent: "../../vendor/us-core/input/pagecontent" }),pagecontentis accepted and spread into the step descriptor, butigResourceFndoes not actually read it — page filtering only excludesresourceType === "Page"fromdefinition.resource.nameispascalize(cfg.id);title/statusfall back tocfg.id/"draft"; emitting it before validation means the synthesized IG is itself validated.
Reads the menu: section of a project's sushi-config.yaml and hands a rendered nav tree to the site renderer via ctx.shared.menu.
menu({ config }) returns a single buildStart step. At build start the fn resolves config against pctx.config.projectRoot (default "sushi-config.yaml"), reads the file, and calls ctx.fns.menu.parseMenu to walk the indent ladder into a MenuNode[] tree (SUSHI's menu format isn't strict YAML — submenu items sit at a deeper indent). It then renders the tree with ctx.fns.menu.renderMenu and publishes pctx.shared.menu = { html, tree }, which fcc/site picks up in its writeBundle pass. It is a flat-ns plugin (ctx.fns.menu, lazily attached via loadFns).
menu({ config: "../../vendor/us-core/sushi-config.yaml" }),- A missing config file or an absent/empty
menu:section is non-fatal: it emits aninfowarning and skips (noctx.shared.menu), so the site falls back to no menu. - Only
configis an option; the menu is read once atbuildStart, not hot-reloaded.
Renders the IG website (StructureDefinitions, terminology, capabilities, narratives, markdown pages, artifact indexes) from the resource graph through one route table that serves both fcc build (precomputed to dist/) and fcc dev (lazy, in-memory + SSE reload).
site(opts) is a factory returning three step descriptors — { hook: "writeBundle", fn }, { hook: "watchPaths", fn }, { hook: "handleHotUpdate", fn } — so it's a per-target output generator (placed in a target's plugins, not the shared plugins). Each step's fn(ctx, config, opts) calls a private attach(ctx, config) that idempotently loadAll(ctx)s the seven flat-ns site_* namespaces (site_core, site_md, site_profile, site_terminology, site_capability, site_narrative, site_artifacts) onto ctx.fns and runs enable with opts into ctx.state.site, then delegates to ctx.fns.site_core.{writeBundle,watchPaths,handleHotUpdate}. buildRoutes is the single route table; $route_* files (e.g. $route_examplesZip.ts → examples.json.zip) contribute extra export routes alongside the per-resource pages.
// in a target's plugins (examples/us-core uses the igSite() preset that bundles it)
import site from "fcc/site";
{ name: "r4", fhir: "4.0.1", out: "dist/r4",
plugins: [ site({ introNotes: "input/intro-notes" }) ] }- It's a target plugin (one site per target's
out), not a shared data-pipeline plugin — opts spread directly onto each step descriptor and are read viaenablefromconfig. - Editing
site_*source files is not hot-reloaded; only IG inputs andwatchPaths-declared paths trigger dev rebuilds — restartfcc devafter changing renderer code.
Emits a FHIR NPM package.tgz at writeBundle, faithful to the HL7 IG Publisher layout.
npm({ emitUnpacked? }) returns one writeBundle step whose npmFn(ctx, config, { bundle }) builds the tarball under package/: a package.json manifest (type: "IG", canonical, url, fhirVersions, dependencies, directories, jurisdiction, derived from config plus the IG resource), a v2 .index.json listing conformance resources only, conformance resources flat (package/<RT>-<id>.json), and examples (those the loader flagged __wasExample, stripped before write) under package/example/ and not indexed. It reuses ctx.shared.sqlite.indexDb verbatim as package/.index.db when the sqlite plugin ran (npm owns no DB code). Entries are sorted for reproducible output, tarred via the local USTAR writer, gzipped with Bun.gzipSync, and written with Bun.write (which creates parent dirs). With emitUnpacked it also writes the unpacked package/ tree for debugging.
import npm from "fcc/npm";
// per-target plugins — package only:
{ name: "pkg", fhir: "4.0.1", out: "dist/pkg", plugins: [npm()] }- Default opts are
{ emitUnpacked: true }— passnpm({ emitUnpacked: false })to ship onlypackage.tgz. .index.dbis omitted when the sqlite plugin isn't in the pipeline; the package.jsonjurisdictionbecomes a"system#code"urn (e.g.urn:iso:std:iso:3166#US) from the IG's first jurisdiction coding.
Builds the FHIR package.json manifest from config + the IG resource and publishes it (plain data) on ctx.shared.manifest.
Runs at the generateBundle hook. It calls the shared fcc helper packageManifest(config, target, ig) — type: "IG", canonical, url, fhirVersions, dependencies, directories, jurisdiction, etc. — and assigns the resulting object to ctx.shared.manifest. The npm plugin reads that handoff for package/package.json (falling back to the same helper when this plugin isn't in the pipeline, since a package.json is mandatory), and any other consumer (a site version chip, a registry-publish step) can read the manifest object directly.
import manifest from "fcc/manifest";
// in a target's plugins (alongside npm); igSite() includes it:
manifest()- Takes no options; the manifest is pure data (no methods), built once at
generateBundle. - Build logic lives in the shared
packageManifesthelper, so npm and any future emitter stay in sync.
Builds the IG-Publisher .index.db (a SQLite mirror of the package's conformance resources) in memory and publishes its serialized bytes for the npm plugin to ship.
Runs at the generateBundle hook. It selects the same conformance-only rows as .index.json (via the shared packageEntries + indexEntry helpers, examples excluded, sorted by filename for reproducible bytes), creates a one-table in-memory bun:sqlite Database(":memory:"), inserts the IG-Publisher ResourceList columns, serializes with db.serialize(), and always closes the db in a finally. The result is published as ctx.shared.sqlite.indexDb (a Uint8Array) — data-only, no db handle leaks into the graph. The npm plugin reads that handoff to emit package/.index.db.
import sqlite from "fcc/sqlite";
// in a target's plugins (alongside npm):
sqlite() // table defaults to "ResourceList"
sqlite({ table: "ResourceList" }) // override the table name- The only option is
table(default"ResourceList"); it's interpolated into DDL, so it must match/^[A-Za-z_][A-Za-z0-9_]*$/or the build errors viactx.error. - Produces only the
ctx.shared.sqlitehandoff — pair it withnpm()for the bytes to actually be written.
igSite(siteOpts) is a config-time factory returning the full IG output pipeline: [site(siteOpts), sqlite(), npm()].
It's not a single plugin but a composed Plugin[] you drop into a target's plugins to emit a browsable site plus a FHIR npm package with an embedded SQLite .index.db. siteOpts is forwarded verbatim to site() (its type is Parameters<typeof site>[0], optional, defaulting to {}) — igSite itself adds no options of its own. The ordering matters: sqlite() runs at generateBundle and publishes its index to ctx.shared.sqlite, which npm() then reads at writeBundle to write package/.index.db. Because run order equals config order, keep sqlite() before npm() so the handoff resolves.
import { igSite } from "fcc/presets";
// in a target:
{ name: "r4", fhir: "4.0.1", out: "dist/r4",
plugins: igSite({ introNotes: "../../vendor/us-core/input/intro-notes" }) }- For a package-only target, skip the preset and use
[npm()]directly; for site-only, callsite()on its own. - The shared data pipeline (loaders,
snapshot,validator, etc.) lives in the top-levelplugins, not inigSite— the preset only covers output generators.
fcc/site is assembled from seven flat-fn namespaces (site_core, site_md, site_profile, site_terminology, site_capability, site_narrative, site_artifacts), each its own /src/site_<name>/ folder; project pages extend them by dropping in $-prefixed files.
site_core/loadAll wires every namespace into ctx.fns.<ns>, and buildRoutes enumerates one lazy render() thunk per output path (shared by fcc build→disk and fcc dev→on-demand). Extension files are dispatched by bare name across all loaded namespaces — $section_<id> (a Content-page section (ctx,{resource})→{title,id,html}|null, ordered per resourceType), $tab_<id>/$block_<class>/$avail_<name> (companion-tab renderer, kramdown block, availability predicate, referenced from tabDefaults/blockDefaults by string key), $page_<slug> (a code-defined page), and $route_<name> (returns a RouteDef/RouteDef[] for arbitrary export paths, even binary). Registry keys that name a fn (a tab's render/avail, a section id, a block) are looked up by ctx.fns.site_core.resolveFn({ key }), which scans every namespace — fn names are globally unique, so the resolution is deterministic and you never hardcode the namespace. $route_ files are gathered by buildRoutes scanning ctx.fns for keys starting with $route_ and calling each with { pluginCtx }; no registration is needed.
// src/site_core/$route_examplesZip.ts — emits /examples.json.zip
import { zip } from "fcc";
export default function $route_examplesZip(_ctx: Context, opts: { pluginCtx: types.fcc.PluginContext }): types.site_core.RouteDef {
const pctx = opts.pluginCtx;
return { path: "examples.json.zip", id: null, contentType: "application/zip",
render: () => zip([...pctx.resources.values()].filter(r => (r.data as any).__wasExample).map(/* …→{name,bytes} */)) };
}RouteDef.id: nullmarks an aggregate route — always rebuilt in prod, re-served fresh in dev; a per-resourceidgates incremental rewrites.- Plugin code (anything under
src/**, including these$-files) is NOT hot-reloaded — adding/renaming one requiresbash src/site/gentypes.shplus anfcc devrestart.