Skip to content

@tabula-css/merge

Tabula의 런타임 병합: cn(), resolve(), dyn(). 레지스트리만 읽으며 — import 그래프에 Tailwind가 없습니다 — 프로파일의 수학적 심장부입니다.

설치

@tabula-css/merge는 런타임 의존성입니다 — 루트 README의 퀵스타트에 포함됩니다.

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

개요

@tabula-css/registry가 생성하는 레지스트리가 주어지면, cn()은 경쟁하는 두 유틸리티 클래스 중 어느 쪽이 이기는지를 결정론적으로 판단합니다 — 휴리스틱도, 런타임에 Tailwind를 참조하는 일도 없습니다. 핵심 개념 § 병합: 캐스케이드 시뮬레이션이 아닌 전순서가 설명하듯, 병합은 브라우저의 캐스케이드(명시도, 소스 순서, !important)를 결코 시뮬레이션하지 않습니다. 대신 레지스트리의 고정된 rank로 정렬된, 각 클래스의 정규 슬롯에 대한 순수한 폴드(fold)를 실행합니다. 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 문자열, 또는 파일시스템 경로 — 을 받아들이며, 레지스트리의 스키마 버전이 이 @tabula-css/merge 빌드가 이해하는 것과 일치하지 않으면 (TAB-E302 진단을 담은) RegistryReadError를 던집니다. 애플리케이션 시작 시 한 번 호출하십시오.

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

configure(registry);

이 패키지의 다른 모든 내보내기는 마지막으로 구성된 레지스트리를 읽습니다; configure() 이전에 cn()/resolve()/dyn()을 호출하면 MissingRegistryError(TAB-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) 키마다 그것을 소유하는 클래스는 더 나중 프래그먼트에 속한 클래스이며, 하나의 프래그먼트 안에서만 레지스트리의 **더 높은 순위(rank)**가 이깁니다. 이 덕분에 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-hover, sm:hover:p-md)은 그 정규 오름차순 rank 체인 접두사 × 기본 유틸리티의 패밀리가 선언된 변형 프로덕트일 때만, 또는 정확한 클래스가 체인 예외일 때만 등록된 것으로 취급됩니다. cn()은 이를 리더의 hasChain()/hasChainClass() 접근자를 통해 읽습니다: 등록된 집합 밖의 체인은 아무 CSS도 내지 않으므로(프리셋은 선언된 프로덕트에 대해서만 규칙을 냅니다), cn()은 이를 조용히 통과시키는 대신 다른 모든 알려지지 않은 클래스와 똑같이 취급합니다 — 아래의 TAB-E300 경로입니다. 파라메트릭 변형(group-*/peer-*/aria-*/data-*)은 폐쇄성의 범위 밖이며 여기서 게이팅되지 않습니다.

cn()은 **프로덕션에서는 모든 입력에 대해 전역적(total)**입니다: 알려지지 않은 클래스(TAB-E300, 선언되지 않은 변형 체인 포함)는 일회성 console.error와 함께 불투명하게(opaque) 통과되고, 크기가 초과된 후보 목록(TAB-E305, MAX_CLASSES 초과)도 그대로 병합됩니다. 개발 환경에서는 두 경우 모두 대신 던집니다 — UnknownClassError 또는 MaxClassesError — 그리고 병합 건전성(merge soundness) 자체 검사(T2)도 함께 수행합니다: cn()은 각 슬롯의 승자를 순위로 독립적으로 다시 계산하고, 레지스트리가 선언한 순서가 스스로와 어긋나면 MergeSoundnessError(TAB-E301)를 던지는데, 이는 레지스트리 자체가 자신의 불변식을 위반했다는 뜻입니다. 클래스가 유한하지 않은(non-finite) 순위로 파싱되면 두 모드 모두에서 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() 자체가 검증하므로, 잘못된 var는 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>;

허가된 인라인 스타일 탈출구입니다(Draft A §4.4). dyn()은 모든 키를 레지스트리의 --d-* 동적 커스텀 속성에 대해 검증하고(등록되지 않은 키는 개발 환경에서 UnregisteredDynamicPropertyError/TAB-E304를 던지고, 프로덕션에서는 일회성 경고와 함께 폐기됩니다), 모든 값을 유계(bounded)된 형태 필터에 대해 검증합니다: 문자열이어야 하고, 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>인지 여부는 (tabula.config.json에 선언되어 TAB-E127을 통해 빌드 시점에 강제되는) @property 구문이 실제로 결정하는 몫입니다.

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 플러그인의 싱크 수집기가 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()의 개발 환경에서는 가시적으로 실패하고 프로덕션에서는 열려서 실패하는(fail-open) 태도와 일치합니다.

오류

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

던져지는 모든 오류는 TabulaMergeError를 확장하며, 안정된 TAB-Exxx 코드와 @tabula-css/core의 구조화된 Diagnostic을 함께 담습니다.

오류코드던지는 곳시점
UnknownClassErrorTAB-E300cn()개발 환경에서 등록되지 않은 클래스(레벤슈타인 최근접 제안 포함).
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은 (마커가 선택적이므로 일반 문자열 리터럴도 여전히 대입 가능한) 문서적 브랜드(documentary brand)이지 강한 명목적(nominal) 타입이 아닙니다 — 이는 호출자가 모든 문자열을 감싸도록 요구하지 않으면서, className prop을 ESLint 플러그인의 no-runtime-class-construction 규칙이 인식하는 허가된 통과 값으로 표시합니다.

저수준 헬퍼

병합 위에 구축된 도구(ESLint 플러그인의 싱크 분석, 커스텀 클래스 문자열 검사기)를 위해 노출된 것들: parseToken()(하나의 클래스 토큰 — 변형 체인과 유틸리티 — 을 레지스트리에 대해 파싱하며 결코 던지지 않음), flatten()(cn()이 내부적으로 사용하는 clsx 스타일 입력 평탄화), levenshtein()/nearest()(편집 거리 기반 "혹시 이거였나요?" 제안), isDev()(이 패키지의 모든 가드가 사용하는 NODE_ENV 기반 개발/프로덕션 검사), warnOnce()/clearWarnOnce()(공유되는 프로덕션 저하 채널 — 고유 키당 평생 한 번의 console.error).

함께 보기

Released under the MIT License.