partisan
v1.0.0
Published
Build-time dependency indirection in the style of the Sanity v2 parts system
Maintainers
Readme
partisan
A Vite + TypeScript reimagining of the Sanity v2 "parts" system, unbundled from Sanity.
This is… not really meant for you to use. It exists to find out how cleanly the old parts mechanic rebuilds on modern web tooling. You probably shouldn't use it in anything you care about. Reasons below.
What "parts" means
A parts system is build-time dependency indirection. Instead of importing a concrete module path, you import an abstract name like part:app/root, and the system rewires that name to whichever plugin provides an implementation. Plugins can declare new names, implement existing ones, override implementations from other plugins, or contribute multiple implementations to a single name.
Three import flavors:
// Single implementation. Last plugin to implement wins.
import Root from 'part:app/root'
// Optional. Returns null if nothing implements it.
import logo from 'part:app/brand-logo?'
// All implementations as an array, in plugin order.
import tools from 'all:part:app/tool'In Sanity v2, parts powered tool plugins, custom inputs, login screens, brand customization, and a few other extension points. The mechanic itself was useful for extensible apps. The implementation, less so.
Why this exists
Sanity v2's parts ran through webpack. That worked, but it also forced webpack-specific behavior onto every other tool that wanted to understand the project: PostCSS, ESLint, TypeScript etc. Each tool had to be patched or taught about parts, and each time the ecosystem moved, something broke.
While looking through some Vite documentation, I spooted Virtual Modules, which made me chuckle a bit. The virtual:some-identifier syntax is very similar to part:some-name, after all. And that made me wonder how hard it would be to support now, given how good Vite has become:
- Vite resolves modules via Rollup conventions that everything else (especially TypeScript) already understands.
- CSS handling is built into Vite. PostCSS just works because resolved part imports are real file paths.
- TypeScript module wildcards plus codegen express the parts contract directly, without a TS plugin or compiler patch.
- ESLint resolvers follow
.d.tsdeclarations the same way they followtsconfigpaths.
Result: the parts mechanic, rebuilt today is a ton easier than what it was back then.
Why you probably shouldn't use this
The original parts system was retired in Sanity v3 for good reasons. Indirection costs more than it looks like it should:
- "Where is this implemented?" requires reading
partisan.jsonfiles instead of following an import. - Override behavior is implicit and depends on the order of
pluginsarrays. - New contributors have to learn a project-specific mechanic before they can read the code.
- The TypeScript story here is functional, but it leans on codegen and ambient declarations. It's not free.
- Other tooling likely won't work or will crash
For most apps, ordinary module imports plus an explicit plugin/registry API are clearer and just as flexible. Reach for partisan if you specifically want the v2 ergonomics back, or you want to play with the design.
How partisan works
partisan.json
Every plugin and the consuming app has a partisan.json at its package root:
{
"root": true,
"pluginPrefix": "acme-plugin",
"plugins": ["@acme/base", "toolbar"],
"parts": [
{"name": "part:acme/root", "description": "Root component"},
{"implements": "part:acme/root", "path": "./src/Root.ts"}
]
}pluginslists dependencies in load order. Last wins for overrides.pluginPrefixis the project's convention for resolving bare plugin names. Withacme-plugin, the entry"toolbar"resolves to the npm packageacme-plugin-toolbar. Scoped names like@acme/baseand relative paths pass through unchanged.partsis a flat list mixing declarations (name,description) and implementations (implements,path).
The import scheme itself (part:, all:part:) is fixed and not configurable. Only the plugin naming convention is.
Resolution
When Vite starts, partisan:
- Reads the project's
partisan.json. - For each entry in
plugins, resolves it via Node's package resolution, reads that package'spartisan.json, and recurses. - Visits depth-first, post-order. The consuming project is added last. This is what makes the project's own implementations win over plugin implementations.
Import handling
Each import scheme is handled differently:
part:foo/barresolves directly to the absolute file path of the last implementation. No virtual module. This is the important part: CSS, SVGs, and other non-JS assets go through Vite's normal pipeline (PostCSS, asset hashing, etc.) with zero special handling, because we never invent a synthetic module for them.part:foo/bar?is a virtual module that re-exports from the last implementation, orexport default nullif there's no implementation.all:part:foo/baris a virtual module that imports each implementation and exports bothdefault(an array of the impls' defaults) andmodules(an array of full namespace objects, in case you need named exports too).
Dynamic imports work as long as the specifier is statically resolvable. await import('part:foo/bar') is fine. await import(\part:${name}`)` is not, because the resolver runs at build time.
Filesystem allow
Plugin implementations often live outside Vite's auto-detected workspace root (especially with npm link, file: deps, or globally installed packages). The plugin contributes every resolved plugin's package root to server.fs.allow automatically, so consumers don't have to think about it.
TypeScript
This is where v2 hurt most. Partisan tries to make it boring with three layers, most-specific first:
Codegen. On dev-server start, partisan writes
partisan-env.d.tsto the project root. It walks the resolved part graph and emitsdeclare moduleblocks that re-export from the actual implementation files. This gives concrete, inferred types, and "go to definition" works in any editor with a TS server because the declarations point at real files.Plugin contract types. Plugins that declare parts can ship a
parts.d.tsdescribing each part's contract (theToolinterface, astring, etc.). Consumers of those parts get useful types even before the app's codegen has run.Ambient wildcards.
partisan/clientexportsdeclare module 'part:*'and friends, typed asunknown. Last-resort fallback for parts that haven't been narrowed by either layer above.
TypeScript picks the most specific matching declaration, so the layers compose. An app sees concrete impl types. A plugin sees contract types. Anything not covered falls through to unknown.
Using it
pnpm add partisan viteVite config
// vite.config.ts
import {defineConfig} from 'vite'
import {partisan} from 'partisan/vite'
export default defineConfig({
plugins: [partisan()],
})That's the whole integration. Codegen runs on dev-server start and on partisan.json changes.
Plugin options:
projectRoot(string): override the directory containing the rootpartisan.json. Defaults to Vite'sconfig.root.generateTypes(string | false): output path for codegen. Defaults to<projectRoot>/partisan-env.d.ts. Passfalseto skip codegen.
App partisan.json
{
"root": true,
"pluginPrefix": "myapp-plugin",
"plugins": ["@myorg/base", "extra-tool"]
}"extra-tool" resolves to myapp-plugin-extra-tool. "@myorg/base" is used verbatim.
App tsconfig.json
{
"compilerOptions": {
"types": ["partisan/client"]
},
"include": ["src/**/*", "partisan-env.d.ts"]
}Decide whether to commit partisan-env.d.ts. Either way works: it's reproducible from partisan.json plus the resolved plugin tree, so .gitignore-ing it is fine.
Authoring a plugin
A plugin is an npm package with a partisan.json at its root:
{
"parts": [
{"name": "part:myapp/root", "description": "Mounts the app"},
{"implements": "part:myapp/root", "path": "./src/Root.ts"}
]
}If your plugin declares parts (as opposed to only implementing parts declared elsewhere), ship a parts.d.ts so downstream code gets useful types without waiting on the consuming app's codegen:
// parts.d.ts
declare module 'all:part:myapp/tool' {
import type {Tool} from './src/types.ts'
const value: readonly Tool[]
export default value
}Give the plugin its own tsconfig.json so editors pick it up:
{
"compilerOptions": {
"types": ["partisan/client"]
},
"include": ["src/**/*", "parts.d.ts"]
}Codegen
Two ways to trigger it:
Automatically via Vite. The Vite plugin runs codegen in its
config()hook (on server start) and on anypartisan.jsonchange. If Vite is running, codegen is running.Via the
partisanCLI. The package ships abinyou can run when Vite isn't:partisan # writes <cwd>/partisan-env.d.ts partisan --project ./app # use ./app as the project root partisan --out types/parts.d.tsThis is what you want in CI before
tsc --noEmit, sincetscwon't trigger Vite. The example app'stypecheckscript just runspartisan && tsc --noEmit.The CLI is plain compiled JavaScript shipped in the
dist/directory of the package. Node 22.12 or newer is required (enforced viaengines.node).
If you want to commit the generated file, set generateTypes to a stable path and check it in. Otherwise add partisan-env.d.ts to .gitignore.
To skip codegen entirely (relying on partisan/client and plugin parts.d.ts files for types):
partisan({generateTypes: false})Example
examples/acme-app/ contains a small demo that exercises the four part flavors:
part:acme/rootis declared by@acme/base, implemented by base, then overridden by the project. Last wins.all:part:acme/toolcollects implementations from both base andacme-plugin-toolbar.part:acme/brand-logo?is declared but never implemented, so it returnsnull.part:acme/stylesis a CSS file imported through the parts system. Vite's CSS pipeline handles it as if you'd imported the file directly.
Run it:
pnpm install
pnpm devLimitations
- No fully dynamic specifiers. The resolver runs at build time, so
import(\part:${runtimeName}`)won't work. AloadPart(name)` runtime helper backed by a generated registry would close the gap if you need it. part:foo?andall:part:fooassume every implementation has a default export. The virtual modules generated for these schemes default-import each impl, so an impl without a default will fail to build. Plainpart:foois just a path redirect and has no such restriction: it accepts any import shape (default, named, namespace, side-effect) that works against the underlying file.- HMR for
partisan.jsonchanges triggers a full reload rather than a hot patch. Good enough for now. - First-time editor opens may briefly show "cannot find module" errors before Vite runs and produces
partisan-env.d.ts. The ambient wildcards inpartisan/clientcover the gap for plugins.
License
MIT. See LICENSE.
