Skip to content

@tabula-css/merge

Tabula 的运行时合并:cn()resolve()dyn()。只读取注册表——导入图中不含 Tailwind——是该配置档案的数学核心。

安装

@tabula-css/merge 是一个运行时依赖——是根 README 快速上手部分的一部分:

bash
npm install @tabula-css/merge @tabula-css/preset

概览

给定 @tabula-css/registry 生成的注册表,cn() 确定性地决定两个相互竞争的工具类中哪一个胜出——没有启发式规则,运行时也不会去查询 Tailwind。正如概念 § 合并:全序,而非级联模拟所解释的,这次合并从不模拟浏览器的级联(特异性、源码顺序、!important);它对每个类的规范槽位执行一次纯粹的折叠(fold),顺序由注册表冻结的 rank 决定。resolve() 对单个元素执行同样的折叠,产出一个完整的、可检视的样式模型——一个智能体(或一个测试)可以运行这个算法来精确预测浏览器将渲染出什么。dyn() 是唯一被认可的逃生舱口,供令牌流水线永远无法预先烘焙进某个类的、真正动态的值(例如进度条的宽度)使用。

在调用这些函数之前,必须先用一个生成的注册表(通常是由 tabula build 写入的 .tabula/registry.json)对运行时进行一次配置。

导出

配置

ts
export function configure(input: string | RegistryFile): RegistryReader;
export function configureReader(reader: RegistryReader): RegistryReader;
export function getRegistry(): RegistryReader; // throws MissingRegistryError if unconfigured
export function isConfigured(): boolean;
export function reset(): void; // test isolation only

configure() 接受与 readRegistry() 完全相同的输入——一个已解析的 RegistryFile、一段 JSON 字符串,或一个文件系统路径——并且如果注册表的 Schema 版本与 @tabula-css/merge 当前这个构建版本所理解的不一致,就会抛出 RegistryReadError(携带一个 TAB-E302 诊断)。在应用启动时调用它一次:

ts
import { configure } from '@tabula-css/merge';
import registry from '../.tabula/registry.json';

configure(registry);

这个包中的其他每一个导出都读取最近一次被配置的注册表;在 configure() 之前调用 cn()/resolve()/dyn() 会抛出 MissingRegistryErrorTAB-E303)。

cn()

ts
export const MAX_CLASSES: number; // 512
export function cn(...inputs: ClassValue[]): string;
export function clearOpaqueWarnings(): void; // test isolation

合并函数。inputs 接受 clsx 风格的值——字符串、数字、null/undefined/false(会被跳过)、嵌套数组,以及其真值键会贡献其(按空白分隔的)类名的对象。cn() 会展平每一个片段,同时记住每个候选项所来自的顶层片段下标;对于每一个 (pseudoElement, condition, slot) 键,拥有它的类是片段更靠后的那一个,只有在同一个片段内部,注册表中更高的排名才会胜出。这正是使得 cn(base, className) 成为一个可靠的覆盖机制的原因:调用方的 className 片段无论其自身内部的排名如何,总会胜出其所占的槽位。

ts
import { cn } from '@tabula-css/merge';

cn('p-md', 'pt-sm');   // → "p-md pt-sm"   (later fragment overrides just padding-top)
cn('pt-sm', 'p-md');   // → "p-md"         (p-md is later AND covers padding-top; pt-sm owns nothing)
cn('bg-surface', className); // → the caller's className fragment always wins its slots

一条变体链hover:bg-accent-hoversm:hover:p-md)只有当它规范的、按升序排列的链前缀 × 该基础工具类所属的族群,是一个已声明的变体产物——或者它的精确类是一条链例外——时,才算作已注册。cn() 通过读取器的 hasChain()/hasChainClass() 访问器从注册表中读取这一点:一条不在已注册集合内的链不会产生 CSS(该预设只为已声明的产物发出规则),所以 cn() 会像对待任何其他未知类一样对待它,而不是悄悄放行——也就是下面的 TAB-E300 路径。参数化变体(group-*/peer-*/aria-*/data-*)不在封闭性的范围之内,这里也不对它们做门禁。

