chaicore — the open-source ChaiBuilder package: visual editor, block registry,
RSC renderer, server config/actions and DB adapters. Published as a standalone
npm package and vendored into host apps as a git subtree (one-way: hosts
pull, and contribute back by PR here). Requires Next.js ≥ 15.3, React ≥ 19,
Tailwind 4.
It must build and test standalone. Nothing here may depend on the host app it happens to be vendored into.
chaicore and chaipro (the commercial edition, core plus plugins) share one
src/ tree. Every file under src/ is byte-identical in both repos except the
paths in src-sync.exclude — src/edition/ (each repo's own copy: package
identity, always-on plugins, plugin barrels, test harness) and the pro-only trees
(src/payload/ and the pro plugin directories). Changes travel between the repos
as one chore(sync): … commit made by pnpm sync:src; pnpm sync:check proves
the trees match. Read SYNC.md before touching anything that names the package,
a plugin, or the edition: runtime strings use CHAI_PACKAGE_NAME from
~/edition/identity, comments and JSDoc write subpaths as <pkg>/…, and shared
code never imports a plugin.
registry/— block registration API (registerChaiBlockProps,stylesProp,builderProp, pipes,v2/runtime). The public surface custom blocks import.web-blocks/— built-in blocks (box, heading, image, text, rte, list, form, …).builder/— the editor UI (largest tree, ~640 files).render/— RSC/SSR renderer:block-renderer,blocks-renderer,binding-engine,binding-pipes,apply-binding, design tokens,rsc/,async/.server/— server runtime:build-config,create-chai-builder,get-chaibuilder,chai-actions/,builder-actions/,plugin-api/,rbac/,repeater-data/,only-server.ts(browser guard).plugins/— one directory per feature (empty-page-starter,page-errors). Each hasclient/, optionallyserver/,schema/,permissions.ts.plugins/client.tsandplugins/server.tsare shells that forward the edition's barrels (src/edition/*-plugins-barrel.ts). Nothing registers automatically — the host names the plugins it wants.edition/— the only directory both editions have and keep different (never synced): identity, always-on plugin lists, plugin barrels, integration-test schema and harness. Seesrc/edition/README.md. The rest of the exceptions insrc-sync.excludeexist only inchaipro:src/payload/and its plugins.db/— adapters: libsql, d1, better-sqlite3.drizzle/— core schema/relations (schema.sqlite.ts,relations.sqlite.ts) plusseed/. The Postgres twin of the core schema lives in the pro edition only.tailwind/— runtime page-CSS compiler (v4.ts). Tailwind v4 only.nextjs/—withChaiBuilder, server/render/render-client entries.ai/— statically-imported provider adapters (openrouter,openai-compatible).components/ui/— the shared shadcn/Radix +class-variance-authorityprimitives.types/,utils/,constants/,lib/,theme/.tests/— shared test infrastructure only:setup/(db, migrations, seeding, transaction manager),mocks/,utils/(factories, assertions). Actual specs live beside their sources.
.agents/skills/chaibuilder/AGENTS.md is the full extension-API document —
custom blocks, block props schemas, sidebar panels, slots, lifecycle hooks,
feature flags, server config, data providers, rendering. It is the reference for
anything consumer-facing; do not restate or contradict it here. Other skills:
.agents/skills/testing-patterns/, custom-hooks-pattern/,
vercel-react-best-practices/, web-design-guidelines/,
anthropic-frontend-design/.
Run from the repo root:
pnpm build # tsup (esm+cjs) then scripts/build-dts.mjs
pnpm dev # tsup --watch
pnpm typecheck # tsc --noEmit — the real typecheck; the root has none
pnpm lint # eslint src/
pnpm format # prettier --write
pnpm test # vitest --run (unit)
pnpm test:integration # vitest --run --config vitest.config.integration.ts
pnpm db:test:generate # drizzle-kit generate for the SQLite test schemaThe declaration build is split into sequential slices (CHAI_DTS_GROUPS /
CHAI_DTS_GROUP) because rollup-plugin-dts OOMs holding ~150 entries in one
module graph. Do not collapse it back into a single pass.
Three separate no-restricted-imports blocks in eslint.config.mjs. Read the
comments there before working around any of them.
- Subtree boundary (error) — no host-app aliases (
@/…,~~/…,#/…) anywhere insrc/. This repo's own alias is~/→./src. Code that reaches into the host compiles in the host and breaks when the package is built or published alone. - Plugin boundary (error) — shared core code (everything outside
src/plugins,src/payloadandsrc/edition) must not import a plugin. Dependencies point one way: plugins consume core, never the reverse. Invert via~/server/plugin-apior the builder register-apis, or wire an edition-specific plugin in throughsrc/edition/. - Plugin discipline (warn) — plugins consume core through public surfaces
(
~/builder/register-apis,~/server/plugin-api,~/server/chai-actions/*,~/types,~/components/ui,~/constants), not deep internals like~/builder/core/components/*/**or~/server/chai-builder/internal/**.
- Prettier (
.prettierrc): semicolons, double quotes, 120 cols,bracketSameLine,prettier-plugin-tailwindcssclass sorting. This differs from the host config — do not carry host style into this directory. - ESLint:
no-explicit-anyis off here (unlike the host)._-prefixed names are the unused-binding escape hatch. lodash-esis the utility import (import { get, isEmpty } from "lodash-es").- Anything server-only imports
~/server/only-serverfor the browser guard. - Public API changes mean a new
exportssubpath inpackage.jsonand a matching entry intsup.config.ts. The two must stay in sync or the subpath resolves to nothing at install time.
src/plugins/<name>/withclient/(andserver/if it has a server half), plusschema/andpermissions.tswhen it owns tables or permission keys. Copy the shape ofsrc/plugins/page-errors/.- Export the plugin from
src/edition/client-plugins-barrel.ts(andserver-plugins-barrel.tsfor a server half) — thesrc/plugins/*.tsbarrels only forward those. - Add
./plugins/<name>/client(and/server) topackage.jsonexportsand the matching entries totsup.config.ts. - A plugin that owns tables contributes them as a schema fragment; core's barrel stays core-only.
- Import only public core surfaces (see boundary 3 above).
- Unit:
*.test.ts(x)beside the source, plus in-source tests viaimport.meta.vitest(includeSource: src/**/*.{ts,tsx}). Default environment isnode. - Integration:
src/**/*.integration.test.ts, excluded from the unit run. Needs.env.testwithTEST_DATABASE_URL(SQLite:file:…, a bare path, or:memory:) and generated migrations (pnpm db:test:generate). No.env.testships. Runs single-worker, non-isolated, 30s timeouts. Setup lives insrc/tests/setup/(global-setup,integration-setup,describe-with-db,transaction-manager). - DOM tests: opt in per file with
// @vitest-environment happy-dom. Neverjsdom— it is not a dependency of this workspace, so it passes locally via hoisted rootnode_modulesand fails in CI withCannot find package 'jsdom'. - Single file:
pnpm vitest --run src/web-blocks/paragraph.test.ts.
src/tailwind/stylesheets.generated.ts— generated byscripts/vendor-tailwind-v4-css.mjs.src/utils/vendor/**— vendored third-party code, kept verbatim, ESLint-ignored.dist/,docs/,src/drizzle/migrations-sqlite/— build/test output.scripts/subtree-{lib,pull,status}.mjs— the one-way subtree tooling; changing it changes how host repos pull. Readscripts/RUNBOOK.mdfirst.- The
dtsGroupsslicing intsup.config.ts(see Setup).
mainis the only long-lived branch. There is nodev..github/workflows/runs only in this standalone repo. In a host checkout GitHub reads the host's root.github/workflows, so these are inert there.ci.yml— lint, format, typecheck, unit tests, integration tests, build +publint, and commitlint on pull requests. One caveat: the format job checks only the files a pull request touches, because 298 files onmainare unformatted. A tree-widepnpm formatwould conflict with every subtree host carrying local edits, so do not run one casually. Thepre-commithook formats staged files so the backlog stops growing.pr-title.yml— pull requests are squash-merged, so the title becomes the commit subject and the changelog line. It must be a conventional commit.release-please.yml— maintains the standing release PR from the commits onmain; merging it bumpspackage.json, writesCHANGELOG.md, and tags.publish.yml— fires on the published GitHub release and runsnpm publish --provenance. Never publish or tag by hand.labeler.yml,stale.yml— repository housekeeping.
- Shared Node + pnpm setup lives in
.github/actions/setup. Add steps there, not in each job. - pnpm is the only supported package manager; the version comes from
packageManagerinpackage.json. - Likewise
.husky/hooks (commitlint, ESLint on staged files) fire only when committing in this repo. - Commit subjects become the
chaicorechangelog and are enforced by commitlint (@commitlint/config-conventional). Subtree hosts pull only and never push commits back, so this repo is the sole path into that history — outside contributions arrive as PRs here.