Skip to main content

Extensions

Learn the explicit extension model, built-in extensions, and the custom extension API.

Extension model

Extensions run in one of two stages:

  • beforeBuild
  • afterBuild (default when stage is 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.

ExtensionStagePurpose
bannerExtension()beforeBuildPrint a compact build banner
validateExtension()beforeBuildValidate source paths and registry entries
indexBuildExtension()afterBuildMaterialize registry entries and write index.json
componentsExtension()afterBuildGenerate per-item JSON component payloads
componentIndexExtension()afterBuildGenerate framework-specific import index files
colorsExtension()afterBuildGenerate 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:

  1. read collections.packages.data
  2. group packages by repo such as core, extra, or community
  3. emit repo-specific database and file-manifest outputs
  4. 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/*.json
  • themes.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

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