Plugins
A codegen plugin teaches the generator a new file convention, a new builder method or a whole new generated file. This is the contract, field by field.
The shape
import type { CodegenPlugin } from "questpie/codegen";
export function openApiPlugin(): CodegenPlugin {
return {
name: "questpie-openapi",
targets: {
server: {
root: ".",
outputFile: "index.ts",
discover: {
openapi: { pattern: "config/openapi.ts", configKey: "openapi" },
},
},
},
};
}| Field | Type | What it is |
|---|---|---|
name | string | Unique. Plugins are deduped by it. |
targets | Record<string, CodegenTargetContribution> | What this plugin adds, per target. |
validators | CrossTargetValidator[] | Checks that run after every target. |
A target is one output directory. server writes index.ts, app-factory.ts,
factories.ts and the type layers beside them. admin-client writes
client.ts. A plugin can invent its own target id.
The core plugin is prepended on every run. It declares the built-in categories and scaffolds. You never register it.
Registering one
Put it on the config, or ship it on a module.
export default runtimeConfig({
plugins: [openApiPlugin()],
});export const myModule = module({
name: "acme-thing",
plugin: myPlugin(),
});The module route is the one to prefer. Codegen walks modules.ts before
discovery, collects every plugin it finds, and the user adds nothing. The walk
is depth first, submodules before the module itself. Duplicate names are dropped
and the first one wins. A plugin registered on the config beats a module one of
the same name.
What a target contribution holds
root and outputFile are required. Everything else is optional.
| Field | Type | What it adds |
|---|---|---|
root | string | Discovery root, relative to the server root |
outDir | string | Output directory inside root, default .generated |
outputFile | string | The main generated file |
moduleRoot | string | Subdirectory of a module dir this target reads |
categories | Record<string, CategoryDeclaration> | Directory conventions to scan |
discover | Record<string, DiscoverPattern> | Single files and globs |
registries | object | Typed methods generated into factories.ts |
callbackParams | Record<string, CallbackParamDefinition> | Runtime proxies for callback methods |
transform | (ctx) => void | Adds imports, types and code before generation |
generate | (ctx) => CodegenTargetOutput | Replaces the template for this target |
scaffolds | Record<string, ScaffoldConfig> | questpie add templates |
Contributors to one target must agree
Codegen merges every plugin's contribution to a target id. A disagreement on
root, outDir, outputFile or moduleRoot throws, and so does a second
generate. Categories merge per key, factoryImports arrays concatenate, and
transforms run in plugin order.
Categories
A category declares a directory to scan and how to emit what it finds.
categories: {
views: {
dirs: ["views"],
prefix: "view",
factoryFunctions: ["view"],
registryKey: true,
typeEmit: "standard",
},
}| Option | Default | Effect |
|---|---|---|
dirs | required | Scanned at the root and under features/*/ |
prefix | required | Variable prefix in the generated file, _view_kanban |
recursive | false | Descend into subdirectories |
keySeparator | "." | Joins recursive path segments, routes use "/" |
emit | "record" | "array" gives a flat list, as migrations and seeds use |
typeEmit | "standard" | Also "services", "emails", "messages", "none" |
createAppKey | the category name | Emit under a different key, emails becomes emailTemplates |
registryKey | none | true uses the category name, a string overrides it |
extractFromModules | true | Merge the same category from installed modules |
extraTypeImports | none | Import statements added when the category has files |
factoryFunctions | none | Names that mark an entity, turns on multi-entity files |
factoryKeyStrategy | "factory-argument" | "export-or-filename" keeps the argument out of the key |
factoryArgument | none | Validate the argument, and require it to be unique |
keyFromProperty | none | Key off a runtime property, blocks use "state.name" |
keyFromSource | none | "basename" keys off the file name |
factoryImports | none | Named exports spread into the field defs in factories.ts |
placeholder | none | Token in a configType, resolved to the union of keys |
recordPlaceholder | none | Token resolved to the full record type |
typeRegistry | none | Interface to augment with the discovered names |
factoryFunctions is the switch that matters most. With it, every exported
matching call becomes its own entity and files with no call are skipped. Without
it, one file is one entity.
Single files and spreads
Where a category scans a directory, a DiscoverPattern claims a file.
discover: {
myConfig: { pattern: "config/my-plugin.ts", configKey: "myPlugin" },
fields: { pattern: "fields.ts", registryKey: "~fieldTypes" },
}A bare string is shorthand. A pattern with a *, or with no extension, is a
directory. A plain path with an extension is a single file.
| Option | Effect |
|---|---|
pattern | The path, relative to the discovery root |
resolve | "auto", "default", "named" or "all". Default "auto" |
keyFrom | "filename" or "exportName" |
cardinality | "single" or "map". Inferred from the pattern |
mergeStrategy | "spread" collects the root file plus every features/* copy |
registryKey | Adds typeof this file to the registry under the given key |
configKey | Emits the whole file as one entry in the config bucket |
configKey is the way to claim a config file. Pair it with an augmentation so
the key is typed.
declare module "questpie" {
interface AppStateConfig {
myPlugin?: { theme?: "light" | "dark" };
}
}Two modules that claim different config keys never collide. Two that claim the
same key do. The later one replaces the earlier one whole. Only app, auth
and admin merge below the key, so your own key is last-wins.
Typed methods in factories.ts
Three declarations generate wrappers into the factory file.
RegistryExtension adds a method to collection(), global() or a field
instance. stateKey is where the value lands on the builder. configType is
the parameter type, and imports brings in what that type needs. Set
isCallback with callbackContextParams for a method that takes a callback.
Set defaults to fold your defaults under the user's config.
SingletonFactory generates a typed identity wrapper for a convention file.
Core declares two, appConfig and authConfig.
BuilderFactory generates a factory that needs the merged field definitions at
construction. The admin contributes block this way. That is why you import
block from #questpie/factories and not from a package.
A callback proxy always points at a real exported factory, never an inline
string. Core declares f as createFieldNameProxy from questpie/builders.
That is what makes f.title resolve to "title" inside .fields().
Transforms and custom generators
transform(ctx) runs after discovery and before generation. It can read every
discovered file and call addImport, addTypeDeclaration, addRuntimeCode and
set. The OpenAPI plugin uses one to emit a union of route keys. Sort what you
emit. Directory read order differs between machines, and unsorted output makes a
committed .generated/ fail to match a fresh run on CI.
generate(ctx) replaces the template for a whole target. It returns { code, additionalFiles? }. Transforms have already run, so the extras are populated.
Every returned file is syntax-checked before it is written. Only one plugin per
target may supply it.
Reuse the standard template
Writing a whole file by hand loses the emit rules the categories already
describe. generateModuleTemplate from questpie/codegen is the primitive the
built-in generator itself calls, so generate() can reuse it and change only
what it needs:
import { generateModuleTemplate } from "questpie/codegen";
generate(ctx) {
const result = generateModuleTemplate({
moduleName: "questpie-admin",
discovered: ctx.discovered,
categoryMeta: new Map(Object.entries(ctx.target.categories ?? {})),
regenerateCommand: ctx.regenerateCommand,
extraImports: ctx.extraImports,
});
return {
code: result.code,
additionalFiles: result.registriesCode
? { "registries.ts": result.registriesCode }
: undefined,
};
}It returns { code, registriesCode }. registriesCode is null unless a
category augments a factory registry. Those augmentations go in a second file on
purpose. module.ts holds builder instances whose augmented interface points
back at the registry, so augmenting in the same file makes it circular.
@questpie/admin is the worked example. It calls this to write
src/questpie/admin/.generated/client.ts and adds only its own view and block
imports on top.
Scaffolds
scaffolds: {
widget: {
dir: "widgets",
extension: ".tsx",
description: "A dashboard widget",
template: ({ kebab, camel }) => `…`,
},
}dir is relative to the target root and extension defaults to .ts. The
template receives kebab, camel, pascal, title and targetId. Declaring
the same scaffold name on two targets is the point. questpie add block hero
writes the server definition and the client renderer in one go.
Cross-target validators
A validator runs after every target has generated. It takes the map of results
and returns ProjectionError[]. One with severity: "error" fails the run and
the CLI exits 1. A "warning" prints and passes. The admin uses one to catch
a server file naming a block the client target never registered.
TypeScript
import type {
CodegenPlugin,
CodegenTargetContribution,
CategoryDeclaration,
DiscoverPattern,
RegistryExtension,
SingletonFactory,
BuilderFactory,
ScaffoldConfig,
CodegenContext,
CrossTargetValidator,
ProjectionError,
} from "questpie/codegen";
import {
categoryRecordEntry,
importStatement,
safeKey,
} from "questpie/codegen";questpie/codegen is the entry point for plugin authors. It carries the types
above and the emit helpers categoryRecordEntry, categoryTypeEntry,
importStatement, safeKey, sortedValues and sourceBasename. It also
exports generateModuleTemplate(), which a target with its own generate calls
when ctx.module is set. To build a package's modules, put a
questpie.config.ts at the package root with packageConfig() and run
questpie generate. That is how the framework builds its own.
Next
Building a plugin walks the same contract as a task. It also covers the smaller seams you reach for first, such as a custom field type or a custom adapter.
Discovery
The rules that turn a file in a convention directory into an entry on the app object. What becomes the key, which exports count, what codegen ignores, and when it stops with an error.
Actors
Four factories on the harness, the user seed each one wants, and what the callback gets. An actor is how a test says who is calling.