对于每一种输入,cn()生产环境中是全函数(total):一个未注册的类(TAB-E300,包括一条未声明的变体链)会不透明地原样通过,并附带一次性的 console.error;一个超大的候选列表(TAB-E305,超过 MAX_CLASSES)也照样会被合并。而在开发环境中,这两种情况都会改为抛出错误——UnknownClassErrorMaxClassesError——并附带一次合并可靠性自检(T2):cn() 会独立地按排名重新计算每个槽位的胜出者,如果注册表所声明的顺序与之不一致,就会抛出 MergeSoundnessErrorTAB-E301),这意味着注册表自身违反了自己的不变量。一个解析出非有限排名的类,在两种模式下都会抛出 RankIntegrityError——与普通的未知类不同,它没有正确的回退方案。clearOpaqueWarnings() 重置每进程一次的警告记录(供测试套件使用,而非应用代码)。

resolve()

ts
export function resolve(classString: string, axes?: AxisState, vars?: DynamicVars): ResolvedStyle;
export function resolveAxisValue(value: unknown, axes: AxisState): string;

resolve() 运行与 cn() 相同的、与顺序无关的折叠,但作用于单个类字符串,在给定的轴状态下(例如 { theme: 'dark' }),并返回一个完整的、可检视的模型,而不是一个合并后的字符串——没有级联模拟,也没有 DOM。与 cn() 不同,未知的类会被报告,而不是被抛出:resolve() 旨在对一个类字符串实际包含的内容建模,包括源码与注册表之间的漂移。

ts
export interface ResolvedStyle {
  readonly base: Readonly<Record<string, string>>;
  readonly conditions: readonly ResolvedCondition[]; // media / self / group / peer, by condition rank
  readonly ambient: Readonly<Record<string, { readonly value: string; readonly source: string }>>;
  readonly atomic: readonly ResolvedAtomic[];
  readonly dependencies: readonly ResolvedDependency[]; // declared group/peer/container dependencies
  readonly unknown: readonly string[];
  readonly warnings: readonly Diagnostic[]; // e.g. TAB-W401 for a registered exception
}
ts
import { resolve } from '@tabula-css/merge';

resolve('bg-surface hover:bg-surface-raised', { theme: 'dark' });
// → { base: { 'background-color': '#0b0b0c' },
//     conditions: [{ condition: 'hover', when: '&:where(:hover)', declarations: { 'background-color': '#1a1a1c' } }],
//     … }

可选的第三个参数 vars,为组件实时的 dyn() 贡献建模——一个 DynamicVars 记录,会以内联样式的优先级替换进去(高于类所设置的自定义属性,高于注册表的 initialValue),与内联样式在级联中所处的位置精确一致。它由 dyn() 自身校验,因此一个无效的变量会抛出与 dyn() 相同的错误。省略它会使每一个结果与不带动态值调用 resolve() 时字节级一致。resolveAxisValue() 是更底层的辅助函数,用来针对某个 AxisState 解析一个按轴映射(或字面量)的值——它被导出以供构建自己的局部模型的调用方使用。

dyn()

ts
export type DynamicVars = Readonly<Record<`--d-${string}`, string>>;
export const MAX_DYNAMIC_VALUE_LENGTH: number; // 256
export function dyn(vars: DynamicVars): Record<string, string>;

被认可的内联样式逃生舱口(草案 A §4.4)。dyn() 会依据注册表中的 --d-* 动态自定义属性校验每一个键(一个未注册的键在开发环境中会抛出 UnregisteredDynamicPropertyError/TAB-E304,在生产环境中会被丢弃并附带一次性警告),并依据一个受限的形状过滤器校验每一个值:它必须是字符串,长度不超过 MAX_DYNAMIC_VALUE_LENGTH,且不包含 ; { } " ' < > & \ @ 中的任何一个字符或控制字符——这些标点符号可能终止一条 CSS 声明,或从 style="…" 属性中转义出去。一个被拒绝的值在开发环境中会抛出 InvalidDynamicValueError/TAB-E306,在生产环境中则会被丢弃。类字符串本身保持静态且已注册(例如 w-progress);dyn() 只提供该类所读取的自定义属性值。

tsx
import { dyn, cn } from '@tabula-css/merge';

<div className={cn('w-progress')} style={dyn({ '--d-progress': `${percent}%` })} />

这个值过滤器刻意是一个形状过滤器,而不是一个 CSS 类型检查器——"73.4%" 对某个特定属性来说是否是合法的 <percentage>,实际上是由 @property 语法(在 tabula.config.json 中声明,并在构建时通过 TAB-E127 强制执行)来决定的。

