@tabula-css/registry
Tabula's registry generator (a two-backend oracle) and the Tailwind-free registry reader consumed by merge, the linters, and the MCP server.
Install
Most projects reach @tabula-css/registry only transitively: @tabula-css/cli depends on it to run tabula build, and @tabula-css/merge depends on its ./read entry point at runtime. Install it directly only if you are building custom tooling that reads a generated registry.json yourself (a custom lint rule, a script, an editor extension) — for that, import @tabula-css/registry/read only:
npm install @tabula-css/registryThe @tabula-css/registry/generate entry point (used internally by the CLI to produce registry.json) pulls in Tailwind's own toolchain; @tabula-css/registry/read never does.
Overview
The registry is Tabula's closed vocabulary made concrete — see Concepts § the registry is ground truth. This package ships both halves of that story as two separate subpath exports, deliberately kept apart:
@tabula-css/registry/generatederivesregistry.jsonby parsing Tailwind's own compiled CSS output through one of two independent backends (the design-system API, or a PostCSS probe-sheet walk), cross-checked against each other in CI as a conformance oracle, rather than re-implementing Tailwind's utility grammar.@tabula-css/registry/readis the Tailwind-free reader every other consumer imports:@tabula-css/merge's runtime, the ESLint and stylelint plugins, and the MCP server. Keeping Tailwind out of this half's import graph is a deliberate dependency law — a Tailwind API break can break generation loudly, but must never be able to break enforcement silently.
Exports
@tabula-css/registry/read
export function readRegistry(input: string | RegistryFile, options?: ReadOptions): RegistryReader;
export interface ReadOptions {
/** Skip schema validation — only for a registry this process just produced. Default true. */
readonly validate?: boolean;
}
export class RegistryReadError extends Error {
readonly diagnostics: readonly Diagnostic[];
}
export function validateManifest(manifest: unknown): string | null;
export const REGISTRY_SCHEMA_VERSION: number; // 3readRegistry() loads and validates a registry, returning a RegistryReader. input is one of a parsed RegistryFile object, a JSON string, or a filesystem path to registry.json (a path is recognized positively — a single line, no newline, under 4096 characters — rather than by "doesn't look like JSON", so a truncated or empty file gets a TAB-E303 diagnostic instead of a confusing raw ENOENT). It throws RegistryReadError on malformed JSON, a schema violation, or a registrySchemaVersion the reader does not understand (TAB-E302).
import { readRegistry } from '@tabula-css/registry/read';
const reader = readRegistry('.tabula/registry.json');
reader.has('bg-surface'); // truevalidateManifest() validates a parsed manifest.json against core's schema (the integrity root — see Concepts), returning a joined error string or null when valid.
RegistryReader
export class RegistryReader {
readonly registry: RegistryFile;
has(className: string): boolean;
getClass(className: string): RegistryClass | undefined;
isAtomic(className: string): boolean;
classNames(): readonly string[];
slotProperty(slot: number): string | undefined;
classBySlot(slot: number): readonly string[];
conflictsOf(className: string): readonly string[];
exceptionOf(className: string): RegistryException | undefined;
isException(className: string): boolean;
exceptions(): Readonly<Record<string, RegistryException>>;
isBanned(className: string): boolean;
banOf(className: string): BanEntry | undefined;
variantsTable(): Readonly<Record<string, RegistryVariant>>;
variantProducts(): Readonly<Record<string, readonly string[]>>;
hasChain(chainPrefix: string, family: string): boolean;
hasChainClass(className: string): boolean;
isStale(expectedSourceHash: string): boolean;
isStaleAgainstManifest(manifest: ManifestFile): boolean;
}The typed accessor surface every consumer of a registry uses instead of indexing registry.json by hand. A few worth calling out:
conflictsOf(className)returns every class name that shares at least one canonical slot withclassNameunder the base condition — the precomputed indexcn()andresolve()build their slot-ownership logic on top of.isAtomic(className)reports whether a class is an atomic reset utility (sr-only,not-prose, …) — one that keys no slots and is never dropped or shadow-resolved by the merge.isStale(expectedSourceHash)/isStaleAgainstManifest(manifest)compare the registry's recordedsourceHashagainst a freshly computed one (the Concepts staleness triangle), which is howtabula doctorand the MCP server's drift check decide a registry needs rebuilding.variantProducts()/hasChain(chainPrefix, family)/hasChainClass(className)are the variant-closure accessors (schema v3).variantProducts()returns the declared products (chain prefix → sorted family list,{}on a legacy registry);hasChain()is the runtime membership gatecn()calls to decide whether a variant chain is registered (true ifffamilyis invariantProducts[chainPrefix]);hasChainClass()answers the same question for an exact single-class chain exception minted bytabula except add --chain. All three are null-prototype-hardened, so a user-controlled prefix or class name ('__proto__','constructor') can never match an inherited key.
Every map on registry (classes, variants, customProperties, exceptions, …) is rebuilt with a null prototype internally, so a lookup like reader.registry.classes['constructor'] cannot accidentally resolve to Object.prototype's own constructor function — every string-keyed lookup across this reader is own-property-safe.
@tabula-css/registry/generate
export interface BuildRegistryOptions {
readonly tokensJson: string;
readonly configJson: string;
readonly profileVersion?: string;
readonly backend?: 'A' | 'B'; // default 'B', the oracle
readonly packageVersions?: Readonly<Record<string, string>>;
}
export interface BuildRegistryResult {
readonly ok: boolean;
readonly diagnostics: readonly Diagnostic[];
readonly result?: BackendResult;
readonly model?: ResolvedModel;
}
export function buildRegistry(opts: BuildRegistryOptions): Promise<BuildRegistryResult>;
export interface ConformanceResult {
readonly equal: boolean;
readonly a: BackendResult;
readonly b: BackendResult;
readonly diff?: string; // the first differing JSON path, when they diverge
}
export function checkConformance(opts: BuildRegistryOptions): Promise<ConformanceResult>;buildRegistry() runs the resolved-token pipeline from @tabula-css/tokens and then one backend to produce the committed artifacts (registry.json, profile.css, manifest.json). It fails closed: if the token pipeline reports an error, no backend runs and nothing is produced. This is what tabula build calls internally.
import { readFileSync } from 'node:fs';
import { buildRegistry } from '@tabula-css/registry/generate';
const { ok, result, diagnostics } = await buildRegistry({
tokensJson: readFileSync('tokens/base.tokens.json', 'utf8'),
configJson: readFileSync('tabula.config.json', 'utf8'),
});checkConformance() runs both backends on the same inputs and asserts their registries are byte-identical (canonical JSON) — the conformance oracle CI runs to guarantee Backend A's design-system path and Backend B's real-build path never quietly diverge.
Also exported from ./generate, for lower-level use (mainly by @tabula-css/cli's own build pipeline): assembleRegistry (turns a resolved model plus a walked CSS result into the three committed artifacts), buildProbeCss/walkProbeCss (the PostCSS probe-sheet backend's building blocks), and the BackendResult type. REGISTRY_SCHEMA_VERSION is also re-exported here so a generator and a reader never disagree about which version they're looking at.
See also
@tabula-css/tokens— produces theResolvedModelthe generator turns into a registry.@tabula-css/merge— the runtime that configures itself from aRegistryReader.@tabula-css/core— theregistrySchema/manifestSchemathis reader validates against.- Concepts — what a registry class entry looks like and why the registry is ground truth.