Extensions
Learn the explicit extension model, built-in extensions, and the custom extension API.
registry-build does not attach default plugins automatically. If you want validation, component-index generation, colors/themes, an Arch-style repository emitter, or any other derived output, you add that extension explicitly in config.
Extension model
Extensions run in one of two stages:
beforeBuildafterBuild(default whenstageis omitted)
They receive a typed API with access to:
- the resolved config
- build artifacts
- output registration helpers
- path helpers
- custom artifact storage
This keeps optional behavior composable without making the core runner guess which outputs every consumer wants.
Built-in extensions
The built-in extensions are mostly UI/docs-oriented helpers. They are useful, but they are not the definition of the core package.
| Extension | Stage | Purpose |
|---|---|---|
bannerExtension() | beforeBuild | Print a compact build banner |
validateExtension() | beforeBuild | Validate source paths and registry entries |
indexBuildExtension() | afterBuild | Materialize registry entries and write index.json |
componentsExtension() | afterBuild | Generate per-item JSON component payloads |
componentIndexExtension() | afterBuild | Generate framework-specific import index files |
colorsExtension() | afterBuild | Generate colors, themes, and CSS artifacts |
import {
bannerExtension,
colorsExtension,
componentIndexExtension,
componentsExtension,
defineConfig,
indexBuildExtension,
validateExtension,
} from '@gentleduck/registry-build'
export default defineConfig({
extensions: [
bannerExtension({ name: 'Duck UI' }),
validateExtension(),
indexBuildExtension(),
componentsExtension(),
componentIndexExtension({
framework: 'nextjs',
excludeTypes: ['registry:ui', 'registry:hook'],
}),
colorsExtension(),
],
})import {
bannerExtension,
colorsExtension,
componentIndexExtension,
componentsExtension,
defineConfig,
indexBuildExtension,
validateExtension,
} from '@gentleduck/registry-build'
export default defineConfig({
extensions: [
bannerExtension({ name: 'Duck UI' }),
validateExtension(),
indexBuildExtension(),
componentsExtension(),
componentIndexExtension({
framework: 'nextjs',
excludeTypes: ['registry:ui', 'registry:hook'],
}),
colorsExtension(),
],
})Collections-first custom extensions
For new non-UI builds, the most important pattern is reading named collections from the build context.
{
name: 'archRepository',
stage: 'afterBuild',
async run(api) {
const collections = api.getArtifact('collections') ?? api.config.collections
const packages = collections.packages?.data
// validate input
// derive outputs
// register emitted files
},
}{
name: 'archRepository',
stage: 'afterBuild',
async run(api) {
const collections = api.getArtifact('collections') ?? api.config.collections
const packages = collections.packages?.data
// validate input
// derive outputs
// register emitted files
},
}That keeps your extension generic:
- config declares data and paths
- the extension reads collections and metadata
- the extension emits files and artifacts intentionally
Arch-style repository pattern
An Arch-like package repository extension usually does four things:
- read
collections.packages.data - group packages by repo such as
core,extra, orcommunity - emit repo-specific database and file-manifest outputs
- emit a search or lookup artifact for downstream consumers
That is exactly the pattern used in the in-repo example under packages/registry-build/examples/arch-package-index.
Read the full walkthrough in Course and Arch Package Index.
componentIndexExtension
This extension is how consumers opt into framework-facing generated import maps.
Typical options:
componentIndexExtension({
framework: 'nextjs',
packageMappings: {
'registry:ui': '@example/registry-ui',
'registry:example': '@example/registry-examples',
},
excludeTypes: ['registry:ui'],
ssr: false,
header: '// Auto-generated component index',
generator: undefined, // set a custom (items) => string to replace the built-in adapter entirely
})componentIndexExtension({
framework: 'nextjs',
packageMappings: {
'registry:ui': '@example/registry-ui',
'registry:example': '@example/registry-examples',
},
excludeTypes: ['registry:ui'],
ssr: false,
header: '// Auto-generated component index',
generator: undefined, // set a custom (items) => string to replace the built-in adapter entirely
})Use a custom generator when none of the built-in adapters match your target runtime.
colorsExtension
This extension consumes resolved colors/themes data and writes derived artifacts such as:
colors/index.json- per-theme color payloads
themes/*.jsonthemes.css
When options are passed directly to the extension, use loaded objects rather than string file paths. File-path loading belongs at the root config level.
Writing a custom extension
import fs from 'node:fs/promises'
import path from 'node:path'
import { defineConfig } from '@gentleduck/registry-build'
export default defineConfig({
extensions: [
{
name: 'manifest',
stage: 'afterBuild',
async run(api) {
const collections = api.getArtifact('collections') ?? api.config.collections
const manifest = {
generatedAt: new Date().toISOString(),
collections: Object.keys(collections),
outputs: api.listOutputs().map((output) => output.name),
}
const manifestPath = path.join(api.getPath('registryDir'), 'manifest.json')
await fs.mkdir(path.dirname(manifestPath), { recursive: true })
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
api.setArtifact('manifest', manifest)
api.registerOutput('manifest', manifestPath, {
kind: 'custom',
})
return {
itemCount: 1,
name: 'manifest',
outputFiles: [manifestPath],
}
},
},
],
})import fs from 'node:fs/promises'
import path from 'node:path'
import { defineConfig } from '@gentleduck/registry-build'
export default defineConfig({
extensions: [
{
name: 'manifest',
stage: 'afterBuild',
async run(api) {
const collections = api.getArtifact('collections') ?? api.config.collections
const manifest = {
generatedAt: new Date().toISOString(),
collections: Object.keys(collections),
outputs: api.listOutputs().map((output) => output.name),
}
const manifestPath = path.join(api.getPath('registryDir'), 'manifest.json')
await fs.mkdir(path.dirname(manifestPath), { recursive: true })
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
api.setArtifact('manifest', manifest)
api.registerOutput('manifest', manifestPath, {
kind: 'custom',
})
return {
itemCount: 1,
name: 'manifest',
outputFiles: [manifestPath],
}
},
},
],
})Design guidance
Keep extensions data-first. Let the core build produce stable artifacts, then let extensions derive extra files from those artifacts. Avoid hiding base-path assumptions or hardcoded package names inside extension code.
When to use an extension vs config
Use config when the value is static and declarative:
- collections and collection metadata
- source folders
- output directories
- package mappings
- target paths
- theme data
Use an extension when you need to execute logic:
- generate a derived file
- validate a custom contract
- publish a custom manifest
- bridge the build into another system