QUESTPIE
CodeCodegen

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.

View markdown

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" },
				},
			},
		},
	};
}
FieldTypeWhat it is
namestringUnique. Plugins are deduped by it.
targetsRecord<string, CodegenTargetContribution>What this plugin adds, per target.
validatorsCrossTargetValidator[]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.

questpie.config.ts
export default runtimeConfig({
	plugins: [openApiPlugin()],
});
the module a package ships
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.

FieldTypeWhat it adds
rootstringDiscovery root, relative to the server root
outDirstringOutput directory inside root, default .generated
outputFilestringThe main generated file
moduleRootstringSubdirectory of a module dir this target reads
categoriesRecord<string, CategoryDeclaration>Directory conventions to scan
discoverRecord<string, DiscoverPattern>Single files and globs
registriesobjectTyped methods generated into factories.ts
callbackParamsRecord<string, CallbackParamDefinition>Runtime proxies for callback methods
transform(ctx) => voidAdds imports, types and code before generation
generate(ctx) => CodegenTargetOutputReplaces the template for this target
scaffoldsRecord<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",
	},
}
OptionDefaultEffect
dirsrequiredScanned at the root and under features/*/
prefixrequiredVariable prefix in the generated file, _view_kanban
recursivefalseDescend 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"
createAppKeythe category nameEmit under a different key, emails becomes emailTemplates
registryKeynonetrue uses the category name, a string overrides it
extractFromModulestrueMerge the same category from installed modules
extraTypeImportsnoneImport statements added when the category has files
factoryFunctionsnoneNames that mark an entity, turns on multi-entity files
factoryKeyStrategy"factory-argument""export-or-filename" keeps the argument out of the key
factoryArgumentnoneValidate the argument, and require it to be unique
keyFromPropertynoneKey off a runtime property, blocks use "state.name"
keyFromSourcenone"basename" keys off the file name
factoryImportsnoneNamed exports spread into the field defs in factories.ts
placeholdernoneToken in a configType, resolved to the union of keys
recordPlaceholdernoneToken resolved to the full record type
typeRegistrynoneInterface 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.

OptionEffect
patternThe 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
registryKeyAdds typeof this file to the registry under the given key
configKeyEmits 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.

On this page