variants()

ts
export interface VariantsConfig<V extends VariantGroups> {
  readonly base?: string;
  readonly variants: V;
  readonly compoundVariants?: readonly CompoundVariant<V>[];
  readonly defaultVariants?: VariantProps<V>;
}
export type VariantsFn<V extends VariantGroups> = (props?: VariantCallProps<V>) => string;
export function variants<V extends VariantGroups>(config: VariantsConfig<V>): VariantsFn<V>;

一个等价于 CVA、可静态分析的变体组合器。配置中的每一个类都是纯字面量字符串(因此 ESLint 插件的 sink 收集器能像读取 cva/tv 配置一样精确地读取它),组合过程通过 cn() 完成——因此其结果与任何其他 cn() 调用一样,携带相同的可靠性、规范化与幂等性保证。

ts
import { variants } from '@tabula-css/merge';

const button = variants({
  base: 'inline-flex rounded-md',
  variants: {
    intent: { primary: 'bg-brand ink-on-brand', ghost: 'ink-text' },
    size: { sm: 'px-sm', md: 'px-md' },
  },
  compoundVariants: [{ intent: 'primary', size: 'sm', class: 'gap-xs' }],
  defaultVariants: { intent: 'primary', size: 'md' },
});

button({ intent: 'ghost', className }); // caller's className is merged last, so it wins

某个选项如果指定了一个变体组没有定义的值,会在开发环境中抛出错误,在生产环境中被忽略(该变体组不贡献任何内容),与 cn() 那种”开发环境可见失败 / 生产环境开放失败”的立场一致。

错误

ts
export class TabulaMergeError extends Error {
  readonly code: string;
  readonly diagnostics: readonly Diagnostic[];
}

每一个抛出的错误都继承自 TabulaMergeError,并携带一个稳定的 TAB-Exxx 代码,加上来自 @tabula-css/core 的结构化 Diagnostic

错误代码抛出方触发时机
UnknownClassErrorTAB-E300cn()开发环境中出现一个未注册的类(附带基于 Levenshtein 距离的最接近建议)。
MergeSoundnessErrorTAB-E301cn()T2 开发环境自检发现注册表所声明的排名顺序与自身不一致。
RankIntegrityErrorTAB-E301cn()某个类解析出非有限的排名——在生产环境中也会触发,因为没有安全的回退方案。
MissingRegistryErrorTAB-E303getRegistry()configure() 之前调用了 cn()/resolve()/dyn()
MaxClassesErrorTAB-E305cn()开发环境中候选数量超过 MAX_CLASSES
UnregisteredDynamicPropertyErrorTAB-E304dyn()开发环境中某个键不是已注册的 --d-* 动态自定义属性。
InvalidDynamicValueErrorTAB-E306dyn()开发环境中某个值未通过形状过滤器(类型错误、过长、含不安全标点)。

类型

ts
export type ClassName = string & { readonly __tabulaClassName?: never };
export type ClassValue = string | number | boolean | null | undefined | ClassDictionary | ClassValue[];
export interface ClassDictionary { readonly [className: string]: boolean | null | undefined; }
export type { RegistryReader, RegistryFile } from '@tabula-css/registry/read';

ClassName 是一种文档性标记(其标记字段是可选的,因此一个普通字符串字面量仍然可以赋值给它),而非一种硬性的名义类型——它将某个 className 属性标记为一种受认可的直通值,供 ESLint 插件的 no-runtime-class-construction 规则识别,而无需调用方为每一个字符串都做包装。

更底层的辅助函数

供构建在这次合并之上的工具使用(ESLint 插件的 sink 分析、自定义类字符串检查器)而导出:parseToken()(依据一个注册表解析单个类令牌——变体链加上工具类——从不抛出)、flatten()cn() 内部使用的 clsx 风格输入展平函数)、levenshtein()/nearest()(基于编辑距离的“你是不是想输入……?”建议)、isDev()(该包中每一处守卫都使用的、基于 NODE_ENV 的开发/生产环境判断),以及 warnOnce()/clearWarnOnce()(共享的生产环境降级通道——每个唯一的键在整个进程生命周期内只触发一次 console.error)。

参见

Released under the MIT License.