From 98fb54243048e4583040af6e02c7f351f7f73ea2 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 22:59:56 +0900 Subject: [PATCH 1/8] feat(system-core): add system definition api --- packages/new-v2/system-core/eslint.config.mjs | 4 + packages/new-v2/system-core/package.json | 38 + packages/new-v2/system-core/src/index.ts | 76 ++ .../new-v2/system-core/src/keyframes/api.ts | 15 + .../new-v2/system-core/src/keyframes/types.ts | 15 + packages/new-v2/system-core/src/preset/api.ts | 7 + .../new-v2/system-core/src/preset/types.ts | 27 + .../system-core/src/recipe/recipe/api.ts | 46 ++ .../system-core/src/recipe/recipe/types.ts | 27 + .../system-core/src/recipe/slotRecipe/api.ts | 53 ++ .../src/recipe/slotRecipe/types.ts | 35 + .../new-v2/system-core/src/recipe/types.ts | 22 + packages/new-v2/system-core/src/style/css.ts | 53 ++ .../new-v2/system-core/src/style/input.ts | 158 +++++ .../new-v2/system-core/src/style/reference.ts | 38 + .../new-v2/system-core/src/style/value.ts | 60 ++ packages/new-v2/system-core/src/system/api.ts | 7 + .../new-v2/system-core/src/system/types.ts | 18 + .../src/token/composite/animation.ts | 22 + .../src/token/composite/textStyle.ts | 24 + .../system-core/src/token/primitive/api.ts | 7 + .../system-core/src/token/primitive/types.ts | 39 ++ .../system-core/src/token/semantic/api.ts | 7 + .../system-core/src/token/semantic/types.ts | 17 + .../new-v2/system-core/src/token/types.ts | 61 ++ .../new-v2/system-core/src/token/utils.ts | 25 + .../new-v2/system-core/test/core.type.test.ts | 647 ++++++++++++++++++ packages/new-v2/system-core/tsconfig.json | 8 + packages/new-v2/system-core/tsdown.config.ts | 16 + packages/new-v2/system-core/vitest.config.ts | 9 + 30 files changed, 1581 insertions(+) create mode 100644 packages/new-v2/system-core/eslint.config.mjs create mode 100644 packages/new-v2/system-core/package.json create mode 100644 packages/new-v2/system-core/src/index.ts create mode 100644 packages/new-v2/system-core/src/keyframes/api.ts create mode 100644 packages/new-v2/system-core/src/keyframes/types.ts create mode 100644 packages/new-v2/system-core/src/preset/api.ts create mode 100644 packages/new-v2/system-core/src/preset/types.ts create mode 100644 packages/new-v2/system-core/src/recipe/recipe/api.ts create mode 100644 packages/new-v2/system-core/src/recipe/recipe/types.ts create mode 100644 packages/new-v2/system-core/src/recipe/slotRecipe/api.ts create mode 100644 packages/new-v2/system-core/src/recipe/slotRecipe/types.ts create mode 100644 packages/new-v2/system-core/src/recipe/types.ts create mode 100644 packages/new-v2/system-core/src/style/css.ts create mode 100644 packages/new-v2/system-core/src/style/input.ts create mode 100644 packages/new-v2/system-core/src/style/reference.ts create mode 100644 packages/new-v2/system-core/src/style/value.ts create mode 100644 packages/new-v2/system-core/src/system/api.ts create mode 100644 packages/new-v2/system-core/src/system/types.ts create mode 100644 packages/new-v2/system-core/src/token/composite/animation.ts create mode 100644 packages/new-v2/system-core/src/token/composite/textStyle.ts create mode 100644 packages/new-v2/system-core/src/token/primitive/api.ts create mode 100644 packages/new-v2/system-core/src/token/primitive/types.ts create mode 100644 packages/new-v2/system-core/src/token/semantic/api.ts create mode 100644 packages/new-v2/system-core/src/token/semantic/types.ts create mode 100644 packages/new-v2/system-core/src/token/types.ts create mode 100644 packages/new-v2/system-core/src/token/utils.ts create mode 100644 packages/new-v2/system-core/test/core.type.test.ts create mode 100644 packages/new-v2/system-core/tsconfig.json create mode 100644 packages/new-v2/system-core/tsdown.config.ts create mode 100644 packages/new-v2/system-core/vitest.config.ts diff --git a/packages/new-v2/system-core/eslint.config.mjs b/packages/new-v2/system-core/eslint.config.mjs new file mode 100644 index 00000000..f455be36 --- /dev/null +++ b/packages/new-v2/system-core/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@jongh/eslint/base" + +/** @type {import("eslint").Linter.Config[]} */ +export default [...config] diff --git a/packages/new-v2/system-core/package.json b/packages/new-v2/system-core/package.json new file mode 100644 index 00000000..d38909c5 --- /dev/null +++ b/packages/new-v2/system-core/package.json @@ -0,0 +1,38 @@ +{ + "name": "@jongh/new-v2-system-core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "type-safe하게 토큰을 선언할수있는 API를 제공합니다.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "package.json" + ], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "tsdown", + "lint": "eslint --max-warnings 0 --no-warn-ignored .", + "check-type": "tsgo --noEmit", + "test": "pnpm exec vitest run" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "devDependencies": { + "@types/node": "^22.8.6", + "@jongh/eslint": "workspace:*", + "@jongh/tsconfig": "workspace:*", + "tsdown": "catalog:" + }, + "dependencies": { + "csstype": "^3.1.3" + } +} diff --git a/packages/new-v2/system-core/src/index.ts b/packages/new-v2/system-core/src/index.ts new file mode 100644 index 00000000..ef28b0e9 --- /dev/null +++ b/packages/new-v2/system-core/src/index.ts @@ -0,0 +1,76 @@ +export { defineKeyframes } from "./keyframes/api.ts" +export type { + KeyframeDeclaration, + KeyframesDefinition, + KeyframesDefinitionMap, + KeyframeSelector, + KeyframesName, + KeyframesReference, +} from "./keyframes/types.ts" +export { definePreset } from "./preset/api.ts" +export type { + SystemCompositesDefinition, + SystemPresetDefinition, + SystemThemeDefinition, + SystemThemeInput, +} from "./preset/types.ts" +export { defineRecipe } from "./recipe/recipe/api.ts" +export type { + RecipeCompoundVariant, + RecipeDefinition, + RecipeVariantRecord, +} from "./recipe/recipe/types.ts" +export { defineSlotRecipe } from "./recipe/slotRecipe/api.ts" +export type { + SlotRecipeCompoundVariant, + SlotRecipeDefinition, + SlotRecipeVariantRecord, +} from "./recipe/slotRecipe/types.ts" +export type { + AnyRecipeDefinition, + DeepPartial, + RecipeInputDefinition, +} from "./recipe/types.ts" +export type { StyleObject, StyleScalar } from "./style/input.ts" +export { styleTokenReferenceMap } from "./style/reference.ts" +export { defineSystem } from "./system/api.ts" +export type { + SystemConfigDefinition, + SystemDefinition, +} from "./system/types.ts" +export type { + AnimationDefinition, + AnimationDefinitionMap, + AnimationReference, + AnimationValue, +} from "./token/composite/animation.ts" +export { defineAnimations } from "./token/composite/animation.ts" +export type { + TextStyleDefinition, + TextStyleGroup, + TextStyleReference, + TextStyleValue, +} from "./token/composite/textStyle.ts" +export { defineTextStyles } from "./token/composite/textStyle.ts" +export { definePrimitiveTokens } from "./token/primitive/api.ts" +export type { + PrimitiveFamilyPath, + PrimitiveTokenPath, + PrimitiveTokensDefinition, + TokenFamilyName, +} from "./token/primitive/types.ts" +export { defineSemanticTokens } from "./token/semantic/api.ts" +export type { + SemanticTokenNode, + SemanticTokensDefinition, + SemanticTokenTree, + SemanticTokenValue, +} from "./token/semantic/types.ts" +export type { + PrimitiveTokenPathByFamily, + SemanticTokenPathByFamily, + TokenPathByFamily, + TokenPathMap, + TokenSource, +} from "./token/types.ts" +export type { TokenGroup } from "./token/utils.ts" diff --git a/packages/new-v2/system-core/src/keyframes/api.ts b/packages/new-v2/system-core/src/keyframes/api.ts new file mode 100644 index 00000000..5bb14b77 --- /dev/null +++ b/packages/new-v2/system-core/src/keyframes/api.ts @@ -0,0 +1,15 @@ +import type { KeyframeDeclaration, KeyframeSelector } from "./types.ts" + +type KeyframesInput = { + readonly [TName in keyof TKeyframes]: { + readonly [TSelector in keyof TKeyframes[TName]]: TSelector extends KeyframeSelector + ? KeyframeDeclaration + : never + } +} + +export function defineKeyframes< + const TKeyframes extends KeyframesInput, +>(keyframes: TKeyframes): TKeyframes { + return keyframes +} diff --git a/packages/new-v2/system-core/src/keyframes/types.ts b/packages/new-v2/system-core/src/keyframes/types.ts new file mode 100644 index 00000000..a49fef47 --- /dev/null +++ b/packages/new-v2/system-core/src/keyframes/types.ts @@ -0,0 +1,15 @@ +export type KeyframeSelector = "from" | "to" | `${number}%` + +export type KeyframeDeclaration = Record + +export type KeyframesDefinition = Partial< + Record +> + +export type KeyframesDefinitionMap = Record + +export type KeyframesName = + keyof TKeyframes & string + +export type KeyframesReference = + `keyframes.${KeyframesName}` diff --git a/packages/new-v2/system-core/src/preset/api.ts b/packages/new-v2/system-core/src/preset/api.ts new file mode 100644 index 00000000..afb33c9a --- /dev/null +++ b/packages/new-v2/system-core/src/preset/api.ts @@ -0,0 +1,7 @@ +import type { SystemPresetDefinition } from "./types.ts" + +export function definePreset( + definition: TPreset, +): TPreset { + return definition +} diff --git a/packages/new-v2/system-core/src/preset/types.ts b/packages/new-v2/system-core/src/preset/types.ts new file mode 100644 index 00000000..054cb1ec --- /dev/null +++ b/packages/new-v2/system-core/src/preset/types.ts @@ -0,0 +1,27 @@ +import type { KeyframesDefinitionMap } from "../keyframes/types.ts" +import type { DeepPartial, RecipeInputDefinition } from "../recipe/types.ts" +import type { AnimationDefinitionMap } from "../token/composite/animation.ts" +import type { TextStyleGroup } from "../token/composite/textStyle.ts" +import type { TokenSource } from "../token/types.ts" + +export interface SystemCompositesDefinition { + textStyles?: DeepPartial + animations?: DeepPartial +} + +export interface SystemThemeDefinition< + TStyleSource extends TokenSource = TokenSource, +> { + primitiveTokens?: DeepPartial + semanticTokens?: DeepPartial + keyframes?: DeepPartial + composites?: SystemCompositesDefinition + recipes?: Record +} + +export type SystemThemeInput = + SystemThemeDefinition + +export type SystemPresetDefinition< + TStyleSource extends TokenSource = TokenSource, +> = SystemThemeDefinition diff --git a/packages/new-v2/system-core/src/recipe/recipe/api.ts b/packages/new-v2/system-core/src/recipe/recipe/api.ts new file mode 100644 index 00000000..9dd65c1c --- /dev/null +++ b/packages/new-v2/system-core/src/recipe/recipe/api.ts @@ -0,0 +1,46 @@ +import type { StyleObject } from "../../style/input.ts" +import type { + RecipeCompoundVariant, + RecipeVariantRecord, + RecipeVariantSelection, +} from "./types.ts" + +type RecipeDefinitionInput< + TName extends string, + TBase extends StyleObject, + TVariants extends RecipeVariantRecord, + TDefaultVariants extends RecipeVariantSelection, + TCompoundVariants extends Array> | undefined, +> = { + name: TName + base: TBase + variants: TVariants + compoundVariants?: TCompoundVariants + defaultVariants: TDefaultVariants +} + +export function defineRecipe< + const TName extends string, + const TBase extends StyleObject, + const TVariants extends RecipeVariantRecord, + const TDefaultVariants extends RecipeVariantSelection>, + const TCompoundVariants extends + | Array>> + | undefined = undefined, +>( + definition: RecipeDefinitionInput< + TName, + TBase, + TVariants, + TDefaultVariants, + TCompoundVariants + >, +): RecipeDefinitionInput< + TName, + TBase, + TVariants, + TDefaultVariants, + TCompoundVariants +> { + return definition +} diff --git a/packages/new-v2/system-core/src/recipe/recipe/types.ts b/packages/new-v2/system-core/src/recipe/recipe/types.ts new file mode 100644 index 00000000..5ee63daa --- /dev/null +++ b/packages/new-v2/system-core/src/recipe/recipe/types.ts @@ -0,0 +1,27 @@ +import type { StyleObject } from "../../style/input.ts" + +export type RecipeVariantRecord = Record> + +export type RecipeVariantSelection< + TVariants extends Record>, +> = Partial<{ + [TVariantName in keyof TVariants & string]: keyof TVariants[TVariantName] & + string +}> + +export interface RecipeCompoundVariant< + TVariants extends RecipeVariantRecord = RecipeVariantRecord, +> { + when: RecipeVariantSelection + css: StyleObject +} + +export interface RecipeDefinition< + TVariants extends RecipeVariantRecord = RecipeVariantRecord, +> { + name: string + base: StyleObject + variants: TVariants + compoundVariants?: Array> + defaultVariants: RecipeVariantSelection +} diff --git a/packages/new-v2/system-core/src/recipe/slotRecipe/api.ts b/packages/new-v2/system-core/src/recipe/slotRecipe/api.ts new file mode 100644 index 00000000..d7ea9807 --- /dev/null +++ b/packages/new-v2/system-core/src/recipe/slotRecipe/api.ts @@ -0,0 +1,53 @@ +import type { StyleObject } from "../../style/input.ts" +import type { + SlotRecipeCompoundVariant, + SlotRecipeVariantRecord, + SlotRecipeVariantSelection, +} from "./types.ts" + +type SlotRecipeDefinitionInput< + TName extends string, + TSlots extends readonly string[], + TBase extends Partial>, + TVariants extends SlotRecipeVariantRecord, + TDefaultVariants extends SlotRecipeVariantSelection, + TCompoundVariants extends + | Array> + | undefined, +> = { + name: TName + slots: TSlots + base: TBase + variants: TVariants + compoundVariants?: TCompoundVariants + defaultVariants: TDefaultVariants +} + +export function defineSlotRecipe< + const TName extends string, + const TSlots extends readonly string[], + const TBase extends Partial>, + const TVariants extends SlotRecipeVariantRecord, + const TDefaultVariants extends SlotRecipeVariantSelection>, + const TCompoundVariants extends + | Array>> + | undefined = undefined, +>( + definition: SlotRecipeDefinitionInput< + TName, + TSlots, + TBase, + TVariants, + TDefaultVariants, + TCompoundVariants + >, +): SlotRecipeDefinitionInput< + TName, + TSlots, + TBase, + TVariants, + TDefaultVariants, + TCompoundVariants +> { + return definition +} diff --git a/packages/new-v2/system-core/src/recipe/slotRecipe/types.ts b/packages/new-v2/system-core/src/recipe/slotRecipe/types.ts new file mode 100644 index 00000000..3b2173d3 --- /dev/null +++ b/packages/new-v2/system-core/src/recipe/slotRecipe/types.ts @@ -0,0 +1,35 @@ +import type { StyleObject } from "../../style/input.ts" + +export type SlotRecipeVariantRecord = Record< + string, + Record>> +> + +export type SlotRecipeVariantSelection< + TVariants extends Record>, +> = Partial<{ + [TVariantName in keyof TVariants & string]: keyof TVariants[TVariantName] & + string +}> + +export interface SlotRecipeCompoundVariant< + TSlotName extends string, + TVariants extends SlotRecipeVariantRecord = + SlotRecipeVariantRecord, +> { + when: SlotRecipeVariantSelection + css: Partial> +} + +export interface SlotRecipeDefinition< + TSlotName extends string, + TVariants extends SlotRecipeVariantRecord = + SlotRecipeVariantRecord, +> { + name: string + slots: readonly TSlotName[] + base: Partial> + variants: TVariants + compoundVariants?: Array> + defaultVariants: SlotRecipeVariantSelection +} diff --git a/packages/new-v2/system-core/src/recipe/types.ts b/packages/new-v2/system-core/src/recipe/types.ts new file mode 100644 index 00000000..936bb8fb --- /dev/null +++ b/packages/new-v2/system-core/src/recipe/types.ts @@ -0,0 +1,22 @@ +import type { RecipeDefinition, RecipeVariantRecord } from "./recipe/types.ts" +import type { + SlotRecipeDefinition, + SlotRecipeVariantRecord, +} from "./slotRecipe/types.ts" + +export type AnyRecipeDefinition = + | RecipeDefinition + | SlotRecipeDefinition> + +export type DeepPartial = + T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { + [K in keyof T]?: DeepPartial + } + : T + +export type RecipeInputDefinition = + | DeepPartial> + | DeepPartial>> diff --git a/packages/new-v2/system-core/src/style/css.ts b/packages/new-v2/system-core/src/style/css.ts new file mode 100644 index 00000000..ed13f220 --- /dev/null +++ b/packages/new-v2/system-core/src/style/css.ts @@ -0,0 +1,53 @@ +import type * as CSS from "csstype" + +export type ClosedCssValue = TValue extends string + ? string extends TValue + ? never + : TValue + : TValue + +export type CssVarFunction = `var(--${string})` +export type CssMathFunction = + | `calc(${string})` + | `min(${string})` + | `max(${string})` + | `clamp(${string})` +export type CssUrlFunction = `url(${string})` +export type CssQuotedString = `"${string}"` | `'${string}'` +export type CssHexColor = `#${string}` +export type CssColorFunction = + | `rgb(${string})` + | `rgba(${string})` + | `hsl(${string})` + | `hsla(${string})` + | `oklch(${string})` +export type TimeLiteral = `${number}ms` | `${number}s` +export type NumberLiteral = `${number}` +export type LengthLiteral = + | `${number}px` + | `${number}rem` + | `${number}em` + | `${number}%` + | `${number}vh` + | `${number}vw` + | `${number}dvh` + | `${number}dvw` + | `${number}ch` +export type ZeroLiteral = "0" + +export type NumberValue = number | NumberLiteral +export type LengthValue = + | ZeroLiteral + | LengthLiteral + | CssVarFunction + | CssMathFunction +export type DimensionValue = ClosedCssValue> +export type ColorValueLiteral = + | ClosedCssValue + | CssHexColor + | CssColorFunction + | CssVarFunction +export type TimingFunctionValue = + | ClosedCssValue + | `cubic-bezier(${string})` + | `steps(${string})` diff --git a/packages/new-v2/system-core/src/style/input.ts b/packages/new-v2/system-core/src/style/input.ts new file mode 100644 index 00000000..3ea4f6f5 --- /dev/null +++ b/packages/new-v2/system-core/src/style/input.ts @@ -0,0 +1,158 @@ +import type * as CSS from "csstype" + +import type { + ClosedCssValue, + CssQuotedString, + CssUrlFunction, + CssVarFunction, + DimensionValue, + LengthValue, + NumberValue, +} from "./css.ts" +import type { StyleInputValueMap } from "./value.ts" + +interface StyleShorthandInputMap { + p: StyleInputValueMap["spacing"] + px: StyleInputValueMap["spacing"] + py: StyleInputValueMap["spacing"] + pt: StyleInputValueMap["spacing"] + pr: StyleInputValueMap["spacing"] + pb: StyleInputValueMap["spacing"] + pl: StyleInputValueMap["spacing"] + m: StyleInputValueMap["spacing"] + mx: StyleInputValueMap["spacing"] + my: StyleInputValueMap["spacing"] + w: StyleInputValueMap["spacing"] + h: StyleInputValueMap["spacing"] + minW: StyleInputValueMap["spacing"] + minH: StyleInputValueMap["spacing"] + maxW: StyleInputValueMap["spacing"] + maxH: StyleInputValueMap["spacing"] + bg: StyleInputValueMap["color"] + rounded: StyleInputValueMap["radius"] +} + +export interface StyleInputMap { + alignItems: ClosedCssValue + animation: StyleInputValueMap["animation"] + animationDuration: StyleInputValueMap["motion"]["duration"] + animationFillMode: ClosedCssValue + animationName: CSS.Property.AnimationName | CssVarFunction + animationTimingFunction: StyleInputValueMap["motion"]["easing"] + appearance: ClosedCssValue + backgroundColor: StyleInputValueMap["color"] + backgroundImage: + | ClosedCssValue + | CssUrlFunction + | CssVarFunction + backgroundPosition: ClosedCssValue< + CSS.Property.BackgroundPosition + > + backgroundRepeat: ClosedCssValue + border: CSS.Property.Border + borderBottomColor: StyleInputValueMap["color"] + borderBottomStyle: ClosedCssValue + borderBottomWidth: ClosedCssValue> + borderColor: StyleInputValueMap["color"] + borderRadius: StyleInputValueMap["radius"] + borderStyle: ClosedCssValue + borderWidth: ClosedCssValue> + bottom: StyleInputValueMap["spacing"] + boxShadow: StyleInputValueMap["shadow"] + boxSizing: ClosedCssValue + color: StyleInputValueMap["color"] + colorScheme: ClosedCssValue | "light dark" + content: ClosedCssValue | CssQuotedString + cursor: ClosedCssValue + display: ClosedCssValue + flex: ClosedCssValue> + flexDirection: ClosedCssValue + flexGrow: CSS.Property.FlexGrow + flexShrink: CSS.Property.FlexShrink + fontFamily: + | ClosedCssValue + | CssQuotedString + | CssVarFunction + | `${string}, ${string}` + fontSize: StyleInputValueMap["fontSize"] + fontWeight: ClosedCssValue | NumberValue + gap: StyleInputValueMap["spacing"] + height: StyleInputValueMap["spacing"] + inset: StyleInputValueMap["spacing"] + justifyContent: ClosedCssValue + left: StyleInputValueMap["spacing"] + lineHeight: ClosedCssValue> + margin: StyleInputValueMap["spacing"] + marginBlock: StyleInputValueMap["spacing"] + marginBottom: StyleInputValueMap["spacing"] + marginInline: StyleInputValueMap["spacing"] + marginLeft: StyleInputValueMap["spacing"] + marginRight: StyleInputValueMap["spacing"] + marginTop: StyleInputValueMap["spacing"] + maskImage: + | ClosedCssValue + | CssUrlFunction + | CssVarFunction + maskPosition: ClosedCssValue> + maskRepeat: ClosedCssValue + maxHeight: StyleInputValueMap["spacing"] + maxWidth: StyleInputValueMap["spacing"] + minHeight: StyleInputValueMap["spacing"] + minWidth: StyleInputValueMap["spacing"] + objectFit: ClosedCssValue + opacity: CSS.Property.Opacity + outline: CSS.Property.Outline + outlineOffset: DimensionValue + overflow: ClosedCssValue + padding: StyleInputValueMap["spacing"] + paddingBlock: StyleInputValueMap["spacing"] + paddingBottom: StyleInputValueMap["spacing"] + paddingInline: StyleInputValueMap["spacing"] + paddingLeft: StyleInputValueMap["spacing"] + paddingRight: StyleInputValueMap["spacing"] + paddingTop: StyleInputValueMap["spacing"] + pointerEvents: ClosedCssValue + position: ClosedCssValue + right: StyleInputValueMap["spacing"] + textDecoration: ClosedCssValue> + textStyle: StyleInputValueMap["textStyle"] + top: StyleInputValueMap["spacing"] + touchAction: ClosedCssValue + transform: ClosedCssValue | CssVarFunction + transition: CSS.Property.Transition + transitionDuration: StyleInputValueMap["motion"]["duration"] + transitionProperty: CSS.Property.TransitionProperty + transitionTimingFunction: StyleInputValueMap["motion"]["easing"] + userSelect: ClosedCssValue + visibility: ClosedCssValue + whiteSpace: ClosedCssValue + width: StyleInputValueMap["spacing"] + zIndex: NumberValue +} + +export type StyleInputKey = keyof StyleInputMap & string + +export type StyleScalar = StyleInputMap[keyof StyleInputMap] + +export type PseudoAlias = + | "_active" + | "_checked" + | "_closed" + | "_disabled" + | "_focus" + | "_focusVisible" + | "_focusWithin" + | "_hover" + | "_open" + | "_selected" + +export type NestedStyleKey = + | PseudoAlias + | `&${string}` + | `@container ${string}` + | `@media ${string}` + | `@supports ${string}` + +export type StyleObject = Partial & { + [K in NestedStyleKey]?: StyleObject +} diff --git a/packages/new-v2/system-core/src/style/reference.ts b/packages/new-v2/system-core/src/style/reference.ts new file mode 100644 index 00000000..10413ae3 --- /dev/null +++ b/packages/new-v2/system-core/src/style/reference.ts @@ -0,0 +1,38 @@ +export const styleTokenReferenceMap = { + color: ["backgroundColor", "borderBottomColor", "borderColor", "color"], + spacing: [ + "bottom", + "gap", + "height", + "inset", + "left", + "margin", + "marginBlock", + "marginBottom", + "marginInline", + "marginLeft", + "marginRight", + "marginTop", + "maxHeight", + "maxWidth", + "minHeight", + "minWidth", + "padding", + "paddingBlock", + "paddingBottom", + "paddingInline", + "paddingLeft", + "paddingRight", + "paddingTop", + "right", + "top", + "width", + ], + radius: ["borderRadius"], + shadow: ["boxShadow"], + motion: { + duration: ["animationDuration", "transitionDuration"], + easing: ["animationTimingFunction", "transitionTimingFunction"], + }, + fontSize: ["fontSize"], +} as const diff --git a/packages/new-v2/system-core/src/style/value.ts b/packages/new-v2/system-core/src/style/value.ts new file mode 100644 index 00000000..446916d5 --- /dev/null +++ b/packages/new-v2/system-core/src/style/value.ts @@ -0,0 +1,60 @@ +import type * as CSS from "csstype" + +import type { + ClosedCssValue, + ColorValueLiteral, + CssVarFunction, + DimensionValue, + LengthValue, + TimeLiteral, + TimingFunctionValue, +} from "./css.ts" + +export type StyleAnimationValue = `animation.${string}` | CssVarFunction + +export type StyleColorValue = ColorValueLiteral | `color.${string}` + +export type StyleSpacingValue = DimensionValue | `spacing.${string}` + +export type StyleRadiusValue = + | ClosedCssValue> + | `radius.${string}` + +export type StyleShadowLiteral = + | `${LengthValue} ${LengthValue}${string}` + | `inset ${LengthValue} ${LengthValue}${string}` + +export type StyleShadowValue = + | ClosedCssValue + | CssVarFunction + | StyleShadowLiteral + | `shadow.${string}` + +export type StyleDurationValue = + | ClosedCssValue> + | `motion.duration.${string}` + +export type StyleEasingValue = + | TimingFunctionValue + | CssVarFunction + | `motion.easing.${string}` + +export type StyleFontSizeValue = + | ClosedCssValue> + | `fontSize.${string}` + +export type StyleTextStyleValue = `textStyle.${string}` + +export type StyleInputValueMap = { + animation: StyleAnimationValue + color: StyleColorValue + spacing: StyleSpacingValue + radius: StyleRadiusValue + shadow: StyleShadowValue + motion: { + duration: StyleDurationValue + easing: StyleEasingValue + } + fontSize: StyleFontSizeValue + textStyle: StyleTextStyleValue +} diff --git a/packages/new-v2/system-core/src/system/api.ts b/packages/new-v2/system-core/src/system/api.ts new file mode 100644 index 00000000..a10e0e90 --- /dev/null +++ b/packages/new-v2/system-core/src/system/api.ts @@ -0,0 +1,7 @@ +import type { SystemDefinition } from "./types.ts" + +export function defineSystem( + definition: TSystem, +): TSystem { + return definition +} diff --git a/packages/new-v2/system-core/src/system/types.ts b/packages/new-v2/system-core/src/system/types.ts new file mode 100644 index 00000000..6b453a15 --- /dev/null +++ b/packages/new-v2/system-core/src/system/types.ts @@ -0,0 +1,18 @@ +import type { + SystemPresetDefinition, + SystemThemeDefinition, +} from "../preset/types.ts" +import type { TokenSource } from "../token/types.ts" + +export interface SystemDefinition< + TStyleSource extends TokenSource = TokenSource, +> { + name: string + prefix: string + presets?: Array> + theme: SystemThemeDefinition +} + +export type SystemConfigDefinition< + TStyleSource extends TokenSource = TokenSource, +> = SystemDefinition diff --git a/packages/new-v2/system-core/src/token/composite/animation.ts b/packages/new-v2/system-core/src/token/composite/animation.ts new file mode 100644 index 00000000..2e88bc7c --- /dev/null +++ b/packages/new-v2/system-core/src/token/composite/animation.ts @@ -0,0 +1,22 @@ +export type AnimationValue = string | number + +export interface AnimationDefinition { + keyframes: AnimationValue + duration: AnimationValue + easing: AnimationValue + delay?: AnimationValue + fillMode?: AnimationValue + iterationCount?: AnimationValue + direction?: AnimationValue +} + +export type AnimationDefinitionMap = Record + +export type AnimationReference> = + `animation.${keyof TAnimations & string}` + +export function defineAnimations< + const TAnimations extends AnimationDefinitionMap, +>(definitions: TAnimations): TAnimations { + return definitions +} diff --git a/packages/new-v2/system-core/src/token/composite/textStyle.ts b/packages/new-v2/system-core/src/token/composite/textStyle.ts new file mode 100644 index 00000000..7397cba1 --- /dev/null +++ b/packages/new-v2/system-core/src/token/composite/textStyle.ts @@ -0,0 +1,24 @@ +import type { LeafPaths } from "../utils.ts" + +export type TextStyleValue = string | number + +export interface TextStyleDefinition { + fontSize: TextStyleValue + lineHeight: TextStyleValue + fontWeight: TextStyleValue + letterSpacing?: TextStyleValue +} + +export type TextStyleGroup = { + readonly [key: string]: TextStyleGroup | TextStyleDefinition +} + +export type TextStyleReference< + TTextStyles extends TextStyleGroup = TextStyleGroup, +> = `textStyle.${LeafPaths}` + +export function defineTextStyles( + definition: TTextStyles, +): TTextStyles { + return definition +} diff --git a/packages/new-v2/system-core/src/token/primitive/api.ts b/packages/new-v2/system-core/src/token/primitive/api.ts new file mode 100644 index 00000000..6fc9fbd1 --- /dev/null +++ b/packages/new-v2/system-core/src/token/primitive/api.ts @@ -0,0 +1,7 @@ +import type { PrimitiveTokensDefinition } from "./types.ts" + +export function definePrimitiveTokens< + const T extends PrimitiveTokensDefinition, +>(definition: T): T { + return definition +} diff --git a/packages/new-v2/system-core/src/token/primitive/types.ts b/packages/new-v2/system-core/src/token/primitive/types.ts new file mode 100644 index 00000000..0b9954b0 --- /dev/null +++ b/packages/new-v2/system-core/src/token/primitive/types.ts @@ -0,0 +1,39 @@ +import type * as CSS from "csstype" + +import type { LeafPaths, TokenGroup } from "../utils.ts" + +interface PrimitiveTokenFamilyMap { + color: TokenGroup + spacing: TokenGroup + radius: TokenGroup + shadow: TokenGroup + motion: { + duration: TokenGroup + easing: TokenGroup + } + fontSize: TokenGroup + fontWeight: TokenGroup + lineHeight: TokenGroup + letterSpacing: TokenGroup +} + +export type PrimitiveTokensDefinition = Partial + +export type TokenFamilyName< + TPrimitiveTokens extends PrimitiveTokensDefinition = + PrimitiveTokensDefinition, +> = keyof TPrimitiveTokens & string + +export type PrimitiveFamilyPath< + TPrimitiveTokens extends PrimitiveTokensDefinition, + TFamily extends TokenFamilyName, +> = `${TFamily}.${LeafPaths, string>}` + +export type PrimitiveTokenPath< + TPrimitiveTokens extends PrimitiveTokensDefinition, +> = { + [TFamily in TokenFamilyName]: PrimitiveFamilyPath< + TPrimitiveTokens, + TFamily + > +}[TokenFamilyName] diff --git a/packages/new-v2/system-core/src/token/semantic/api.ts b/packages/new-v2/system-core/src/token/semantic/api.ts new file mode 100644 index 00000000..33b2b8e2 --- /dev/null +++ b/packages/new-v2/system-core/src/token/semantic/api.ts @@ -0,0 +1,7 @@ +import type { SemanticTokensDefinition } from "./types.ts" + +export function defineSemanticTokens< + const TSemanticTokens extends SemanticTokensDefinition, +>(definition: TSemanticTokens): TSemanticTokens { + return definition +} diff --git a/packages/new-v2/system-core/src/token/semantic/types.ts b/packages/new-v2/system-core/src/token/semantic/types.ts new file mode 100644 index 00000000..282658e2 --- /dev/null +++ b/packages/new-v2/system-core/src/token/semantic/types.ts @@ -0,0 +1,17 @@ +import type { TokenGroup } from "../utils.ts" + +export type SemanticTokenValue = string | number + +export type SemanticTokenNode< + TValue extends SemanticTokenValue = SemanticTokenValue, + TMode extends string = string, +> = Readonly> + +export type SemanticTokenTree< + TValue extends SemanticTokenValue = SemanticTokenValue, + TMode extends string = string, +> = TokenGroup> + +export type SemanticTokensDefinition = Partial< + Record> +> diff --git a/packages/new-v2/system-core/src/token/types.ts b/packages/new-v2/system-core/src/token/types.ts new file mode 100644 index 00000000..e2ccd09f --- /dev/null +++ b/packages/new-v2/system-core/src/token/types.ts @@ -0,0 +1,61 @@ +import type { + PrimitiveFamilyPath, + PrimitiveTokensDefinition, + TokenFamilyName, +} from "./primitive/types.ts" +import type { + SemanticTokenNode, + SemanticTokensDefinition, +} from "./semantic/types.ts" +import type { LeafPaths } from "./utils.ts" + +export interface TokenSource { + primitiveTokens: PrimitiveTokensDefinition + semanticTokens: SemanticTokensDefinition +} + +type TokenSourceFamilyName = TokenFamilyName< + TTokenSource["primitiveTokens"] +> + +type TokenFamilyKey< + TTokenSource extends TokenSource, + TFamily extends keyof PrimitiveTokensDefinition & string, +> = Extract> + +type SemanticFamilyTree< + TTokenSource extends TokenSource, + TFamily extends TokenSourceFamilyName, +> = TFamily extends keyof TTokenSource["semanticTokens"] + ? Exclude + : never + +export type PrimitiveTokenPathByFamily< + TTokenSource extends TokenSource, + TFamily extends TokenSourceFamilyName, +> = PrimitiveFamilyPath + +export type SemanticTokenPathByFamily< + TTokenSource extends TokenSource, + TFamily extends TokenSourceFamilyName, +> = [SemanticFamilyTree] extends [never] + ? never + : `${TFamily}.${LeafPaths< + SemanticFamilyTree, + SemanticTokenNode + >}` + +export type TokenPathByFamily< + TTokenSource extends TokenSource, + TFamily extends TokenSourceFamilyName, +> = + | PrimitiveTokenPathByFamily + | SemanticTokenPathByFamily + +export type TokenPathMap = { + readonly [TFamily in keyof PrimitiveTokensDefinition & + string]: TokenPathByFamily< + TTokenSource, + TokenFamilyKey + > +} diff --git a/packages/new-v2/system-core/src/token/utils.ts b/packages/new-v2/system-core/src/token/utils.ts new file mode 100644 index 00000000..88d4d87a --- /dev/null +++ b/packages/new-v2/system-core/src/token/utils.ts @@ -0,0 +1,25 @@ +export type TokenKey = string | number + +type KeyOf = keyof T & TokenKey +type StringifyKey = T extends TokenKey ? `${T}` : never +type Join = `${Head}.${Tail}` + +type LeafPathForValue< + TKey extends TokenKey, + TValue, + TLeaf, +> = TValue extends TLeaf + ? StringifyKey + : TValue extends object + ? Join, LeafPaths> + : never + +export type TokenGroup = { + readonly [key in TokenKey]: TValue | TokenGroup +} + +export type LeafPaths = T extends object + ? { + [K in KeyOf]: LeafPathForValue + }[KeyOf] + : never diff --git a/packages/new-v2/system-core/test/core.type.test.ts b/packages/new-v2/system-core/test/core.type.test.ts new file mode 100644 index 00000000..ebc66c9e --- /dev/null +++ b/packages/new-v2/system-core/test/core.type.test.ts @@ -0,0 +1,647 @@ +import { assertType, expectTypeOf, test } from "vitest" + +import { + type AnimationReference, + defineAnimations, + defineKeyframes, + definePreset, + definePrimitiveTokens, + defineRecipe, + defineSemanticTokens, + defineSlotRecipe, + defineSystem, + defineTextStyles, + type KeyframesReference, + type PrimitiveFamilyPath, + type PrimitiveTokenPath, + type StyleObject, + type SystemDefinition, + type TextStyleReference, + type TokenSource, +} from "../src/index.ts" + +const primitiveTokens = definePrimitiveTokens({ + color: { + transparent: "transparent", + slate: { + 50: "#f8fafc", + 900: "#0f172a", + }, + }, + spacing: { + 1: "0.25rem", + 2: "0.5rem", + }, + radius: { + md: "0.375rem", + }, + shadow: { + sm: "0 1px 2px rgba(0, 0, 0, 0.08)", + }, + motion: { + duration: { + fast: "120ms", + slow: "400ms", + }, + easing: { + standard: "ease", + emphasized: "cubic-bezier(0.2, 0, 0, 1)", + }, + }, + fontSize: { + bodyMd: "1rem", + titleLg: "1.5rem", + }, + fontWeight: { + regular: "400", + bold: "700", + }, + lineHeight: { + bodyMd: "1.5", + titleLg: "1.2", + }, + letterSpacing: { + tight: "-0.01em", + }, +}) + +const semanticTokens = defineSemanticTokens({ + color: { + surface: { + base: "color.slate.50", + contrast: "color.slate.900", + raw: "#ffffff", + }, + }, + spacing: { + layout: { + base: 12, + compact: "0.75rem", + }, + }, +}) + +const textStyles = defineTextStyles({ + body: { + md: { + fontSize: "fontSize.bodyMd", + lineHeight: "lineHeight.bodyMd", + fontWeight: "fontWeight.regular", + letterSpacing: "letterSpacing.tight", + }, + }, + title: { + lg: { + fontSize: "1.5rem", + lineHeight: 1.2, + fontWeight: 700, + }, + }, +}) + +const keyframes = defineKeyframes({ + fadeIn: { + from: { + opacity: "0", + }, + "50%": { + opacity: "0.5", + }, + to: { + opacity: "1", + }, + }, +}) + +const animations = defineAnimations({ + fadeInFast: { + keyframes: "keyframes.fadeIn", + duration: "motion.duration.fast", + easing: "motion.easing.standard", + fillMode: "both", + }, + rawFade: { + keyframes: "fade-in", + duration: "120ms", + easing: "ease", + iterationCount: 2, + }, +}) + +const buttonRecipe = defineRecipe({ + name: "button", + base: { + animation: "animation.fadeInFast", + color: "#0f172a", + p: "spacing.2", + rounded: "radius.md", + textStyle: "textStyle.body.md", + _hover: { + color: "color.surface", + }, + }, + variants: { + size: { + sm: { + p: "spacing.1", + }, + md: { + p: "spacing.2", + }, + }, + tone: { + solid: { + color: "color.surface", + }, + ghost: { + color: "currentColor", + }, + }, + }, + compoundVariants: [ + { + when: { + size: "md", + tone: "solid", + }, + css: { + fontSize: "fontSize.bodyMd", + }, + }, + ], + defaultVariants: { + size: "sm", + tone: "solid", + }, +}) + +const tabsRecipe = defineSlotRecipe({ + name: "tabs", + slots: ["root", "trigger", "content"], + base: { + root: { + color: "color.surface", + }, + trigger: { + p: "spacing.1", + }, + }, + variants: { + tone: { + solid: { + trigger: { + color: "color.slate.900", + }, + content: { + textStyle: "textStyle.body.md", + }, + }, + }, + }, + compoundVariants: [ + { + when: { + tone: "solid", + }, + css: { + trigger: { + fontWeight: "700", + }, + }, + }, + ], + defaultVariants: { + tone: "solid", + }, +}) + +const colorOnlyPrimitiveTokens = definePrimitiveTokens({ + color: { + slate: { + 50: "#f8fafc", + }, + }, +}) + +test("primitive token types preserve literals, allow partial families, and reject malformed token trees", () => { + expectTypeOf(primitiveTokens.color.slate[50]).toEqualTypeOf<"#f8fafc">() + expectTypeOf(primitiveTokens.motion.duration.fast).toEqualTypeOf<"120ms">() + expectTypeOf( + colorOnlyPrimitiveTokens.color.slate[50], + ).toEqualTypeOf<"#f8fafc">() + expectTypeOf< + PrimitiveFamilyPath + >().toEqualTypeOf<"spacing.1" | "spacing.2">() + expectTypeOf< + PrimitiveTokenPath + >().toEqualTypeOf<"color.slate.50">() + expectTypeOf>().toEqualTypeOf< + | "color.transparent" + | "color.slate.50" + | "color.slate.900" + | "spacing.1" + | "spacing.2" + | "radius.md" + | "shadow.sm" + | "motion.duration.fast" + | "motion.duration.slow" + | "motion.easing.standard" + | "motion.easing.emphasized" + | "fontSize.bodyMd" + | "fontSize.titleLg" + | "fontWeight.regular" + | "fontWeight.bold" + | "lineHeight.bodyMd" + | "lineHeight.titleLg" + | "letterSpacing.tight" + >() + + assertType( + definePrimitiveTokens({ + color: { + // @ts-expect-error primitive token leaf values must be css-compatible scalars + invalid: false, + }, + }), + ) +}) + +test("semantic tokens keep authored values and reject invalid mode values", () => { + expectTypeOf( + semanticTokens.color.surface.base, + ).toEqualTypeOf<"color.slate.50">() + expectTypeOf(semanticTokens.color.surface.raw).toEqualTypeOf<"#ffffff">() + expectTypeOf(semanticTokens.spacing.layout.base).toEqualTypeOf<12>() + + assertType( + defineSemanticTokens({ + color: { + surface: { + // @ts-expect-error semantic mode values must be strings + base: false, + }, + }, + }), + ) +}) + +test("text style types support token references and raw values while enforcing shape", () => { + expectTypeOf(textStyles.body.md.fontSize).toEqualTypeOf<"fontSize.bodyMd">() + expectTypeOf(textStyles.title.lg.lineHeight).toEqualTypeOf<1.2>() + expectTypeOf>().toEqualTypeOf< + "textStyle.body.md" | "textStyle.title.lg" + >() + + assertType( + defineTextStyles({ + body: { + // @ts-expect-error text styles require fontSize, lineHeight, and fontWeight + md: { + fontSize: "fontSize.bodyMd", + lineHeight: "lineHeight.bodyMd", + }, + }, + }), + ) + + assertType( + defineTextStyles({ + body: { + md: { + fontSize: "fontSize.bodyMd", + lineHeight: "lineHeight.bodyMd", + // @ts-expect-error text style values must be string or number + fontWeight: false, + }, + }, + }), + ) +}) + +test("keyframes and animations preserve references and reject invalid shapes", () => { + expectTypeOf(keyframes.fadeIn["50%"]?.opacity).toEqualTypeOf<"0.5">() + expectTypeOf< + KeyframesReference + >().toEqualTypeOf<"keyframes.fadeIn">() + expectTypeOf( + animations.fadeInFast.keyframes, + ).toEqualTypeOf<"keyframes.fadeIn">() + expectTypeOf>().toEqualTypeOf< + "animation.fadeInFast" | "animation.rawFade" + >() + + assertType( + defineKeyframes({ + fadeIn: { + // @ts-expect-error keyframe selectors are from, to, or percentages + middle: { + opacity: "0.5", + }, + }, + }), + ) + + assertType( + defineKeyframes({ + fadeIn: { + from: { + // @ts-expect-error keyframe declaration values must be strings + opacity: 0, + }, + }, + }), + ) + + assertType( + defineAnimations({ + // @ts-expect-error animations require keyframes, duration, and easing + invalid: { + keyframes: "keyframes.fadeIn", + duration: "120ms", + }, + }), + ) + + assertType( + defineAnimations({ + invalid: { + keyframes: "keyframes.fadeIn", + duration: "120ms", + easing: "ease", + // @ts-expect-error animation values must be string or number + delay: false, + }, + }), + ) +}) + +test("style object values accept css values and token references for the matching property category", () => { + assertType({ + animation: "animation.fadeInFast", + animationDuration: "motion.duration.fast", + animationTimingFunction: "motion.easing.standard", + backgroundColor: "#ffffff", + bg: "var(--surface)", + borderColor: "rgb(15 23 42)", + borderRadius: "999px", + boxShadow: "shadow.sm", + color: "color.slate.900", + fontSize: "fontSize.bodyMd", + margin: "2rem", + p: "spacing.1", + rounded: "radius.md", + textStyle: "textStyle.body.md", + transitionDuration: "120ms", + transitionTimingFunction: "cubic-bezier(0.2, 0, 0, 1)", + width: "calc(100% - 1rem)", + _hover: { + color: "#ffffff", + p: "0", + }, + "@media (min-width: 768px)": { + p: "2rem", + }, + }) +}) + +test("style object values reject values from the wrong property category", () => { + assertType({ + // @ts-expect-error color accepts css colors or color token references only + color: "spacing.1", + }) + + assertType({ + // @ts-expect-error color does not accept numbers + color: 12, + }) + + assertType({ + // @ts-expect-error color does not accept arbitrary non-color strings + color: "not-a-color", + }) + + assertType({ + // @ts-expect-error spacing properties accept css dimensions or spacing token references only + p: "color.slate.50", + }) + + assertType({ + // @ts-expect-error spacing properties do not accept raw numbers + p: 12, + }) + + assertType({ + // @ts-expect-error radius properties accept css radius values or radius token references only + rounded: "spacing.1", + }) + + assertType({ + // @ts-expect-error duration properties accept css time values or duration token references only + animationDuration: "motion.easing.standard", + }) + + assertType({ + // @ts-expect-error easing properties accept css timing functions or easing token references only + animationTimingFunction: "motion.duration.fast", + }) + + assertType({ + // @ts-expect-error fontSize accepts css font sizes or fontSize token references only + fontSize: "color.slate.50", + }) + + assertType({ + // @ts-expect-error textStyle accepts textStyle references only + textStyle: "color.slate.50", + }) + + assertType({ + // @ts-expect-error unknown style properties are rejected + unknownProperty: "value", + }) + + assertType({ + // @ts-expect-error zIndex accepts number-like values only + zIndex: "top", + }) +}) + +test("recipe types preserve variants and reject invalid variant selections", () => { + expectTypeOf(buttonRecipe.name).toEqualTypeOf<"button">() + expectTypeOf(buttonRecipe.base.p).toEqualTypeOf<"spacing.2">() + expectTypeOf(buttonRecipe.variants.size.sm.p).toEqualTypeOf<"spacing.1">() + expectTypeOf(buttonRecipe.defaultVariants.size).toEqualTypeOf<"sm">() + expectTypeOf< + NonNullable[number]["when"]["tone"] + >().toEqualTypeOf<"solid">() + + assertType( + defineRecipe({ + name: "invalid-button", + base: {}, + variants: { + size: { + sm: {}, + }, + }, + defaultVariants: { + // @ts-expect-error defaultVariants only accepts declared variant values + size: "md", + }, + }), + ) + + assertType( + defineRecipe({ + name: "invalid-button", + base: {}, + variants: { + size: { + sm: {}, + }, + }, + defaultVariants: { + // @ts-expect-error defaultVariants only accepts declared variant keys + tone: "solid", + }, + }), + ) + + assertType( + defineRecipe({ + name: "invalid-button", + base: {}, + variants: { + size: { + sm: {}, + }, + }, + compoundVariants: [ + { + when: { + // @ts-expect-error compoundVariants only accepts declared variant values + size: "lg", + }, + css: {}, + }, + ], + defaultVariants: { + size: "sm", + }, + }), + ) +}) + +test("slot recipe types preserve slots and reject unknown slot names", () => { + expectTypeOf<(typeof tabsRecipe.slots)[number]>().toEqualTypeOf< + "root" | "trigger" | "content" + >() + expectTypeOf( + tabsRecipe.variants.tone.solid.content?.textStyle, + ).toEqualTypeOf<"textStyle.body.md">() + + assertType( + defineSlotRecipe({ + name: "invalid-tabs", + slots: ["root", "trigger"], + base: { + // @ts-expect-error base only accepts declared slots + content: {}, + }, + variants: {}, + defaultVariants: {}, + }), + ) + + assertType( + defineSlotRecipe({ + name: "invalid-tabs", + slots: ["root", "trigger"], + base: {}, + variants: { + tone: { + solid: { + // @ts-expect-error variants only accepts declared slots + content: {}, + }, + }, + }, + defaultVariants: { + tone: "solid", + }, + }), + ) + + assertType( + defineSlotRecipe({ + name: "invalid-tabs", + slots: ["root", "trigger"], + base: {}, + variants: { + tone: { + solid: { + trigger: {}, + }, + }, + }, + defaultVariants: { + // @ts-expect-error slot recipe defaultVariants only accepts declared variant values + tone: "ghost", + }, + }), + ) +}) + +test("system types preserve authored sections and reject missing required system fields", () => { + const preset = definePreset({ + semanticTokens, + keyframes, + composites: { + textStyles, + animations, + }, + recipes: { + button: buttonRecipe, + }, + }) + + const system = defineSystem({ + name: "test-system", + prefix: "jds", + presets: [preset], + theme: { + primitiveTokens, + semanticTokens, + keyframes, + composites: { + textStyles, + animations, + }, + recipes: { + button: buttonRecipe, + tabs: tabsRecipe, + }, + }, + }) + + type Source = { + primitiveTokens: typeof primitiveTokens + semanticTokens: typeof semanticTokens + } + + expectTypeOf().toExtend() + expectTypeOf(system).toExtend() + expectTypeOf(system.name).toEqualTypeOf<"test-system">() + expectTypeOf( + system.theme.composites.textStyles.body.md.fontSize, + ).toEqualTypeOf<"fontSize.bodyMd">() + expectTypeOf(system.theme.recipes.tabs).toExtend() + + assertType( + // @ts-expect-error systems require name, prefix, and theme + defineSystem({ + name: "missing-prefix", + theme: {}, + }), + ) +}) diff --git a/packages/new-v2/system-core/tsconfig.json b/packages/new-v2/system-core/tsconfig.json new file mode 100644 index 00000000..226b3010 --- /dev/null +++ b/packages/new-v2/system-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@jongh/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/new-v2/system-core/tsdown.config.ts b/packages/new-v2/system-core/tsdown.config.ts new file mode 100644 index 00000000..9e91c3b6 --- /dev/null +++ b/packages/new-v2/system-core/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + outExtensions: () => ({ js: ".js" }), + clean: true, + sourcemap: false, + minify: false, + dts: true, + platform: "node", + target: "node22", + deps: { + neverBundle: ["csstype"], + }, +}) diff --git a/packages/new-v2/system-core/vitest.config.ts b/packages/new-v2/system-core/vitest.config.ts new file mode 100644 index 00000000..1449ddf2 --- /dev/null +++ b/packages/new-v2/system-core/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + typecheck: { + include: ["test/**/*.type.test.ts"], + }, + }, +}) From 7bfae52b03fdc40b97756ec92ef0c3b6324973cb Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 23:01:41 +0900 Subject: [PATCH 2/8] feat(system-compiler): add css compiler --- .../new-v2/system-compiler/eslint.config.mjs | 4 + packages/new-v2/system-compiler/package.json | 40 ++ .../system-compiler/src/compile/context.ts | 38 ++ .../system-compiler/src/compile/index.ts | 21 + .../system-compiler/src/compile/options.ts | 10 + .../src/compile/targets/css.ts | 74 ++++ .../src/compile/targets/tailwind-v4.ts | 377 ++++++++++++++++++ .../src/compile/targets/types.ts | 4 + .../system-compiler/src/compile/types.ts | 9 + .../new-v2/system-compiler/src/emit/files.ts | 28 ++ packages/new-v2/system-compiler/src/index.ts | 8 + .../src/normalize/composites.ts | 60 +++ .../src/normalize/keyframes.ts | 16 + .../system-compiler/src/normalize/recipes.ts | 93 +++++ .../system-compiler/src/normalize/schema.ts | 139 +++++++ .../system-compiler/src/normalize/styles.ts | 76 ++++ .../src/normalize/system.test.ts | 292 ++++++++++++++ .../system-compiler/src/normalize/system.ts | 23 ++ .../system-compiler/src/normalize/theme.ts | 26 ++ .../system-compiler/src/normalize/tokens.ts | 86 ++++ .../system-compiler/src/normalize/utils.ts | 9 + .../system-compiler/src/output/schema.ts | 98 +++++ .../src/registry/composites.ts | 122 ++++++ .../system-compiler/src/registry/keyframes.ts | 29 ++ .../system-compiler/src/registry/tokens.ts | 90 +++++ .../system-compiler/src/registry/types.ts | 22 + .../system-compiler/src/render/output.ts | 70 ++++ .../system-compiler/src/render/postcss.ts | 69 ++++ .../src/transform/properties.ts | 86 ++++ .../system-compiler/src/transform/recipes.ts | 220 ++++++++++ .../src/transform/selectors.ts | 64 +++ .../system-compiler/test/compiler.test.ts | 300 ++++++++++++++ packages/new-v2/system-compiler/tsconfig.json | 8 + .../new-v2/system-compiler/tsdown.config.ts | 16 + 34 files changed, 2627 insertions(+) create mode 100644 packages/new-v2/system-compiler/eslint.config.mjs create mode 100644 packages/new-v2/system-compiler/package.json create mode 100644 packages/new-v2/system-compiler/src/compile/context.ts create mode 100644 packages/new-v2/system-compiler/src/compile/index.ts create mode 100644 packages/new-v2/system-compiler/src/compile/options.ts create mode 100644 packages/new-v2/system-compiler/src/compile/targets/css.ts create mode 100644 packages/new-v2/system-compiler/src/compile/targets/tailwind-v4.ts create mode 100644 packages/new-v2/system-compiler/src/compile/targets/types.ts create mode 100644 packages/new-v2/system-compiler/src/compile/types.ts create mode 100644 packages/new-v2/system-compiler/src/emit/files.ts create mode 100644 packages/new-v2/system-compiler/src/index.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/composites.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/keyframes.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/recipes.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/schema.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/styles.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/system.test.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/system.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/theme.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/tokens.ts create mode 100644 packages/new-v2/system-compiler/src/normalize/utils.ts create mode 100644 packages/new-v2/system-compiler/src/output/schema.ts create mode 100644 packages/new-v2/system-compiler/src/registry/composites.ts create mode 100644 packages/new-v2/system-compiler/src/registry/keyframes.ts create mode 100644 packages/new-v2/system-compiler/src/registry/tokens.ts create mode 100644 packages/new-v2/system-compiler/src/registry/types.ts create mode 100644 packages/new-v2/system-compiler/src/render/output.ts create mode 100644 packages/new-v2/system-compiler/src/render/postcss.ts create mode 100644 packages/new-v2/system-compiler/src/transform/properties.ts create mode 100644 packages/new-v2/system-compiler/src/transform/recipes.ts create mode 100644 packages/new-v2/system-compiler/src/transform/selectors.ts create mode 100644 packages/new-v2/system-compiler/test/compiler.test.ts create mode 100644 packages/new-v2/system-compiler/tsconfig.json create mode 100644 packages/new-v2/system-compiler/tsdown.config.ts diff --git a/packages/new-v2/system-compiler/eslint.config.mjs b/packages/new-v2/system-compiler/eslint.config.mjs new file mode 100644 index 00000000..f455be36 --- /dev/null +++ b/packages/new-v2/system-compiler/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@jongh/eslint/base" + +/** @type {import("eslint").Linter.Config[]} */ +export default [...config] diff --git a/packages/new-v2/system-compiler/package.json b/packages/new-v2/system-compiler/package.json new file mode 100644 index 00000000..d3b0faf9 --- /dev/null +++ b/packages/new-v2/system-compiler/package.json @@ -0,0 +1,40 @@ +{ + "name": "@jongh/new-v2-system-compiler", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "디자인 시스템 토큰/레시피를 CSS로 컴파일합니다.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "package.json" + ], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "tsdown", + "lint": "eslint --max-warnings 0 --no-warn-ignored .", + "check-type": "tsgo --noEmit", + "test": "pnpm exec vitest run" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "devDependencies": { + "@jongh/eslint": "workspace:*", + "@jongh/tsconfig": "workspace:*", + "@types/node": "^22.8.6", + "tsdown": "catalog:" + }, + "dependencies": { + "@jongh/new-v2-system-core": "workspace:*", + "postcss": "^8.5.10", + "zod": "^4.3.6" + } +} diff --git a/packages/new-v2/system-compiler/src/compile/context.ts b/packages/new-v2/system-compiler/src/compile/context.ts new file mode 100644 index 00000000..6edb5be2 --- /dev/null +++ b/packages/new-v2/system-compiler/src/compile/context.ts @@ -0,0 +1,38 @@ +import type { SystemDefinition, TokenSource } from "@jongh/new-v2-system-core" +import type { z } from "zod" + +import type { compilerSystemSchema } from "../normalize/schema.ts" +import { normalizeSystem } from "../normalize/system.ts" +import { createCompositeRegistry } from "../registry/composites.ts" +import { createKeyframesRegistry } from "../registry/keyframes.ts" +import { createTokenRegistry } from "../registry/tokens.ts" +import type { CompilerRegistry } from "../registry/types.ts" + +export type NormalizedSystem = z.infer + +export interface CompileContext { + normalized: NormalizedSystem + registry: CompilerRegistry +} + +export function createCompileContext( + system: SystemDefinition, +): CompileContext { + const normalized = normalizeSystem(system) + const tokens = createTokenRegistry(normalized.prefix, normalized.tokens) + const keyframes = createKeyframesRegistry(normalized.keyframes) + const composites = createCompositeRegistry( + normalized.composites, + tokens, + keyframes, + ) + + return { + normalized, + registry: { + tokens, + keyframes, + composites, + }, + } +} diff --git a/packages/new-v2/system-compiler/src/compile/index.ts b/packages/new-v2/system-compiler/src/compile/index.ts new file mode 100644 index 00000000..a629c1dc --- /dev/null +++ b/packages/new-v2/system-compiler/src/compile/index.ts @@ -0,0 +1,21 @@ +import type { TokenSource } from "@jongh/new-v2-system-core" +import type { SystemDefinition } from "@jongh/new-v2-system-core" + +import type { CompileDocument } from "../output/schema.ts" +import { createCompileContext } from "./context.ts" +import type { CompileOptions, CompileTarget } from "./options.ts" +import { compileCss } from "./targets/css.ts" +import { compileTailwindV4 } from "./targets/tailwind-v4.ts" +import type { TargetCompiler } from "./targets/types.ts" + +const targetCompilers: Record = { + css: compileCss, + "tailwind-v4": compileTailwindV4, +} + +export function compileSystem( + system: SystemDefinition, + options: CompileOptions, +): CompileDocument { + return targetCompilers[options.target](createCompileContext(system)) +} diff --git a/packages/new-v2/system-compiler/src/compile/options.ts b/packages/new-v2/system-compiler/src/compile/options.ts new file mode 100644 index 00000000..f375d0a3 --- /dev/null +++ b/packages/new-v2/system-compiler/src/compile/options.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +export const compileTargetSchema = z.enum(["css", "tailwind-v4"]) + +export const compileOptionsSchema = z.object({ + target: compileTargetSchema, +}) + +export type CompileTarget = z.infer +export type CompileOptions = z.infer diff --git a/packages/new-v2/system-compiler/src/compile/targets/css.ts b/packages/new-v2/system-compiler/src/compile/targets/css.ts new file mode 100644 index 00000000..2eeef27f --- /dev/null +++ b/packages/new-v2/system-compiler/src/compile/targets/css.ts @@ -0,0 +1,74 @@ +import type { KeyframeSelector } from "@jongh/new-v2-system-core" + +import type { + CompileDocument, + CompileLayer, + CssDeclaration, +} from "../../output/schema.ts" +import { compileDocumentSchema } from "../../output/schema.ts" +import { transformRecipes } from "../../transform/recipes.ts" +import type { CompileContext } from "../context.ts" + +function toKebab(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/_/g, "-") + .toLowerCase() +} + +function keyframesToDeclarations( + frames: CompileContext["normalized"]["keyframes"][number]["frames"], +): Array<{ selector: KeyframeSelector; declarations: CssDeclaration[] }> { + return frames.map((frame) => ({ + selector: frame.selector as KeyframeSelector, + declarations: frame.declarations.map((declaration) => ({ + property: toKebab(declaration.property), + value: declaration.value, + })), + })) +} + +export function compileCss(context: CompileContext): CompileDocument { + const tokenBlocks: CompileLayer["blocks"] = [] + + for (const [family, declarations] of Object.entries( + context.registry.tokens.declarations, + )) { + tokenBlocks.push({ + kind: "token", + family, + declarations, + }) + } + + for (const [mode, declarations] of Object.entries( + context.registry.tokens.modeDeclarations, + )) { + tokenBlocks.push({ + kind: "tokenMode", + mode, + declarations, + }) + } + + const layers: CompileLayer[] = [ + { + name: "tokens", + blocks: tokenBlocks, + }, + { + name: "keyframes", + blocks: context.normalized.keyframes.map((keyframes) => ({ + kind: "keyframes", + name: keyframes.name, + frames: keyframesToDeclarations(keyframes.frames), + })), + }, + { + name: "recipes", + blocks: transformRecipes(context.normalized, context.registry), + }, + ] + + return compileDocumentSchema.parse({ layers }) +} diff --git a/packages/new-v2/system-compiler/src/compile/targets/tailwind-v4.ts b/packages/new-v2/system-compiler/src/compile/targets/tailwind-v4.ts new file mode 100644 index 00000000..5397dc1b --- /dev/null +++ b/packages/new-v2/system-compiler/src/compile/targets/tailwind-v4.ts @@ -0,0 +1,377 @@ +import type { KeyframeSelector } from "@jongh/new-v2-system-core" + +import type { + CompileDocument, + CompileLayer, + CssDeclaration, + CssNode, +} from "../../output/schema.ts" +import { compileDocumentSchema } from "../../output/schema.ts" +import { getKeyframesName } from "../../registry/keyframes.ts" +import { getTokenValue } from "../../registry/tokens.ts" +import { transformRecipes } from "../../transform/recipes.ts" +import type { CompileContext, NormalizedSystem } from "../context.ts" + +type NormalizedToken = + | NormalizedSystem["tokens"]["primitive"][number] + | NormalizedSystem["tokens"]["semantic"][number] +type NormalizedTextStyle = NormalizedSystem["composites"]["textStyles"][number] + +const shorthandProperties = { + p: ["padding"], + px: ["padding-left", "padding-right"], + py: ["padding-top", "padding-bottom"], + pt: ["padding-top"], + pr: ["padding-right"], + pb: ["padding-bottom"], + pl: ["padding-left"], + m: ["margin"], + mx: ["margin-left", "margin-right"], + my: ["margin-top", "margin-bottom"], + rounded: ["border-radius"], + bg: ["background-color"], +} as const + +const tokenFamilyNamespaces: Record = { + color: "color", + fontSize: "text", + fontWeight: "font-weight", + letterSpacing: "tracking", + lineHeight: "leading", + radius: "radius", + shadow: "shadow", + spacing: "spacing", +} + +function toKebab(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/_/g, "-") + .replace(/\./g, "-") + .toLowerCase() +} + +function pathSuffix(path: string, segmentsToRemove: number): string { + return path.split(".").slice(segmentsToRemove).map(toKebab).join("-") +} + +function isTokenReference(value: unknown): value is string { + return ( + typeof value === "string" && /^[a-zA-Z][\w-]*(?:\.[\w-]+)+$/.test(value) + ) +} + +function toCssProperties(property: string): string[] { + if (property in shorthandProperties) { + return [ + ...shorthandProperties[property as keyof typeof shorthandProperties], + ] + } + + return [toKebab(property)] +} + +function keyframesToDeclarations( + frames: CompileContext["normalized"]["keyframes"][number]["frames"], +): Array<{ selector: KeyframeSelector; declarations: CssDeclaration[] }> { + return frames.map((frame) => ({ + selector: frame.selector as KeyframeSelector, + declarations: frame.declarations.map((declaration) => ({ + property: toKebab(declaration.property), + value: declaration.value, + })), + })) +} + +function tailwindTokenProperty(token: NormalizedToken): string | undefined { + if (token.path.startsWith("motion.duration.")) { + return `--transition-duration-${pathSuffix(token.path, 2)}` + } + + if (token.path.startsWith("motion.easing.")) { + return `--ease-${pathSuffix(token.path, 2)}` + } + + const namespace = tokenFamilyNamespaces[token.family] + + if (namespace === undefined) { + return undefined + } + + return `--${namespace}-${pathSuffix(token.path, 1)}` +} + +function createTailwindTokenReferences(context: CompileContext) { + const references = new Map() + + for (const token of [ + ...context.normalized.tokens.primitive, + ...context.normalized.tokens.semantic, + ]) { + const property = tailwindTokenProperty(token) + + if (property !== undefined) { + references.set(token.path, `var(${property})`) + } + } + + return references +} + +function resolveTailwindValue( + context: CompileContext, + references: Map, + value: string, +): string { + return references.get(value) ?? getTokenValue(context.registry.tokens, value) +} + +function animationProperty(reference: string): string { + return `--animate-${toKebab(reference.replace(/^animation\./, ""))}` +} + +function createAnimationValue( + context: CompileContext, + references: Map, + animation: NormalizedSystem["composites"]["animations"][number], +): string { + const values = [ + getKeyframesName(context.registry.keyframes, animation.keyframes), + resolveTailwindValue(context, references, animation.duration), + resolveTailwindValue(context, references, animation.easing), + ] + + if (animation.delay !== undefined) { + values.push(resolveTailwindValue(context, references, animation.delay)) + } + + if (animation.iterationCount !== undefined) { + values.push(String(animation.iterationCount)) + } + + if (animation.direction !== undefined) { + values.push(animation.direction) + } + + if (animation.fillMode !== undefined) { + values.push(animation.fillMode) + } + + return values.join(" ") +} + +function createTextStyleDeclarations( + context: CompileContext, + references: Map, + textStyle: NormalizedTextStyle, +): CssDeclaration[] { + const declarations = [ + { + property: "font-size", + value: resolveTailwindValue(context, references, textStyle.fontSize), + }, + { + property: "line-height", + value: resolveTailwindValue(context, references, textStyle.lineHeight), + }, + { + property: "font-weight", + value: resolveTailwindValue(context, references, textStyle.fontWeight), + }, + ] + + if (textStyle.letterSpacing !== undefined) { + declarations.push({ + property: "letter-spacing", + value: resolveTailwindValue(context, references, textStyle.letterSpacing), + }) + } + + return declarations +} + +function createThemeDeclarations(context: CompileContext): CssDeclaration[] { + const references = createTailwindTokenReferences(context) + const declarations: CssDeclaration[] = [] + + for (const token of [ + ...context.normalized.tokens.primitive, + ...context.normalized.tokens.semantic, + ]) { + const property = tailwindTokenProperty(token) + + if (property === undefined) { + continue + } + + declarations.push({ + property, + value: getTokenValue(context.registry.tokens, token.path), + }) + } + + for (const animation of context.normalized.composites.animations) { + declarations.push({ + property: animationProperty(`animation.${animation.name}`), + value: createAnimationValue(context, references, animation), + }) + } + + return declarations +} + +function createTextStyleUtilities(context: CompileContext): CssNode[] { + const references = createTailwindTokenReferences(context) + + return context.normalized.composites.textStyles.map((textStyle) => ({ + kind: "at-rule", + name: "utility", + params: `text-style-${toKebab(textStyle.name)}`, + declarations: createTextStyleDeclarations(context, references, textStyle), + })) +} + +function getTextStyleDeclarations( + context: CompileContext, + references: Map, + reference: string, +) { + const name = reference.replace(/^textStyle\./, "") + const textStyle = context.normalized.composites.textStyles.find( + (item) => item.name === name, + ) + + if (textStyle === undefined) { + throw new Error(`Unknown textStyle reference "${reference}"`) + } + + return createTextStyleDeclarations(context, references, textStyle) +} + +function transformTailwindProperty( + context: CompileContext, + references: Map, + property: string, + value: unknown, +): CssDeclaration[] { + if (property === "animation") { + if (typeof value !== "string") { + return [] + } + + if (!context.registry.composites.animations.has(value)) { + throw new Error(`Unknown animation reference "${value}"`) + } + + return [ + { + property: "animation", + value: `var(${animationProperty(value)})`, + }, + ] + } + + if (property === "textStyle") { + return typeof value === "string" + ? getTextStyleDeclarations(context, references, value) + : [] + } + + if (typeof value !== "string" && typeof value !== "number") { + return [] + } + + return toCssProperties(property).map((cssProperty) => ({ + property: cssProperty, + value: isTokenReference(value) + ? resolveTailwindValue(context, references, String(value)) + : String(value), + })) +} + +function createComponentBlocks( + context: CompileContext, +): CompileLayer["blocks"] { + const references = createTailwindTokenReferences(context) + + return transformRecipes( + context.normalized, + context.registry, + (property, value) => + transformTailwindProperty(context, references, property, value), + ) +} + +export function compileTailwindV4(context: CompileContext): CompileDocument { + const tokenBlocks: CompileLayer["blocks"] = [] + + for (const [family, declarations] of Object.entries( + context.registry.tokens.declarations, + )) { + tokenBlocks.push({ + kind: "token", + family, + declarations, + }) + } + + for (const [mode, declarations] of Object.entries( + context.registry.tokens.modeDeclarations, + )) { + tokenBlocks.push({ + kind: "tokenMode", + mode, + declarations, + }) + } + + const themeDeclarations = createThemeDeclarations(context) + const layers: CompileLayer[] = [ + { + name: "tokens", + blocks: tokenBlocks, + }, + { + name: "theme", + blocks: [ + { + kind: "css", + nodes: + themeDeclarations.length > 0 + ? [ + { + kind: "at-rule", + name: "theme", + params: "inline", + declarations: themeDeclarations, + }, + ] + : [], + }, + ], + }, + { + name: "keyframes", + blocks: context.normalized.keyframes.map((keyframes) => ({ + kind: "keyframes", + name: keyframes.name, + frames: keyframesToDeclarations(keyframes.frames), + })), + }, + { + name: "utilities", + blocks: [ + { + kind: "css", + nodes: createTextStyleUtilities(context), + }, + ], + }, + { + name: "components", + blocks: createComponentBlocks(context), + }, + ] + + return compileDocumentSchema.parse({ layers }) +} diff --git a/packages/new-v2/system-compiler/src/compile/targets/types.ts b/packages/new-v2/system-compiler/src/compile/targets/types.ts new file mode 100644 index 00000000..37562814 --- /dev/null +++ b/packages/new-v2/system-compiler/src/compile/targets/types.ts @@ -0,0 +1,4 @@ +import type { CompileDocument } from "../../output/schema.ts" +import type { CompileContext } from "../context.ts" + +export type TargetCompiler = (context: CompileContext) => CompileDocument diff --git a/packages/new-v2/system-compiler/src/compile/types.ts b/packages/new-v2/system-compiler/src/compile/types.ts new file mode 100644 index 00000000..1f8fea8c --- /dev/null +++ b/packages/new-v2/system-compiler/src/compile/types.ts @@ -0,0 +1,9 @@ +export interface CompileFile { + path: string + contentType: "text/css" + content: string +} + +export interface CompileResult { + files: CompileFile[] +} diff --git a/packages/new-v2/system-compiler/src/emit/files.ts b/packages/new-v2/system-compiler/src/emit/files.ts new file mode 100644 index 00000000..e0cfac05 --- /dev/null +++ b/packages/new-v2/system-compiler/src/emit/files.ts @@ -0,0 +1,28 @@ +import { z } from "zod" + +import type { CompileFile, CompileResult } from "../compile/types.ts" +import type { CompileLayer } from "../output/schema.ts" +import { renderLayer } from "../render/output.ts" + +export const emitFilesOptionsSchema = z.object({ + outputFiles: z.record(z.string().min(1), z.string().min(1)).optional(), +}) + +export type EmitFilesOptions = z.infer + +export function emitFiles( + layers: CompileLayer[], + options: EmitFilesOptions, +): CompileResult { + const files: CompileFile[] = layers + .map((layer) => ({ + path: options.outputFiles?.[layer.name] ?? `${layer.name}.css`, + contentType: "text/css" as const, + content: renderLayer(layer), + })) + .filter((file) => file.content.trim().length > 0) + + return { + files, + } +} diff --git a/packages/new-v2/system-compiler/src/index.ts b/packages/new-v2/system-compiler/src/index.ts new file mode 100644 index 00000000..82bda6e2 --- /dev/null +++ b/packages/new-v2/system-compiler/src/index.ts @@ -0,0 +1,8 @@ +export { compileSystem } from "./compile/index.ts" +export type { CompileOptions, CompileTarget } from "./compile/options.ts" +export { compileOptionsSchema, compileTargetSchema } from "./compile/options.ts" +export type { CompileFile, CompileResult } from "./compile/types.ts" +export type { EmitFilesOptions } from "./emit/files.ts" +export { emitFiles, emitFilesOptionsSchema } from "./emit/files.ts" +export type { CompileDocument } from "./output/schema.ts" +export { compileDocumentSchema } from "./output/schema.ts" diff --git a/packages/new-v2/system-compiler/src/normalize/composites.ts b/packages/new-v2/system-compiler/src/normalize/composites.ts new file mode 100644 index 00000000..02d3efe4 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/composites.ts @@ -0,0 +1,60 @@ +import { entriesOf, isRecord } from "./utils.ts" + +function normalizeAnimations(animations: unknown) { + return entriesOf(animations).map(([name, animation]) => ({ + name, + ...(isRecord(animation) ? animation : {}), + })) +} + +function normalizeTextStyles(textStyles: unknown) { + const output: unknown[] = [] + const stack = entriesOf(textStyles).map(([key, value]) => ({ + path: [key], + value, + })) + + for (let index = 0; index < stack.length; index += 1) { + const item = stack[index] + + if (item === undefined || item.value === undefined) { + continue + } + + if (!isRecord(item.value)) { + continue + } + + const values = Object.values(item.value) + const isLeaf = + values.length > 0 && values.every((value) => !isRecord(value)) + + if (isLeaf) { + output.push({ + name: item.path.join("."), + ...item.value, + }) + continue + } + + for (const [key, child] of entriesOf(item.value)) { + stack.push({ + path: [...item.path, key], + value: child, + }) + } + } + + return output +} + +export function normalizeComposites(composites: unknown) { + return { + textStyles: normalizeTextStyles( + isRecord(composites) ? composites.textStyles : undefined, + ), + animations: normalizeAnimations( + isRecord(composites) ? composites.animations : undefined, + ), + } +} diff --git a/packages/new-v2/system-compiler/src/normalize/keyframes.ts b/packages/new-v2/system-compiler/src/normalize/keyframes.ts new file mode 100644 index 00000000..f0dbc847 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/keyframes.ts @@ -0,0 +1,16 @@ +import { entriesOf } from "./utils.ts" + +export function normalizeKeyframes(keyframes: unknown) { + return entriesOf(keyframes).map(([name, frames]) => ({ + name, + frames: entriesOf(frames).map(([selector, declarations]) => ({ + selector, + declarations: entriesOf(declarations) + .filter(([, value]) => value !== undefined) + .map(([property, value]) => ({ + property, + value, + })), + })), + })) +} diff --git a/packages/new-v2/system-compiler/src/normalize/recipes.ts b/packages/new-v2/system-compiler/src/normalize/recipes.ts new file mode 100644 index 00000000..5b46afdc --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/recipes.ts @@ -0,0 +1,93 @@ +import { normalizeStyle } from "./styles.ts" +import { entriesOf, isRecord } from "./utils.ts" + +export function normalizeRecipes(recipes: unknown) { + return entriesOf(recipes).map(([key, recipe]) => { + const recipeRecord = isRecord(recipe) ? recipe : {} + const name = typeof recipeRecord.name === "string" ? recipeRecord.name : key + + if ("slots" in recipeRecord) { + return { + kind: "slotRecipe", + name, + slots: Array.isArray(recipeRecord.slots) ? [...recipeRecord.slots] : [], + base: entriesOf(recipeRecord.base).map(([slot, style]) => ({ + slot, + style: normalizeStyle(style), + })), + variants: entriesOf(recipeRecord.variants).flatMap( + ([variantName, values]) => + entriesOf(values).map(([variantValue, slotStyles]) => ({ + name: variantName, + value: variantValue, + slots: entriesOf(slotStyles).map(([slot, style]) => ({ + slot, + style: normalizeStyle(style), + })), + })), + ), + compoundVariants: Array.isArray(recipeRecord.compoundVariants) + ? recipeRecord.compoundVariants.map((compoundVariant) => { + const compoundVariantRecord = isRecord(compoundVariant) + ? compoundVariant + : {} + + return { + when: Object.fromEntries( + entriesOf(compoundVariantRecord.when).filter( + ([, value]) => value !== undefined, + ), + ), + slots: entriesOf(compoundVariantRecord.css).map( + ([slot, style]) => ({ + slot, + style: normalizeStyle(style), + }), + ), + } + }) + : [], + defaultVariants: Object.fromEntries( + entriesOf(recipeRecord.defaultVariants).filter( + ([, value]) => value !== undefined, + ), + ), + } + } + + return { + kind: "recipe", + name, + base: normalizeStyle(recipeRecord.base), + variants: entriesOf(recipeRecord.variants).flatMap( + ([variantName, values]) => + entriesOf(values).map(([variantValue, style]) => ({ + name: variantName, + value: variantValue, + style: normalizeStyle(style), + })), + ), + compoundVariants: Array.isArray(recipeRecord.compoundVariants) + ? recipeRecord.compoundVariants.map((compoundVariant) => { + const compoundVariantRecord = isRecord(compoundVariant) + ? compoundVariant + : {} + + return { + when: Object.fromEntries( + entriesOf(compoundVariantRecord.when).filter( + ([, value]) => value !== undefined, + ), + ), + style: normalizeStyle(compoundVariantRecord.css), + } + }) + : [], + defaultVariants: Object.fromEntries( + entriesOf(recipeRecord.defaultVariants).filter( + ([, value]) => value !== undefined, + ), + ), + } + }) +} diff --git a/packages/new-v2/system-compiler/src/normalize/schema.ts b/packages/new-v2/system-compiler/src/normalize/schema.ts new file mode 100644 index 00000000..096313c1 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/schema.ts @@ -0,0 +1,139 @@ +import { z } from "zod" + +const styleDeclarationSchema = z.object({ + scope: z.array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("selector"), + value: z.string(), + }), + z.object({ + kind: z.literal("atRule"), + name: z.string(), + params: z.string().optional(), + }), + ]), + ), + property: z.string().min(1), + value: z.union([z.string(), z.number()]), +}) + +export const compilerSystemSchema = z.object({ + name: z.string().min(1), + prefix: z.string().min(1), + tokens: z.object({ + primitive: z.array( + z.object({ + family: z.string().min(1), + path: z.string().min(1), + value: z.string(), + }), + ), + semantic: z.array( + z.object({ + family: z.string().min(1), + path: z.string().min(1), + modes: z + .record(z.string(), z.string()) + .refine((modes) => Object.keys(modes).length > 0), + }), + ), + }), + keyframes: z.array( + z.object({ + name: z.string().min(1), + frames: z.array( + z.object({ + selector: z.string().regex(/^(?:from|to|\d+%)$/), + declarations: z.array( + z.object({ + property: z.string(), + value: z.string(), + }), + ), + }), + ), + }), + ), + composites: z.object({ + textStyles: z.array( + z.object({ + name: z.string().min(1), + fontSize: z.string(), + lineHeight: z.string(), + fontWeight: z.string(), + letterSpacing: z.string().optional(), + }), + ), + animations: z.array( + z.object({ + name: z.string().min(1), + keyframes: z.string().regex(/^keyframes\..+$/), + duration: z.string(), + easing: z.string(), + delay: z.string().optional(), + fillMode: z.string().optional(), + iterationCount: z.union([z.string(), z.number()]).optional(), + direction: z.string().optional(), + }), + ), + }), + recipes: z.array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("recipe"), + name: z.string().min(1), + base: z.array(styleDeclarationSchema), + variants: z.array( + z.object({ + name: z.string(), + value: z.string(), + style: z.array(styleDeclarationSchema), + }), + ), + compoundVariants: z.array( + z.object({ + when: z.record(z.string(), z.string()), + style: z.array(styleDeclarationSchema), + }), + ), + defaultVariants: z.record(z.string(), z.string()), + }), + z.object({ + kind: z.literal("slotRecipe"), + name: z.string().min(1), + slots: z.array(z.string().min(1)), + base: z.array( + z.object({ + slot: z.string(), + style: z.array(styleDeclarationSchema), + }), + ), + variants: z.array( + z.object({ + name: z.string(), + value: z.string(), + slots: z.array( + z.object({ + slot: z.string(), + style: z.array(styleDeclarationSchema), + }), + ), + }), + ), + compoundVariants: z.array( + z.object({ + when: z.record(z.string(), z.string()), + slots: z.array( + z.object({ + slot: z.string(), + style: z.array(styleDeclarationSchema), + }), + ), + }), + ), + defaultVariants: z.record(z.string(), z.string()), + }), + ]), + ), +}) diff --git a/packages/new-v2/system-compiler/src/normalize/styles.ts b/packages/new-v2/system-compiler/src/normalize/styles.ts new file mode 100644 index 00000000..629902c1 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/styles.ts @@ -0,0 +1,76 @@ +import { entriesOf } from "./utils.ts" + +const nestedStyleKeys = { + selector: (property: string) => + property.startsWith("_") || property.startsWith("&"), + atRule: (property: string) => + property.startsWith("@media ") || + property.startsWith("@supports ") || + property.startsWith("@container "), +} + +export function normalizeStyle(style: unknown) { + const output: unknown[] = [] + const stack: Array<{ + scope: unknown[] + style: unknown + }> = [ + { + scope: [], + style, + }, + ] + + for (let index = 0; index < stack.length; index += 1) { + const item = stack[index] + + if (item === undefined) { + continue + } + + for (const [property, value] of entriesOf(item.style)) { + if (value === undefined) { + continue + } + + if (nestedStyleKeys.selector(property)) { + stack.push({ + scope: [ + ...item.scope, + { + kind: "selector", + value: property, + }, + ], + style: value, + }) + continue + } + + if (nestedStyleKeys.atRule(property)) { + const [name = "", ...params] = property.slice(1).split(" ") + + stack.push({ + scope: [ + ...item.scope, + { + kind: "atRule", + name, + ...(params.length > 0 ? { params: params.join(" ") } : {}), + }, + ], + style: value, + }) + continue + } + + output.push({ + scope: item.scope, + property, + value, + }) + } + } + + return output +} diff --git a/packages/new-v2/system-compiler/src/normalize/system.test.ts b/packages/new-v2/system-compiler/src/normalize/system.test.ts new file mode 100644 index 00000000..c6dd519c --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/system.test.ts @@ -0,0 +1,292 @@ +import { + defineKeyframes, + definePrimitiveTokens, + defineRecipe, + defineSemanticTokens, + defineSystem, + defineTextStyles, +} from "@jongh/new-v2-system-core" +import { describe, expect, test } from "vitest" +import { ZodError } from "zod" + +import { normalizeSystem } from "./system.ts" + +const primitiveTokens = definePrimitiveTokens({ + color: { + slate: { + 50: "#f8fafc", + 900: "#0f172a", + }, + }, + spacing: { + 1: "0.25rem", + }, + radius: { + md: "0.375rem", + }, + shadow: { + sm: "0 1px 2px rgba(15, 23, 42, 0.08)", + }, + motion: { + duration: { + fast: "120ms", + }, + easing: { + standard: "ease", + }, + }, + fontSize: { + bodyMd: "1rem", + }, + fontWeight: { + regular: "400", + }, + lineHeight: { + bodyMd: "1.5", + }, + letterSpacing: { + tight: "-0.01em", + }, +}) + +const semanticTokens = defineSemanticTokens({ + color: { + surface: { + base: "color.slate.50", + dark: "color.slate.900", + }, + }, +}) + +const textStyles = defineTextStyles({ + bodyMd: { + fontSize: "fontSize.bodyMd", + lineHeight: "lineHeight.bodyMd", + fontWeight: "fontWeight.regular", + letterSpacing: "letterSpacing.tight", + }, +}) + +const keyframes = defineKeyframes({ + fadeIn: { + from: { + opacity: "0", + }, + to: { + opacity: "1", + }, + }, +}) + +const buttonRecipe = defineRecipe({ + name: "button", + base: { + p: "spacing.1", + _hover: { + color: "color.slate.900", + }, + "@media (min-width: 768px)": { + p: "spacing.1", + }, + }, + variants: { + size: { + sm: { + p: "spacing.1", + }, + }, + }, + defaultVariants: { + size: "sm", + }, +}) + +describe("normalizeSystem", () => { + test("normalizes a system authored with core defineSystem", () => { + const system = defineSystem({ + name: "test-system", + prefix: "jds", + theme: { + primitiveTokens, + semanticTokens, + keyframes, + composites: { + textStyles, + }, + recipes: { + button: buttonRecipe, + }, + }, + }) + + expect(normalizeSystem(system)).toEqual({ + name: "test-system", + prefix: "jds", + tokens: { + primitive: [ + { + family: "spacing", + path: "spacing.1", + value: "0.25rem", + }, + { + family: "radius", + path: "radius.md", + value: "0.375rem", + }, + { + family: "shadow", + path: "shadow.sm", + value: "0 1px 2px rgba(15, 23, 42, 0.08)", + }, + { + family: "fontSize", + path: "fontSize.bodyMd", + value: "1rem", + }, + { + family: "fontWeight", + path: "fontWeight.regular", + value: "400", + }, + { + family: "lineHeight", + path: "lineHeight.bodyMd", + value: "1.5", + }, + { + family: "letterSpacing", + path: "letterSpacing.tight", + value: "-0.01em", + }, + { + family: "color", + path: "color.slate.50", + value: "#f8fafc", + }, + { + family: "color", + path: "color.slate.900", + value: "#0f172a", + }, + { + family: "motion", + path: "motion.duration.fast", + value: "120ms", + }, + { + family: "motion", + path: "motion.easing.standard", + value: "ease", + }, + ], + semantic: [ + { + family: "color", + path: "color.surface", + modes: { + base: "color.slate.50", + dark: "color.slate.900", + }, + }, + ], + }, + keyframes: [ + { + name: "fadeIn", + frames: [ + { + selector: "from", + declarations: [{ property: "opacity", value: "0" }], + }, + { + selector: "to", + declarations: [{ property: "opacity", value: "1" }], + }, + ], + }, + ], + composites: { + textStyles: [ + { + name: "bodyMd", + fontSize: "fontSize.bodyMd", + lineHeight: "lineHeight.bodyMd", + fontWeight: "fontWeight.regular", + letterSpacing: "letterSpacing.tight", + }, + ], + animations: [], + }, + recipes: [ + { + kind: "recipe", + name: "button", + base: [ + { + scope: [], + property: "p", + value: "spacing.1", + }, + { + scope: [ + { + kind: "selector", + value: "_hover", + }, + ], + property: "color", + value: "color.slate.900", + }, + { + scope: [ + { + kind: "atRule", + name: "media", + params: "(min-width: 768px)", + }, + ], + property: "p", + value: "spacing.1", + }, + ], + variants: [ + { + name: "size", + value: "sm", + style: [ + { + scope: [], + property: "p", + value: "spacing.1", + }, + ], + }, + ], + compoundVariants: [], + defaultVariants: { + size: "sm", + }, + }, + ], + }) + }) + + test("throws a ZodError when normalized data does not match the schema", () => { + const invalidSystem = defineSystem({ + name: "invalid-system", + prefix: "jds", + theme: { + keyframes: { + fadeIn: { + middle: { + opacity: "0.5", + }, + }, + }, + }, + }) + + expect(() => normalizeSystem(invalidSystem)).toThrow(ZodError) + }) +}) diff --git a/packages/new-v2/system-compiler/src/normalize/system.ts b/packages/new-v2/system-compiler/src/normalize/system.ts new file mode 100644 index 00000000..1afc61f1 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/system.ts @@ -0,0 +1,23 @@ +import type { SystemDefinition, TokenSource } from "@jongh/new-v2-system-core" + +import { normalizeComposites } from "./composites.ts" +import { normalizeKeyframes } from "./keyframes.ts" +import { normalizeRecipes } from "./recipes.ts" +import { compilerSystemSchema } from "./schema.ts" +import { normalizeTheme } from "./theme.ts" +import { normalizeTokens } from "./tokens.ts" + +export function normalizeSystem( + system: SystemDefinition, +) { + const theme = normalizeTheme(system) + + return compilerSystemSchema.parse({ + name: system.name, + prefix: system.prefix, + tokens: normalizeTokens(theme), + keyframes: normalizeKeyframes(theme.keyframes), + composites: normalizeComposites(theme.composites), + recipes: normalizeRecipes(theme.recipes), + }) +} diff --git a/packages/new-v2/system-compiler/src/normalize/theme.ts b/packages/new-v2/system-compiler/src/normalize/theme.ts new file mode 100644 index 00000000..ec354972 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/theme.ts @@ -0,0 +1,26 @@ +import type { SystemDefinition, TokenSource } from "@jongh/new-v2-system-core" + +import { entriesOf, isRecord, type RecordObject } from "./utils.ts" + +function mergeRecords(base: unknown, override: unknown): RecordObject { + const merged: RecordObject = { ...(isRecord(base) ? base : {}) } + + for (const [key, value] of entriesOf(override)) { + const current = merged[key] + merged[key] = + isRecord(current) && isRecord(value) + ? mergeRecords(current, value) + : value + } + + return merged +} + +export function normalizeTheme( + system: SystemDefinition, +) { + return [system.theme, ...(system.presets ?? [])].reduce( + (theme, nextTheme) => mergeRecords(theme, nextTheme), + {}, + ) +} diff --git a/packages/new-v2/system-compiler/src/normalize/tokens.ts b/packages/new-v2/system-compiler/src/normalize/tokens.ts new file mode 100644 index 00000000..ca9957d2 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/tokens.ts @@ -0,0 +1,86 @@ +import type { RecordObject } from "./utils.ts" +import { entriesOf, isRecord } from "./utils.ts" + +function normalizePrimitiveTokens(tokens: unknown) { + const output: unknown[] = [] + const stack = entriesOf(tokens).map(([family, value]) => ({ + family, + path: [family], + value, + })) + + for (let index = 0; index < stack.length; index += 1) { + const item = stack[index] + + if (item === undefined || item.value === undefined) { + continue + } + + if (isRecord(item.value)) { + for (const [key, child] of Object.entries(item.value)) { + stack.push({ + family: item.family, + path: [...item.path, key], + value: child, + }) + } + + continue + } + + output.push({ + family: item.family, + path: item.path.join("."), + value: typeof item.value === "number" ? String(item.value) : item.value, + }) + } + + return output +} + +function normalizeSemanticTokens(tokens: unknown) { + const output: unknown[] = [] + const stack = entriesOf(tokens).map(([family, value]) => ({ + family, + path: [family], + value, + })) + + for (let index = 0; index < stack.length; index += 1) { + const item = stack[index] + + if (item === undefined || item.value === undefined) { + continue + } + + const values = isRecord(item.value) ? Object.values(item.value) : [] + const isLeaf = + values.length > 0 && values.every((mode) => typeof mode === "string") + + if (isLeaf) { + output.push({ + family: item.family, + path: item.path.join("."), + modes: item.value, + }) + continue + } + + for (const [key, child] of entriesOf(item.value)) { + stack.push({ + family: item.family, + path: [...item.path, key], + value: child, + }) + } + } + + return output +} + +export function normalizeTokens(theme: RecordObject) { + return { + primitive: normalizePrimitiveTokens(theme.primitiveTokens), + semantic: normalizeSemanticTokens(theme.semanticTokens), + } +} diff --git a/packages/new-v2/system-compiler/src/normalize/utils.ts b/packages/new-v2/system-compiler/src/normalize/utils.ts new file mode 100644 index 00000000..d4869d66 --- /dev/null +++ b/packages/new-v2/system-compiler/src/normalize/utils.ts @@ -0,0 +1,9 @@ +export type RecordObject = Record + +export function isRecord(value: unknown): value is RecordObject { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function entriesOf(value: unknown): Array<[string, unknown]> { + return isRecord(value) ? Object.entries(value) : [] +} diff --git a/packages/new-v2/system-compiler/src/output/schema.ts b/packages/new-v2/system-compiler/src/output/schema.ts new file mode 100644 index 00000000..2fc8043a --- /dev/null +++ b/packages/new-v2/system-compiler/src/output/schema.ts @@ -0,0 +1,98 @@ +import { z } from "zod" + +const declarationSchema = z.object({ + property: z.string(), + value: z.string(), +}) + +type CssNodeShape = + | { + kind: "rule" + selector: string + declarations: Array> + children?: CssNodeShape[] + } + | { + kind: "at-rule" + name: string + params?: string + declarations?: Array> + children?: CssNodeShape[] + } + +const cssNodeSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("rule"), + selector: z.string(), + declarations: z.array(declarationSchema), + children: z.array(cssNodeSchema).optional(), + }), + z.object({ + kind: z.literal("at-rule"), + name: z.string(), + params: z.string().optional(), + declarations: z.array(declarationSchema).optional(), + children: z.array(cssNodeSchema).optional(), + }), + ]), +) + +export const compileDocumentSchema = z.object({ + layers: z.array( + z.object({ + name: z.string(), + blocks: z.array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("token"), + family: z.string(), + declarations: z.array(declarationSchema), + }), + z.object({ + kind: z.literal("tokenMode"), + mode: z.string().min(1), + declarations: z.array(declarationSchema), + }), + z.object({ + kind: z.literal("css"), + nodes: z.array(cssNodeSchema), + }), + z.object({ + kind: z.literal("keyframes"), + name: z.string(), + frames: z.array( + z.object({ + selector: z.union([ + z.literal("from"), + z.literal("to"), + z.string().regex(/^\d+%$/), + ]), + declarations: z.array(declarationSchema), + }), + ), + }), + z.object({ + kind: z.literal("composite"), + name: z.string(), + type: z.union([z.literal("textStyle"), z.literal("animation")]), + declarations: z.array(declarationSchema), + }), + z.object({ + kind: z.literal("recipe"), + name: z.string(), + nodes: z.array(cssNodeSchema), + }), + ]), + ), + }), + ), +}) + +export type CompileDocument = z.infer +export type CompileLayer = CompileDocument["layers"][number] +export type CompileBlock = CompileLayer["blocks"][number] +export type CssDeclaration = z.infer +export type CssNode = z.infer +export type CssRule = Extract +export type CssAtRule = Extract diff --git a/packages/new-v2/system-compiler/src/registry/composites.ts b/packages/new-v2/system-compiler/src/registry/composites.ts new file mode 100644 index 00000000..7172763e --- /dev/null +++ b/packages/new-v2/system-compiler/src/registry/composites.ts @@ -0,0 +1,122 @@ +import type { z } from "zod" + +import type { compilerSystemSchema } from "../normalize/schema.ts" +import { getKeyframesName } from "./keyframes.ts" +import { getTokenValue } from "./tokens.ts" +import type { + CompositeRegistry, + KeyframesRegistry, + TokenRegistry, +} from "./types.ts" + +type NormalizedComposites = z.infer["composites"] + +export function createCompositeRegistry( + composites: NormalizedComposites, + tokens: TokenRegistry, + keyframes: KeyframesRegistry, +): CompositeRegistry { + return { + animations: new Map( + composites.animations.map((animation) => { + const declarations = [ + { + property: "animation-name", + value: getKeyframesName(keyframes, animation.keyframes), + }, + { + property: "animation-duration", + value: getTokenValue(tokens, animation.duration), + }, + { + property: "animation-timing-function", + value: getTokenValue(tokens, animation.easing), + }, + ] + + if (animation.delay !== undefined) { + declarations.push({ + property: "animation-delay", + value: getTokenValue(tokens, animation.delay), + }) + } + + if (animation.fillMode !== undefined) { + declarations.push({ + property: "animation-fill-mode", + value: animation.fillMode, + }) + } + + if (animation.iterationCount !== undefined) { + declarations.push({ + property: "animation-iteration-count", + value: String(animation.iterationCount), + }) + } + + if (animation.direction !== undefined) { + declarations.push({ + property: "animation-direction", + value: animation.direction, + }) + } + + return [`animation.${animation.name}`, declarations] + }), + ), + textStyles: new Map( + composites.textStyles.map((textStyle) => { + const declarations = [ + { + property: "font-size", + value: getTokenValue(tokens, textStyle.fontSize), + }, + { + property: "line-height", + value: getTokenValue(tokens, textStyle.lineHeight), + }, + { + property: "font-weight", + value: getTokenValue(tokens, textStyle.fontWeight), + }, + ] + + if (textStyle.letterSpacing !== undefined) { + declarations.push({ + property: "letter-spacing", + value: getTokenValue(tokens, textStyle.letterSpacing), + }) + } + + return [`textStyle.${textStyle.name}`, declarations] + }), + ), + } +} + +export function getAnimationDeclarations( + registry: CompositeRegistry, + reference: string, +) { + const declarations = registry.animations.get(reference) + + if (declarations === undefined) { + throw new Error(`Unknown animation reference "${reference}"`) + } + + return declarations +} + +export function getTextStyleDeclarations( + registry: CompositeRegistry, + reference: string, +) { + const declarations = registry.textStyles.get(reference) + + if (declarations === undefined) { + throw new Error(`Unknown textStyle reference "${reference}"`) + } + + return declarations +} diff --git a/packages/new-v2/system-compiler/src/registry/keyframes.ts b/packages/new-v2/system-compiler/src/registry/keyframes.ts new file mode 100644 index 00000000..9c79f6b5 --- /dev/null +++ b/packages/new-v2/system-compiler/src/registry/keyframes.ts @@ -0,0 +1,29 @@ +import type { z } from "zod" + +import type { compilerSystemSchema } from "../normalize/schema.ts" +import type { KeyframesRegistry } from "./types.ts" + +type NormalizedKeyframes = z.infer["keyframes"] + +export function createKeyframesRegistry( + keyframes: NormalizedKeyframes, +): KeyframesRegistry { + return { + references: new Map( + keyframes.map(({ name }) => [`keyframes.${name}`, name]), + ), + } +} + +export function getKeyframesName( + registry: KeyframesRegistry, + reference: string, +): string { + const name = registry.references.get(reference) + + if (name === undefined) { + throw new Error(`Unknown keyframes reference "${reference}"`) + } + + return name +} diff --git a/packages/new-v2/system-compiler/src/registry/tokens.ts b/packages/new-v2/system-compiler/src/registry/tokens.ts new file mode 100644 index 00000000..950cd9b5 --- /dev/null +++ b/packages/new-v2/system-compiler/src/registry/tokens.ts @@ -0,0 +1,90 @@ +import type { z } from "zod" + +import type { compilerSystemSchema } from "../normalize/schema.ts" +import type { CssDeclaration } from "../output/schema.ts" +import type { TokenRegistry } from "./types.ts" + +type NormalizedTokens = z.infer["tokens"] + +function toKebab(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/_/g, "-") + .toLowerCase() +} + +export function toCssVariableName(prefix: string, path: string): string { + return `--${prefix}-${path.split(".").map(toKebab).join("-")}` +} + +function toCssVariableReference(prefix: string, path: string): string { + return `var(${toCssVariableName(prefix, path)})` +} + +function pushDeclaration( + declarations: Record, + family: string, + declaration: CssDeclaration, +): void { + declarations[family] = [...(declarations[family] ?? []), declaration] +} + +export function createTokenRegistry( + prefix: string, + tokens: NormalizedTokens, +): TokenRegistry { + const declarations: Record = {} + const modeDeclarations: Record = {} + const references = new Map() + + for (const token of tokens.primitive) { + const property = toCssVariableName(prefix, token.path) + references.set(token.path, `var(${property})`) + pushDeclaration(declarations, token.family, { + property, + value: token.value, + }) + } + + for (const token of tokens.semantic) { + const property = toCssVariableName(prefix, token.path) + const modeEntries = Object.entries(token.modes) + const defaultModeEntry = + modeEntries.find(([mode]) => mode === "base") ?? modeEntries[0] + + if (defaultModeEntry === undefined) { + continue + } + + const [defaultMode, referencedPath] = defaultModeEntry + references.set(token.path, `var(${property})`) + pushDeclaration(declarations, token.family, { + property, + value: toCssVariableReference(prefix, referencedPath), + }) + + for (const [mode, modeReferencedPath] of modeEntries) { + if (mode === defaultMode) { + continue + } + + modeDeclarations[mode] = [ + ...(modeDeclarations[mode] ?? []), + { + property, + value: toCssVariableReference(prefix, modeReferencedPath), + }, + ] + } + } + + return { + declarations, + modeDeclarations, + references, + } +} + +export function getTokenValue(registry: TokenRegistry, path: string): string { + return registry.references.get(path) ?? path +} diff --git a/packages/new-v2/system-compiler/src/registry/types.ts b/packages/new-v2/system-compiler/src/registry/types.ts new file mode 100644 index 00000000..9772cf12 --- /dev/null +++ b/packages/new-v2/system-compiler/src/registry/types.ts @@ -0,0 +1,22 @@ +import type { CssDeclaration } from "../output/schema.ts" + +export type TokenRegistry = { + declarations: Record + modeDeclarations: Record + references: Map +} + +export type KeyframesRegistry = { + references: Map +} + +export type CompositeRegistry = { + animations: Map + textStyles: Map +} + +export type CompilerRegistry = { + tokens: TokenRegistry + keyframes: KeyframesRegistry + composites: CompositeRegistry +} diff --git a/packages/new-v2/system-compiler/src/render/output.ts b/packages/new-v2/system-compiler/src/render/output.ts new file mode 100644 index 00000000..500bfafc --- /dev/null +++ b/packages/new-v2/system-compiler/src/render/output.ts @@ -0,0 +1,70 @@ +import type { CompileLayer, CssDeclaration, CssNode } from "../output/schema.ts" +import { stringifyCssNodes } from "./postcss.ts" + +function layerToNodes(layer: CompileLayer): CssNode[] { + const tokenDeclarations = layer.blocks.flatMap((block) => + block.kind === "token" ? block.declarations : [], + ) + const modeDeclarations = new Map() + + for (const block of layer.blocks) { + if (block.kind !== "tokenMode") { + continue + } + + modeDeclarations.set(block.mode, [ + ...(modeDeclarations.get(block.mode) ?? []), + ...block.declarations, + ]) + } + + const nodes: CssNode[] = + tokenDeclarations.length > 0 + ? [ + { + kind: "rule", + selector: ":root", + declarations: tokenDeclarations, + }, + ] + : [] + + for (const [mode, declarations] of modeDeclarations) { + nodes.push({ + kind: "rule", + selector: `.${mode}`, + declarations, + }) + } + + for (const block of layer.blocks) { + if (block.kind === "css") { + nodes.push(...block.nodes) + continue + } + + if (block.kind === "keyframes") { + nodes.push({ + kind: "at-rule", + name: "keyframes", + params: block.name, + children: block.frames.map((frame) => ({ + kind: "rule", + selector: frame.selector, + declarations: frame.declarations, + })), + }) + continue + } + + if (block.kind === "recipe") { + nodes.push(...block.nodes) + } + } + + return nodes +} + +export function renderLayer(layer: CompileLayer): string { + return stringifyCssNodes(layerToNodes(layer)) +} diff --git a/packages/new-v2/system-compiler/src/render/postcss.ts b/packages/new-v2/system-compiler/src/render/postcss.ts new file mode 100644 index 00000000..47000d8d --- /dev/null +++ b/packages/new-v2/system-compiler/src/render/postcss.ts @@ -0,0 +1,69 @@ +import postcss from "postcss" + +import type { + CssAtRule, + CssDeclaration, + CssNode, + CssRule, +} from "../output/schema.ts" + +function appendDeclarations( + container: postcss.Container, + declarations: CssDeclaration[] | undefined, +): void { + for (const declaration of declarations ?? []) { + container.append( + postcss.decl({ + prop: declaration.property, + value: declaration.value, + }), + ) + } +} + +function toPostcssNode(node: CssNode): postcss.Rule | postcss.AtRule { + if (node.kind === "rule") { + return toPostcssRule(node) + } + + return toPostcssAtRule(node) +} + +function toPostcssRule(node: CssRule): postcss.Rule { + const rule = postcss.rule({ + selector: node.selector, + }) + + appendDeclarations(rule, node.declarations) + + for (const child of node.children ?? []) { + rule.append(toPostcssNode(child)) + } + + return rule +} + +function toPostcssAtRule(node: CssAtRule): postcss.AtRule { + const atRule = postcss.atRule({ + name: node.name, + params: node.params, + }) + + appendDeclarations(atRule, node.declarations) + + for (const child of node.children ?? []) { + atRule.append(toPostcssNode(child)) + } + + return atRule +} + +export function stringifyCssNodes(nodes: CssNode[]): string { + const root = postcss.root() + + for (const node of nodes) { + root.append(toPostcssNode(node)) + } + + return root.toString() +} diff --git a/packages/new-v2/system-compiler/src/transform/properties.ts b/packages/new-v2/system-compiler/src/transform/properties.ts new file mode 100644 index 00000000..fbe4d848 --- /dev/null +++ b/packages/new-v2/system-compiler/src/transform/properties.ts @@ -0,0 +1,86 @@ +import type { CssDeclaration } from "../output/schema.ts" +import { + getAnimationDeclarations, + getTextStyleDeclarations, +} from "../registry/composites.ts" +import { getTokenValue } from "../registry/tokens.ts" +import type { CompilerRegistry } from "../registry/types.ts" + +const shorthandProperties = { + p: ["padding"], + px: ["padding-left", "padding-right"], + py: ["padding-top", "padding-bottom"], + pt: ["padding-top"], + pr: ["padding-right"], + pb: ["padding-bottom"], + pl: ["padding-left"], + m: ["margin"], + mx: ["margin-left", "margin-right"], + my: ["margin-top", "margin-bottom"], + rounded: ["border-radius"], + bg: ["background-color"], +} as const + +type PropertyTransformer = ( + value: unknown, + registry: CompilerRegistry, +) => CssDeclaration[] + +const compositePropertyTransformers = { + animation(value, registry) { + return typeof value === "string" + ? getAnimationDeclarations(registry.composites, value) + : [] + }, + textStyle(value, registry) { + return typeof value === "string" + ? getTextStyleDeclarations(registry.composites, value) + : [] + }, +} satisfies Record + +function toKebab(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/_/g, "-") + .toLowerCase() +} + +function toCssProperties(property: string): string[] { + if (property in shorthandProperties) { + return [ + ...shorthandProperties[property as keyof typeof shorthandProperties], + ] + } + + return [toKebab(property)] +} + +function isTokenReference(value: unknown): value is string { + return ( + typeof value === "string" && /^[a-zA-Z][\w-]*(?:\.[\w-]+)+$/.test(value) + ) +} + +export function transformProperty( + property: string, + value: unknown, + registry: CompilerRegistry, +): CssDeclaration[] { + if (property in compositePropertyTransformers) { + return compositePropertyTransformers[ + property as keyof typeof compositePropertyTransformers + ](value, registry) + } + + if (typeof value !== "string" && typeof value !== "number") { + return [] + } + + return toCssProperties(property).map((cssProperty) => ({ + property: cssProperty, + value: isTokenReference(value) + ? getTokenValue(registry.tokens, String(value)) + : String(value), + })) +} diff --git a/packages/new-v2/system-compiler/src/transform/recipes.ts b/packages/new-v2/system-compiler/src/transform/recipes.ts new file mode 100644 index 00000000..4178c648 --- /dev/null +++ b/packages/new-v2/system-compiler/src/transform/recipes.ts @@ -0,0 +1,220 @@ +import type { z } from "zod" + +import type { compilerSystemSchema } from "../normalize/schema.ts" +import type { CssDeclaration, CssNode } from "../output/schema.ts" +import type { CompilerRegistry } from "../registry/types.ts" +import { transformProperty } from "./properties.ts" +import { + getCompoundClassName, + getNestedSelector, + getRecipeClassName, + getSlotClassName, + getVariantClassName, +} from "./selectors.ts" + +type NormalizedSystem = z.infer +type NormalizedRecipe = NormalizedSystem["recipes"][number] +type StyleDeclaration = Extract< + NormalizedRecipe, + { kind: "recipe" } +>["base"][number] +export type RecipePropertyTransformer = ( + property: string, + value: unknown, + registry: CompilerRegistry, +) => CssDeclaration[] + +function transformStyle( + selector: string, + style: StyleDeclaration[], + registry: CompilerRegistry, + propertyTransformer: RecipePropertyTransformer, +) { + const groups = new Map< + string, + { + scope: StyleDeclaration["scope"] + declarations: CssDeclaration[] + } + >() + + for (const declaration of style) { + const cssDeclarations = propertyTransformer( + declaration.property, + declaration.value, + registry, + ) + + if (cssDeclarations.length === 0) { + continue + } + + const key = JSON.stringify(declaration.scope) + const group = groups.get(key) ?? { + scope: declaration.scope, + declarations: [], + } + + group.declarations.push(...cssDeclarations) + groups.set(key, group) + } + + return Array.from(groups.values()).map((group) => + createScopedNode(selector, group.scope, group.declarations), + ) +} + +function createScopedNode( + selector: string, + scope: StyleDeclaration["scope"], + declarations: CssDeclaration[], +) { + let currentSelector = selector + let node: CssNode + const atRules: Array< + Extract + > = [] + + for (const item of scope) { + if (item.kind === "selector") { + currentSelector = getNestedSelector(currentSelector, item.value) + continue + } + + atRules.push(item) + } + + node = { + kind: "rule", + selector: currentSelector, + declarations, + } + + for (const atRule of atRules.reverse()) { + node = { + kind: "at-rule", + name: atRule.name, + ...(atRule.params === undefined ? {} : { params: atRule.params }), + children: [node], + } + } + + return node +} + +function transformRecipe( + prefix: string, + recipe: Extract, + registry: CompilerRegistry, + propertyTransformer: RecipePropertyTransformer, +) { + const baseClassName = getRecipeClassName(prefix, recipe.name) + const nodes: CssNode[] = [ + ...transformStyle( + `.${baseClassName}`, + recipe.base, + registry, + propertyTransformer, + ), + ] + + for (const variant of recipe.variants) { + nodes.push( + ...transformStyle( + `.${getVariantClassName(baseClassName, variant.name, variant.value)}`, + variant.style, + registry, + propertyTransformer, + ), + ) + } + + for (const compoundVariant of recipe.compoundVariants) { + nodes.push( + ...transformStyle( + `.${getCompoundClassName(baseClassName, compoundVariant.when)}`, + compoundVariant.style, + registry, + propertyTransformer, + ), + ) + } + + return nodes +} + +function transformSlotRecipe( + prefix: string, + recipe: Extract, + registry: CompilerRegistry, + propertyTransformer: RecipePropertyTransformer, +) { + const baseClassName = getRecipeClassName(prefix, recipe.name) + const nodes: CssNode[] = [] + + for (const slotStyle of recipe.base) { + nodes.push( + ...transformStyle( + `.${getSlotClassName(baseClassName, slotStyle.slot)}`, + slotStyle.style, + registry, + propertyTransformer, + ), + ) + } + + for (const variant of recipe.variants) { + for (const slotStyle of variant.slots) { + nodes.push( + ...transformStyle( + `.${getVariantClassName( + getSlotClassName(baseClassName, slotStyle.slot), + variant.name, + variant.value, + )}`, + slotStyle.style, + registry, + propertyTransformer, + ), + ) + } + } + + for (const compoundVariant of recipe.compoundVariants) { + for (const slotStyle of compoundVariant.slots) { + nodes.push( + ...transformStyle( + `.${getCompoundClassName( + getSlotClassName(baseClassName, slotStyle.slot), + compoundVariant.when, + )}`, + slotStyle.style, + registry, + propertyTransformer, + ), + ) + } + } + + return nodes +} + +export function transformRecipes( + system: NormalizedSystem, + registry: CompilerRegistry, + propertyTransformer: RecipePropertyTransformer = transformProperty, +) { + return system.recipes.map((recipe) => ({ + kind: "recipe" as const, + name: recipe.name, + nodes: + recipe.kind === "slotRecipe" + ? transformSlotRecipe( + system.prefix, + recipe, + registry, + propertyTransformer, + ) + : transformRecipe(system.prefix, recipe, registry, propertyTransformer), + })) +} diff --git a/packages/new-v2/system-compiler/src/transform/selectors.ts b/packages/new-v2/system-compiler/src/transform/selectors.ts new file mode 100644 index 00000000..8b56b8c7 --- /dev/null +++ b/packages/new-v2/system-compiler/src/transform/selectors.ts @@ -0,0 +1,64 @@ +const pseudoSelectors = { + _active: ":active", + _checked: ":checked", + _closed: "[data-state='closed']", + _disabled: ":disabled", + _focus: ":focus", + _focusVisible: ":focus-visible", + _focusWithin: ":focus-within", + _hover: ":hover", + _open: "[data-state='open']", + _selected: "[data-selected]", +} as const + +export function getRecipeClassName(prefix: string, name: string): string { + return `${prefix}-${name}` +} + +export function getVariantClassName( + baseClassName: string, + variantName: string, + variantValue: string, +): string { + return `${baseClassName}--${variantName}_${variantValue}` +} + +export function getCompoundClassName( + baseClassName: string, + selection: Record, +): string { + const suffix = Object.entries(selection) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => `${name}_${value}`) + .join("-") + + return `${baseClassName}--${suffix}` +} + +export function getSlotClassName(baseClassName: string, slot: string): string { + return `${baseClassName}__${slot}` +} + +export function getNestedSelector(parent: string, key: string): string { + if (key in pseudoSelectors) { + return `${parent}${pseudoSelectors[key as keyof typeof pseudoSelectors]}` + } + + if (key.startsWith("&")) { + return key.replaceAll("&", parent) + } + + return key +} + +export function isNestedSelector(key: string): boolean { + return key in pseudoSelectors || key.startsWith("&") +} + +export function isNestedAtRule(key: string): boolean { + return ( + key.startsWith("@media ") || + key.startsWith("@supports ") || + key.startsWith("@container ") + ) +} diff --git a/packages/new-v2/system-compiler/test/compiler.test.ts b/packages/new-v2/system-compiler/test/compiler.test.ts new file mode 100644 index 00000000..dbb11978 --- /dev/null +++ b/packages/new-v2/system-compiler/test/compiler.test.ts @@ -0,0 +1,300 @@ +import { + defineAnimations, + defineKeyframes, + definePrimitiveTokens, + defineRecipe, + defineSemanticTokens, + defineSlotRecipe, + defineSystem, + defineTextStyles, +} from "@jongh/new-v2-system-core" +import { describe, expect, test } from "vitest" + +import { compileSystem, emitFiles } from "../src/index.ts" + +const primitiveTokens = definePrimitiveTokens({ + color: { + slate: { + 50: "#f8fafc", + 900: "#0f172a", + }, + }, + spacing: { + 1: "0.25rem", + 2: "0.5rem", + }, + radius: { + md: "0.375rem", + }, + shadow: { + sm: "0 1px 2px rgba(15, 23, 42, 0.08)", + }, + motion: { + duration: { + fast: "120ms", + }, + easing: { + standard: "ease", + }, + }, + fontSize: { + bodyMd: "1rem", + }, + fontWeight: { + regular: "400", + }, + lineHeight: { + bodyMd: "1.5", + }, + letterSpacing: { + tight: "-0.01em", + }, +}) + +const semanticTokens = defineSemanticTokens({ + color: { + surface: { + base: "color.slate.50", + dark: "color.slate.900", + }, + }, +}) + +const textStyles = defineTextStyles({ + bodyMd: { + fontSize: "fontSize.bodyMd", + lineHeight: "lineHeight.bodyMd", + fontWeight: "fontWeight.regular", + letterSpacing: "letterSpacing.tight", + }, +}) + +const keyframes = defineKeyframes({ + fadeIn: { + from: { + opacity: "0", + }, + to: { + opacity: "1", + }, + }, +}) + +const animations = defineAnimations({ + fadeInFast: { + keyframes: "keyframes.fadeIn", + duration: "motion.duration.fast", + easing: "motion.easing.standard", + fillMode: "both", + }, +}) + +const buttonRecipe = defineRecipe({ + name: "button", + base: { + animation: "animation.fadeInFast", + color: "color.surface", + p: "spacing.1", + rounded: "radius.md", + textStyle: "textStyle.bodyMd", + _hover: { + color: "color.slate.900", + }, + }, + variants: { + size: { + sm: { + p: "spacing.1", + }, + md: { + p: "spacing.2", + }, + }, + }, + compoundVariants: [ + { + when: { + size: "md", + }, + css: { + rounded: "radius.md", + }, + }, + ], + defaultVariants: { + size: "sm", + }, +}) + +const tabsRecipe = defineSlotRecipe({ + name: "tabs", + slots: ["root", "trigger"] as const, + base: { + root: { + color: "color.surface", + }, + }, + variants: { + tone: { + solid: { + trigger: { + color: "color.slate.900", + }, + }, + }, + }, + defaultVariants: { + tone: "solid", + }, +}) + +const system = defineSystem({ + name: "test-system", + prefix: "jds", + theme: { + primitiveTokens, + semanticTokens, + keyframes, + composites: { + textStyles, + animations, + }, + recipes: { + button: buttonRecipe, + tabs: tabsRecipe, + }, + }, +}) + +describe("compileSystem", () => { + test("compiles a system definition to the expected css files", () => { + const document = compileSystem(system, { + target: "css", + }) + const result = emitFiles(document.layers, {}) + + expect(result.files).toEqual([ + { + path: "tokens.css", + contentType: "text/css", + content: `:root { + --jds-spacing-1: 0.25rem; + --jds-spacing-2: 0.5rem; + --jds-radius-md: 0.375rem; + --jds-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.08); + --jds-font-size-body-md: 1rem; + --jds-font-weight-regular: 400; + --jds-line-height-body-md: 1.5; + --jds-letter-spacing-tight: -0.01em; + --jds-color-slate-50: #f8fafc; + --jds-color-slate-900: #0f172a; + --jds-color-surface: var(--jds-color-slate-50); + --jds-motion-duration-fast: 120ms; + --jds-motion-easing-standard: ease +} +.dark { + --jds-color-surface: var(--jds-color-slate-900) +}`, + }, + { + path: "keyframes.css", + contentType: "text/css", + content: `@keyframes fadeIn { + from { + opacity: 0 + } + to { + opacity: 1 + } +}`, + }, + { + path: "recipes.css", + contentType: "text/css", + content: `.jds-button { + animation-name: fadeIn; + animation-duration: var(--jds-motion-duration-fast); + animation-timing-function: var(--jds-motion-easing-standard); + animation-fill-mode: both; + color: var(--jds-color-surface); + padding: var(--jds-spacing-1); + border-radius: var(--jds-radius-md); + font-size: var(--jds-font-size-body-md); + line-height: var(--jds-line-height-body-md); + font-weight: var(--jds-font-weight-regular); + letter-spacing: var(--jds-letter-spacing-tight) +} +.jds-button:hover { + color: var(--jds-color-slate-900) +} +.jds-button--size_sm { + padding: var(--jds-spacing-1) +} +.jds-button--size_md { + padding: var(--jds-spacing-2) +} +.jds-button--size_md { + border-radius: var(--jds-radius-md) +} +.jds-tabs__root { + color: var(--jds-color-surface) +} +.jds-tabs__trigger--tone_solid { + color: var(--jds-color-slate-900) +}`, + }, + ]) + }) + + test("uses emit options to choose output file paths", () => { + const document = compileSystem(system, { + target: "css", + }) + const result = emitFiles(document.layers, { + outputFiles: { + tokens: "styles/variables.css", + keyframes: "styles/keyframes.css", + recipes: "styles/components.css", + }, + }) + + expect(result.files.map((file) => file.path)).toEqual([ + "styles/variables.css", + "styles/keyframes.css", + "styles/components.css", + ]) + }) + + test("throws a clear error for missing animation references", () => { + const invalidRecipe = defineRecipe({ + name: "button", + base: { + animation: "animation.missing" as never, + }, + variants: {}, + defaultVariants: {}, + }) + + const invalidSystem = defineSystem({ + name: "invalid-system", + prefix: "jds", + theme: { + primitiveTokens, + semanticTokens, + keyframes, + composites: { + animations, + }, + recipes: { + button: invalidRecipe, + }, + }, + }) + + expect(() => + compileSystem(invalidSystem, { + target: "css", + }), + ).toThrow('Unknown animation reference "animation.missing"') + }) +}) diff --git a/packages/new-v2/system-compiler/tsconfig.json b/packages/new-v2/system-compiler/tsconfig.json new file mode 100644 index 00000000..226b3010 --- /dev/null +++ b/packages/new-v2/system-compiler/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@jongh/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/new-v2/system-compiler/tsdown.config.ts b/packages/new-v2/system-compiler/tsdown.config.ts new file mode 100644 index 00000000..ff20f1ff --- /dev/null +++ b/packages/new-v2/system-compiler/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + outExtensions: () => ({ js: ".js" }), + clean: true, + sourcemap: false, + minify: false, + dts: true, + platform: "node", + target: "node22", + deps: { + neverBundle: ["@jongh/new-v2-system-core", "postcss", "zod"], + }, +}) From c9f77bdc9925d9da8c49abdccbda726c06b7f2b1 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 23:03:16 +0900 Subject: [PATCH 3/8] feat(system-cli): add compile command --- packages/new-v2/system-cli/eslint.config.mjs | 4 + packages/new-v2/system-cli/package.json | 46 +++++ packages/new-v2/system-cli/src/command.ts | 25 +++ packages/new-v2/system-cli/src/compile.ts | 59 ++++++ packages/new-v2/system-cli/src/index.ts | 20 ++ packages/new-v2/system-cli/src/schema.ts | 28 +++ .../new-v2/system-cli/test/compile.test.ts | 105 +++++++++++ .../test/fixtures/basic-system.config.ts | 176 ++++++++++++++++++ packages/new-v2/system-cli/tsconfig.json | 8 + packages/new-v2/system-cli/tsdown.config.ts | 22 +++ packages/new-v2/system-cli/vitest.config.ts | 3 + 11 files changed, 496 insertions(+) create mode 100644 packages/new-v2/system-cli/eslint.config.mjs create mode 100644 packages/new-v2/system-cli/package.json create mode 100644 packages/new-v2/system-cli/src/command.ts create mode 100644 packages/new-v2/system-cli/src/compile.ts create mode 100644 packages/new-v2/system-cli/src/index.ts create mode 100644 packages/new-v2/system-cli/src/schema.ts create mode 100644 packages/new-v2/system-cli/test/compile.test.ts create mode 100644 packages/new-v2/system-cli/test/fixtures/basic-system.config.ts create mode 100644 packages/new-v2/system-cli/tsconfig.json create mode 100644 packages/new-v2/system-cli/tsdown.config.ts create mode 100644 packages/new-v2/system-cli/vitest.config.ts diff --git a/packages/new-v2/system-cli/eslint.config.mjs b/packages/new-v2/system-cli/eslint.config.mjs new file mode 100644 index 00000000..f455be36 --- /dev/null +++ b/packages/new-v2/system-cli/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@jongh/eslint/base" + +/** @type {import("eslint").Linter.Config[]} */ +export default [...config] diff --git a/packages/new-v2/system-cli/package.json b/packages/new-v2/system-cli/package.json new file mode 100644 index 00000000..8445ac19 --- /dev/null +++ b/packages/new-v2/system-cli/package.json @@ -0,0 +1,46 @@ +{ + "name": "@jongh/new-v2-system-cli", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "디자인 시스템 설정을 CSS 파일로 생성하는 CLI입니다.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "jds-system": "./dist/index.js" + }, + "files": [ + "dist", + "package.json" + ], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "tsdown", + "lint": "eslint --max-warnings 0 --no-warn-ignored .", + "check-type": "tsgo --noEmit", + "test": "pnpm exec vitest run" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@jongh/new-v2-system-compiler": "workspace:*", + "@jongh/new-v2-system-core": "workspace:*", + "commander": "^13.1.0", + "jiti": "^2.6.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@jongh/eslint": "workspace:*", + "@jongh/tsconfig": "workspace:*", + "@types/node": "^22.8.6", + "tsdown": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/new-v2/system-cli/src/command.ts b/packages/new-v2/system-cli/src/command.ts new file mode 100644 index 00000000..377ce14a --- /dev/null +++ b/packages/new-v2/system-cli/src/command.ts @@ -0,0 +1,25 @@ +import { Command } from "commander" + +import { runCompile } from "./compile.ts" +import { cliOptionsSchema } from "./schema.ts" + +export function createCompileCommand() { + return new Command() + .name("compile") + .description("Compile a design system config to CSS files.") + .option( + "-c, --cwd ", + "current working directory, defaults to process.cwd()", + ) + .option("--config ", "config path") + .option("--outdir ", "output directory override") + .option("--target ", "compiler target") + .action(async (rawOptions) => { + const options = cliOptionsSchema.parse({ + ...rawOptions, + cwd: rawOptions.cwd ?? process.cwd(), + }) + + await runCompile(options) + }) +} diff --git a/packages/new-v2/system-cli/src/compile.ts b/packages/new-v2/system-cli/src/compile.ts new file mode 100644 index 00000000..986cca7d --- /dev/null +++ b/packages/new-v2/system-cli/src/compile.ts @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from "node:fs/promises" +import path from "node:path" + +import { + type CompileFile, + compileSystem, + emitFiles, +} from "@jongh/new-v2-system-compiler" +import { createJiti } from "jiti" + +import { type CliOptions, configFileSchema } from "./schema.ts" + +export interface CompileCliResult { + files: CompileFile[] + writtenFiles: string[] +} + +export async function runCompile(input: CliOptions): Promise { + const paths = { + cwd: path.resolve(input.cwd), + config: input.config, + outdir: "", + } + paths.config = path.resolve(paths.cwd, paths.config) + + const config = configFileSchema.parse( + await createJiti(paths.config, { + moduleCache: false, + }).import(paths.config, { + default: true, + }), + ) + paths.outdir = path.resolve(paths.cwd, input.outdir ?? config.outdir) + const document = compileSystem(config.system, { + ...config.compiler, + ...(input.target === undefined ? {} : { target: input.target }), + }) + const result = emitFiles(document.layers, config.options) + + const writtenFiles = await Promise.all( + result.files.map(async (file) => { + const outputPath = path.isAbsolute(file.path) + ? file.path + : path.resolve(paths.outdir, file.path) + + await mkdir(path.dirname(outputPath), { + recursive: true, + }) + await writeFile(outputPath, file.content, "utf8") + + return outputPath + }), + ) + + return { + files: result.files, + writtenFiles, + } +} diff --git a/packages/new-v2/system-cli/src/index.ts b/packages/new-v2/system-cli/src/index.ts new file mode 100644 index 00000000..e6270c01 --- /dev/null +++ b/packages/new-v2/system-cli/src/index.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import { Command } from "commander" + +import packageJson from "../package.json" +import { createCompileCommand } from "./command.ts" + +async function main() { + const command = new Command() + .name("jds-system") + .version(packageJson.version || "0.0.0") + .addCommand(createCompileCommand()) + + await command.parseAsync() +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/packages/new-v2/system-cli/src/schema.ts b/packages/new-v2/system-cli/src/schema.ts new file mode 100644 index 00000000..38286700 --- /dev/null +++ b/packages/new-v2/system-cli/src/schema.ts @@ -0,0 +1,28 @@ +import { + compileOptionsSchema, + compileTargetSchema, + emitFilesOptionsSchema, +} from "@jongh/new-v2-system-compiler" +import type { SystemDefinition, TokenSource } from "@jongh/new-v2-system-core" +import { z } from "zod" + +const pathSchema = z.string().min(1) + +export const configFileSchema = z.object({ + system: z.custom>(), + compiler: compileOptionsSchema.default({ + target: "css", + }), + outdir: pathSchema.default("styled-system"), + options: emitFilesOptionsSchema.default({}), +}) + +export const cliOptionsSchema = z.object({ + cwd: z.string().min(1), + config: pathSchema.default("jds.config.ts"), + outdir: pathSchema.optional(), + target: compileTargetSchema.optional(), +}) + +export type ConfigFile = z.infer +export type CliOptions = z.infer diff --git a/packages/new-v2/system-cli/test/compile.test.ts b/packages/new-v2/system-cli/test/compile.test.ts new file mode 100644 index 00000000..3caf8d33 --- /dev/null +++ b/packages/new-v2/system-cli/test/compile.test.ts @@ -0,0 +1,105 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" + +import { afterAll, beforeAll, describe, expect, test } from "vitest" + +import { runCompile } from "../src/compile.ts" +import config from "./fixtures/basic-system.config.ts" + +let outdir: string + +beforeAll(async () => { + outdir = await mkdtemp(path.join(tmpdir(), "system-cli-")) +}) + +afterAll(async () => { + await rm(outdir, { + recursive: true, + force: true, + }) +}) + +describe("runCompile", () => { + test("loads a TypeScript system config and writes compiler output files", async () => { + const result = await runCompile({ + cwd: path.resolve("."), + config: "test/fixtures/basic-system.config.ts", + outdir, + }) + const expectedCssByFile = { + [config.options.outputFiles.tokens]: `:root { + --jds-spacing-1: 0.25rem; + --jds-spacing-2: 0.5rem; + --jds-radius-md: 0.375rem; + --jds-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.08); + --jds-font-size-body-md: 1rem; + --jds-font-weight-regular: 400; + --jds-line-height-body-md: 1.5; + --jds-letter-spacing-tight: -0.01em; + --jds-color-slate-50: #f8fafc; + --jds-color-slate-900: #0f172a; + --jds-color-surface: var(--jds-color-slate-50); + --jds-motion-duration-fast: 120ms; + --jds-motion-easing-standard: ease +} +.dark { + --jds-color-surface: var(--jds-color-slate-900) +}`, + [config.options.outputFiles.keyframes]: `@keyframes fadeIn { + from { + opacity: 0 + } + to { + opacity: 1 + } +}`, + [config.options.outputFiles.recipes]: `.jds-button { + animation-name: fadeIn; + animation-duration: var(--jds-motion-duration-fast); + animation-timing-function: var(--jds-motion-easing-standard); + animation-fill-mode: both; + color: var(--jds-color-surface); + padding: var(--jds-spacing-1); + border-radius: var(--jds-radius-md); + font-size: var(--jds-font-size-body-md); + line-height: var(--jds-line-height-body-md); + font-weight: var(--jds-font-weight-regular); + letter-spacing: var(--jds-letter-spacing-tight) +} +.jds-button:hover { + color: var(--jds-color-slate-900) +} +.jds-button--size_sm { + padding: var(--jds-spacing-1) +} +.jds-button--size_md { + padding: var(--jds-spacing-2) +} +.jds-button--size_md { + border-radius: var(--jds-radius-md) +} +.jds-tabs__root { + color: var(--jds-color-surface) +} +.jds-tabs__trigger--tone_solid { + color: var(--jds-color-slate-900) +}`, + } + const expectedWrittenFiles = Object.keys(expectedCssByFile).map((file) => + path.join(outdir, file), + ) + + expect(result.files.map((file) => file.path)).toEqual( + Object.keys(expectedCssByFile), + ) + expect(result.writtenFiles).toEqual(expectedWrittenFiles) + await Promise.all( + result.writtenFiles.map(async (file, index) => { + await expect(readFile(file, "utf8")).resolves.toBe( + Object.values(expectedCssByFile)[index], + ) + }), + ) + }) +}) diff --git a/packages/new-v2/system-cli/test/fixtures/basic-system.config.ts b/packages/new-v2/system-cli/test/fixtures/basic-system.config.ts new file mode 100644 index 00000000..b3039503 --- /dev/null +++ b/packages/new-v2/system-cli/test/fixtures/basic-system.config.ts @@ -0,0 +1,176 @@ +import { + defineAnimations, + defineKeyframes, + definePrimitiveTokens, + defineRecipe, + defineSemanticTokens, + defineSlotRecipe, + defineSystem, + defineTextStyles, +} from "@jongh/new-v2-system-core" + +const primitiveTokens = definePrimitiveTokens({ + color: { + slate: { + 50: "#f8fafc", + 900: "#0f172a", + }, + }, + spacing: { + 1: "0.25rem", + 2: "0.5rem", + }, + radius: { + md: "0.375rem", + }, + shadow: { + sm: "0 1px 2px rgba(15, 23, 42, 0.08)", + }, + motion: { + duration: { + fast: "120ms", + }, + easing: { + standard: "ease", + }, + }, + fontSize: { + bodyMd: "1rem", + }, + fontWeight: { + regular: "400", + }, + lineHeight: { + bodyMd: "1.5", + }, + letterSpacing: { + tight: "-0.01em", + }, +}) + +const semanticTokens = defineSemanticTokens({ + color: { + surface: { + base: "color.slate.50", + dark: "color.slate.900", + }, + }, +}) + +const textStyles = defineTextStyles({ + bodyMd: { + fontSize: "fontSize.bodyMd", + lineHeight: "lineHeight.bodyMd", + fontWeight: "fontWeight.regular", + letterSpacing: "letterSpacing.tight", + }, +}) + +const keyframes = defineKeyframes({ + fadeIn: { + from: { + opacity: "0", + }, + to: { + opacity: "1", + }, + }, +}) + +const animations = defineAnimations({ + fadeInFast: { + keyframes: "keyframes.fadeIn", + duration: "motion.duration.fast", + easing: "motion.easing.standard", + fillMode: "both", + }, +}) + +const buttonRecipe = defineRecipe({ + name: "button", + base: { + animation: "animation.fadeInFast", + color: "color.surface", + p: "spacing.1", + rounded: "radius.md", + textStyle: "textStyle.bodyMd", + _hover: { + color: "color.slate.900", + }, + }, + variants: { + size: { + sm: { + p: "spacing.1", + }, + md: { + p: "spacing.2", + }, + }, + }, + compoundVariants: [ + { + when: { + size: "md", + }, + css: { + rounded: "radius.md", + }, + }, + ], + defaultVariants: { + size: "sm", + }, +}) + +const tabsRecipe = defineSlotRecipe({ + name: "tabs", + slots: ["root", "trigger"] as const, + base: { + root: { + color: "color.surface", + }, + }, + variants: { + tone: { + solid: { + trigger: { + color: "color.slate.900", + }, + }, + }, + }, + defaultVariants: { + tone: "solid", + }, +}) + +const system = defineSystem({ + name: "basic-system", + prefix: "jds", + theme: { + primitiveTokens, + semanticTokens, + keyframes, + composites: { + textStyles, + animations, + }, + recipes: { + button: buttonRecipe, + tabs: tabsRecipe, + }, + }, +}) + +export default { + system, + outdir: "styled-system", + options: { + outputFiles: { + tokens: "variables.css", + keyframes: "motion.css", + recipes: "components.css", + }, + }, +} diff --git a/packages/new-v2/system-cli/tsconfig.json b/packages/new-v2/system-cli/tsconfig.json new file mode 100644 index 00000000..226b3010 --- /dev/null +++ b/packages/new-v2/system-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@jongh/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/new-v2/system-cli/tsdown.config.ts b/packages/new-v2/system-cli/tsdown.config.ts new file mode 100644 index 00000000..1c26b9ae --- /dev/null +++ b/packages/new-v2/system-cli/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + outExtensions: () => ({ js: ".js" }), + clean: true, + sourcemap: false, + minify: false, + dts: true, + platform: "node", + target: "node22", + deps: { + neverBundle: [ + "@jongh/new-v2-system-compiler", + "@jongh/new-v2-system-core", + "commander", + "jiti", + "zod", + ], + }, +}) diff --git a/packages/new-v2/system-cli/vitest.config.ts b/packages/new-v2/system-cli/vitest.config.ts new file mode 100644 index 00000000..94a64e2f --- /dev/null +++ b/packages/new-v2/system-cli/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({}) From 0a62525c7f30612493b928b0fd661c5b0c7c2bac Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 23:07:39 +0900 Subject: [PATCH 4/8] chore(workspace): register system v2 packages --- package.json | 3 +- pnpm-lock.yaml | 210 +++++++++++++++++++++++++++++++++----------- pnpm-workspace.yaml | 1 + 3 files changed, 161 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index 62c25212..84359160 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@commitlint/config-conventional": "^19.6.0", "@commitlint/cz-commitlint": "^19.5.0", "@turbo/gen": "^2.3.3", - "@typescript/native-preview": "^7.0.0-dev.20260326.1", + "@typescript/native-preview": "beta", "@vitest/coverage-v8": "catalog:", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", @@ -39,6 +39,7 @@ }, "workspaces": [ "packages/*", + "packages/new-v2/*", "app/*", "configs/*" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9823b7bc..512a1d5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: playwright: specifier: ^1.48.2 version: 1.59.1 + tsdown: + specifier: ^0.21.0 + version: 0.21.9 vite: specifier: ^6.4.1 version: 6.4.1 @@ -54,8 +57,8 @@ importers: specifier: ^2.3.3 version: 2.9.6(@types/node@22.19.17) '@typescript/native-preview': - specifier: ^7.0.0-dev.20260326.1 - version: 7.0.0-dev.20260420.1 + specifier: beta + version: 7.0.0-dev.20260421.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -97,7 +100,7 @@ importers: version: 6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) app/docs: dependencies: @@ -313,6 +316,84 @@ importers: specifier: ^4.19.2 version: 4.21.0 + packages/new-v2/system-cli: + dependencies: + '@jongh/new-v2-system-compiler': + specifier: workspace:* + version: link:../system-compiler + '@jongh/new-v2-system-core': + specifier: workspace:* + version: link:../system-core + commander: + specifier: ^13.1.0 + version: 13.1.0 + jiti: + specifier: ^2.6.1 + version: 2.6.1 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@jongh/eslint': + specifier: workspace:* + version: link:../../../configs/eslint + '@jongh/tsconfig': + specifier: workspace:* + version: link:../../../configs/tsconfig + '@types/node': + specifier: ^22.8.6 + version: 22.19.17 + tsdown: + specifier: 'catalog:' + version: 0.21.9(@typescript/native-preview@7.0.0-dev.20260421.2)(synckit@0.11.12)(typescript@6.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + packages/new-v2/system-compiler: + dependencies: + '@jongh/new-v2-system-core': + specifier: workspace:* + version: link:../system-core + postcss: + specifier: ^8.5.10 + version: 8.5.10 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@jongh/eslint': + specifier: workspace:* + version: link:../../../configs/eslint + '@jongh/tsconfig': + specifier: workspace:* + version: link:../../../configs/tsconfig + '@types/node': + specifier: ^22.8.6 + version: 22.19.17 + tsdown: + specifier: 'catalog:' + version: 0.21.9(@typescript/native-preview@7.0.0-dev.20260421.2)(synckit@0.11.12)(typescript@6.0.2) + + packages/new-v2/system-core: + dependencies: + csstype: + specifier: ^3.1.3 + version: 3.2.3 + devDependencies: + '@jongh/eslint': + specifier: workspace:* + version: link:../../../configs/eslint + '@jongh/tsconfig': + specifier: workspace:* + version: link:../../../configs/tsconfig + '@types/node': + specifier: ^22.8.6 + version: 22.19.17 + tsdown: + specifier: 'catalog:' + version: 0.21.9(@typescript/native-preview@7.0.0-dev.20260421.2)(synckit@0.11.12)(typescript@6.0.2) + packages/panda-animation: devDependencies: '@jongh/eslint': @@ -329,7 +410,7 @@ importers: version: 2.2.0 tsdown: specifier: ^0.21.3 - version: 0.21.9(@typescript/native-preview@7.0.0-dev.20260420.1)(synckit@0.11.12)(typescript@6.0.2) + version: 0.21.9(@typescript/native-preview@7.0.0-dev.20260421.2)(synckit@0.11.12)(typescript@6.0.2) packages/script: devDependencies: @@ -3357,47 +3438,48 @@ packages: resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-WMVphWuSmPuDzdutP2OM7ZhvlgdrBQ6ufQia+cJz6jqLd2MjPaYlS+/ACEcTgf6+PsTXygUOUvcqv9j5s7QC8w==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-ChRf5ZpdM5CmplmtfCHGFd/UjYDkld9Z27h5HeC41NGxkbe7NeNQYjwPcbZynca2swISZvFr8Wwlo4nNZG/51A==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-AmRuZ5R1TTrBNMInIACxd3AGvXS8X0PCAheftIMSa/1YOsNbfvPCbzZTleSaIx0ETabPeKQArur0uTPwnKjJbg==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-8+Ey5WZ1jiJXFuN8oQxu3N6pTi6UvigNXTWmB/e+2q6cu6ytFNgiVic6xXtcxOqBpqYMKwXzTZkECjs3NtT6og==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-zk6y0AX1+SjuYXZfIRTkpRttp+gfpnz6ELFDWLuhtTJtnxgm8BRSKPup2rcm4xkfpRrVv9dhPGSjJYGNR9z9tg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-TopcWdHwUGaocYuoj0qEZmZa1SLFm9hY0jKrzi5Xa9Xt/gvUnSLJYRBVblibM+/s8S9ZaV94Gc+CUn9EeSsJ9A==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-9P6DDMOCncgXGbFfxRpZE0sWzjyymRON67T9J1u885NLp+AmIdns8e91nzD6ipB0EdZimyR4ZjdSZfqetAQSrQ==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260420.1': - resolution: {integrity: sha512-XYMYZ1OAvO1OWJMntCGW5yoJvJjdrsgmNJxXEPO3Za2kFmQ69TdHOAzg9adurMgAXNpaDWtf28bfEveMRnvpZA==} + '@typescript/native-preview@7.0.0-dev.20260421.2': + resolution: {integrity: sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==} hasBin: true '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3960,6 +4042,9 @@ packages: caniuse-lite@1.0.30001788: resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + caniuse-lite@1.0.30001790: + resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4459,6 +4544,9 @@ packages: electron-to-chromium@1.5.340: resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==} + electron-to-chromium@1.5.343: + resolution: {integrity: sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -6253,6 +6341,9 @@ packages: node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7859,7 +7950,6 @@ packages: '@vitest/ui': 4.1.4 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -11755,7 +11845,7 @@ snapshots: '@types/conventional-commits-parser@5.0.2': dependencies: - '@types/node': 22.19.17 + '@types/node': 20.19.39 '@types/debug@4.1.13': dependencies: @@ -11776,7 +11866,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 22.19.17 + '@types/node': 20.19.39 '@types/hast@3.0.4': dependencies: @@ -11795,14 +11885,14 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 22.19.17 + '@types/node': 20.19.39 '@types/junit-report-builder@3.0.2': {} '@types/liftoff@4.0.3': dependencies: '@types/fined': 1.1.5 - '@types/node': 22.19.17 + '@types/node': 20.19.39 '@types/mdast@4.0.4': dependencies: @@ -11849,13 +11939,13 @@ snapshots: '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 22.19.17 + '@types/node': 20.19.39 '@types/statuses@2.0.6': {} '@types/through@0.0.33': dependencies: - '@types/node': 22.19.17 + '@types/node': 20.19.39 '@types/unist@2.0.11': {} @@ -11958,36 +12048,36 @@ snapshots: '@typescript-eslint/types': 8.59.0 eslint-visitor-keys: 5.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260420.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260420.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260420.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260420.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260420.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260420.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260420.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2': optional: true - '@typescript/native-preview@7.0.0-dev.20260420.1': + '@typescript/native-preview@7.0.0-dev.20260421.2': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260420.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260420.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260420.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260420.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260420.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260420.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260420.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260421.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260421.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260421.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260421.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260421.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260421.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260421.2 '@ungap/structured-clone@1.3.0': {} @@ -12095,7 +12185,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/expect@3.0.5': dependencies: @@ -12250,7 +12340,7 @@ snapshots: '@vue/shared': 3.5.25 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.6 + postcss: 8.5.10 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.25': @@ -12562,9 +12652,9 @@ snapshots: browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001788 - electron-to-chromium: 1.5.340 - node-releases: 2.0.37 + caniuse-lite: 1.0.30001790 + electron-to-chromium: 1.5.343 + node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-from@1.1.2: {} @@ -12623,6 +12713,8 @@ snapshots: caniuse-lite@1.0.30001788: {} + caniuse-lite@1.0.30001790: {} + ccount@2.0.1: {} chai@5.3.3: @@ -13065,6 +13157,8 @@ snapshots: electron-to-chromium@1.5.340: {} + electron-to-chromium@1.5.343: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -15265,6 +15359,8 @@ snapshots: node-releases@2.0.37: {} + node-releases@2.0.38: {} + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 @@ -16094,7 +16190,7 @@ snapshots: rfdc@1.4.1: {} - rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260420.1)(rolldown@1.0.0-rc.16)(typescript@6.0.2): + rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260421.2)(rolldown@1.0.0-rc.16)(typescript@6.0.2): dependencies: '@babel/generator': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 @@ -16108,7 +16204,7 @@ snapshots: picomatch: 4.0.4 rolldown: 1.0.0-rc.16 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260420.1 + '@typescript/native-preview': 7.0.0-dev.20260421.2 typescript: 6.0.2 transitivePeerDependencies: - oxc-resolver @@ -16797,7 +16893,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.21.9(@typescript/native-preview@7.0.0-dev.20260420.1)(synckit@0.11.12)(typescript@6.0.2): + tsdown@0.21.9(@typescript/native-preview@7.0.0-dev.20260421.2)(synckit@0.11.12)(typescript@6.0.2): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -16808,7 +16904,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0-rc.16 - rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260420.1)(rolldown@1.0.0-rc.16)(typescript@6.0.2) + rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260421.2)(rolldown@1.0.0-rc.16)(typescript@6.0.2) semver: 7.7.4 tinyexec: 1.1.1 tinyglobby: 0.2.16 @@ -17252,7 +17348,7 @@ snapshots: - tsx - yaml - vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(msw@2.13.4(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -17278,7 +17374,17 @@ snapshots: '@types/node': 22.19.17 '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) transitivePeerDependencies: + - jiti + - less + - lightningcss - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml vue-template-compiler@2.7.16: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 542a9bfd..3d5a9d8b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "packages/*" + - "packages/new-v2/*" - "app/*" - "configs/*" From b26e093521ed359e78c49ce488a41ad43e3c5efb Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 23:08:08 +0900 Subject: [PATCH 5/8] chore(agents): add project skills --- .agents/skills/ai-sdk/SKILL.md | 78 +++ .../skills/ai-sdk/references/ai-gateway.md | 66 +++ .../skills/ai-sdk/references/common-errors.md | 443 ++++++++++++++++++ .agents/skills/ai-sdk/references/devtools.md | 52 ++ .../ai-sdk/references/type-safe-agents.md | 204 ++++++++ .../skills/gathering-task-context/SKILL.md | 170 +++++++ 6 files changed, 1013 insertions(+) create mode 100644 .agents/skills/ai-sdk/SKILL.md create mode 100644 .agents/skills/ai-sdk/references/ai-gateway.md create mode 100644 .agents/skills/ai-sdk/references/common-errors.md create mode 100644 .agents/skills/ai-sdk/references/devtools.md create mode 100644 .agents/skills/ai-sdk/references/type-safe-agents.md create mode 100644 .agents/skills/gathering-task-context/SKILL.md diff --git a/.agents/skills/ai-sdk/SKILL.md b/.agents/skills/ai-sdk/SKILL.md new file mode 100644 index 00000000..f4ac3465 --- /dev/null +++ b/.agents/skills/ai-sdk/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ai-sdk +description: 'Answer questions about the AI SDK and help build AI-powered features. Use when developers: (1) Ask about AI SDK functions like generateText, streamText, ToolLoopAgent, embed, or tools, (2) Want to build AI agents, chatbots, RAG systems, or text generation features, (3) Have questions about AI providers (OpenAI, Anthropic, Google, etc.), streaming, tool calling, structured output, or embeddings, (4) Use React hooks like useChat or useCompletion. Triggers on: "AI SDK", "Vercel AI SDK", "generateText", "streamText", "add AI to my app", "build an agent", "tool calling", "structured output", "useChat".' +--- + +## Prerequisites + +Before searching docs, check if `node_modules/ai/docs/` exists. If not, install **only** the `ai` package using the project's package manager (e.g., `pnpm add ai`). + +Do not install other packages at this stage. Provider packages (e.g., `@ai-sdk/openai`) and client packages (e.g., `@ai-sdk/react`) should be installed later when needed based on user requirements. + +## Critical: Do Not Trust Internal Knowledge + +Everything you know about the AI SDK is outdated or wrong. Your training data contains obsolete APIs, deprecated patterns, and incorrect usage. + +**When working with the AI SDK:** + +1. Ensure `ai` package is installed (see Prerequisites) +2. Search `node_modules/ai/docs/` and `node_modules/ai/src/` for current APIs +3. If not found locally, search ai-sdk.dev documentation (instructions below) +4. Never rely on memory - always verify against source code or docs +5. **`useChat` has changed significantly** - check [Common Errors](references/common-errors.md) before writing client code +6. When deciding which model and provider to use (e.g. OpenAI, Anthropic, Gemini), use the Vercel AI Gateway provider unless the user specifies otherwise. See [AI Gateway Reference](references/ai-gateway.md) for usage details. +7. **Always fetch current model IDs** - Never use model IDs from memory. Before writing code that uses a model, run `curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("provider/")) | .id] | reverse | .[]'` (replacing `provider` with the relevant provider like `anthropic`, `openai`, or `google`) to get the full list with newest models first. Use the model with the highest version number (e.g., `claude-sonnet-4-5` over `claude-sonnet-4` over `claude-3-5-sonnet`). +8. Run typecheck after changes to ensure code is correct +9. **Be minimal** - Only specify options that differ from defaults. When unsure of defaults, check docs or source rather than guessing or over-specifying. + +If you cannot find documentation to support your answer, state that explicitly. + +## Finding Documentation + +### ai@6.0.34+ + +Search bundled docs and source in `node_modules/ai/`: + +- **Docs**: `grep "query" node_modules/ai/docs/` +- **Source**: `grep "query" node_modules/ai/src/` + +Provider packages include docs at `node_modules/@ai-sdk//docs/`. + +### Earlier versions + +1. Search: `https://ai-sdk.dev/api/search-docs?q=your_query` +2. Fetch `.md` URLs from results (e.g., `https://ai-sdk.dev/docs/agents/building-agents.md`) + +## When Typecheck Fails + +**Before searching source code**, grep [Common Errors](references/common-errors.md) for the failing property or function name. Many type errors are caused by deprecated APIs documented there. + +If not found in common-errors.md: + +1. Search `node_modules/ai/src/` and `node_modules/ai/docs/` +2. Search ai-sdk.dev (for earlier versions or if not found locally) + +## Building and Consuming Agents + +### Creating Agents + +Always use the `ToolLoopAgent` pattern. Search `node_modules/ai/docs/` for current agent creation APIs. + +**File conventions**: See [type-safe-agents.md](references/type-safe-agents.md) for where to save agents and tools. + +**Type Safety**: When consuming agents with `useChat`, always use `InferAgentUIMessage` for type-safe tool results. See [reference](references/type-safe-agents.md). + +### Consuming Agents (Framework-Specific) + +Before implementing agent consumption: + +1. Check `package.json` to detect the project's framework/stack +2. Search documentation for the framework's quickstart guide +3. Follow the framework-specific patterns for streaming, API routes, and client integration + +## References + +- [Common Errors](references/common-errors.md) - Renamed parameters reference (parameters → inputSchema, etc.) +- [AI Gateway](references/ai-gateway.md) - Gateway setup and usage +- [Type-Safe Agents with useChat](references/type-safe-agents.md) - End-to-end type safety with InferAgentUIMessage +- [DevTools](references/devtools.md) - Set up local debugging and observability (development only) diff --git a/.agents/skills/ai-sdk/references/ai-gateway.md b/.agents/skills/ai-sdk/references/ai-gateway.md new file mode 100644 index 00000000..53d54e66 --- /dev/null +++ b/.agents/skills/ai-sdk/references/ai-gateway.md @@ -0,0 +1,66 @@ +--- +title: Vercel AI Gateway +description: Reference for using Vercel AI Gateway with the AI SDK. +--- + +# Vercel AI Gateway + +The Vercel AI Gateway is the fastest way to get started with the AI SDK. It provides access to models from OpenAI, Anthropic, Google, and other providers through a single API. + +## Authentication + +Authenticate with OIDC (for Vercel deployments) or an [AI Gateway API key](https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi-keys&title=AI+Gateway+API+Keys): + +```env filename=".env.local" +AI_GATEWAY_API_KEY=your_api_key_here +``` + +## Usage + +The AI Gateway is the default global provider, so you can access models using a simple string: + +```ts +import { generateText } from "ai" + +const { text } = await generateText({ + model: "anthropic/claude-sonnet-4.5", + prompt: "What is love?", +}) +``` + +You can also explicitly import and use the gateway provider: + +```ts +// Option 1: Import from 'ai' package (included by default) +import { gateway } from "ai" +model: gateway("anthropic/claude-sonnet-4.5") + +// Option 2: Install and import from '@ai-sdk/gateway' package +import { gateway } from "@ai-sdk/gateway" +model: gateway("anthropic/claude-sonnet-4.5") +``` + +## Find Available Models + +**Important**: Always fetch the current model list before writing code. Never use model IDs from memory - they may be outdated. + +List all available models through the gateway API: + +```bash +curl https://ai-gateway.vercel.sh/v1/models +``` + +Filter by provider using `jq`. **Do not truncate with `head`** - always fetch the full list to find the latest models: + +```bash +# Anthropic models +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("anthropic/")) | .id] | reverse | .[]' + +# OpenAI models +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("openai/")) | .id] | reverse | .[]' + +# Google models +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("google/")) | .id] | reverse | .[]' +``` + +When multiple versions of a model exist, use the one with the highest version number (e.g., prefer `claude-sonnet-4-5` over `claude-sonnet-4` over `claude-3-5-sonnet`). diff --git a/.agents/skills/ai-sdk/references/common-errors.md b/.agents/skills/ai-sdk/references/common-errors.md new file mode 100644 index 00000000..6a18b3b9 --- /dev/null +++ b/.agents/skills/ai-sdk/references/common-errors.md @@ -0,0 +1,443 @@ +--- +title: Common Errors +description: Reference for common AI SDK errors and how to resolve them. +--- + +# Common Errors + +## `maxTokens` → `maxOutputTokens` + +```typescript +// ❌ Incorrect +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + maxTokens: 512, // deprecated: use `maxOutputTokens` instead + prompt: "Write a short story", +}) + +// ✅ Correct +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + maxOutputTokens: 512, + prompt: "Write a short story", +}) +``` + +## `maxSteps` → `stopWhen: isStepCount(n)` + +```typescript +// ❌ Incorrect +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + tools: { weather }, + maxSteps: 5, // deprecated: use `stopWhen: isStepCount(n)` instead + prompt: "What is the weather in NYC?", +}) + +// ✅ Correct +import { generateText, isStepCount } from "ai" + +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + tools: { weather }, + stopWhen: isStepCount(5), + prompt: "What is the weather in NYC?", +}) +``` + +## `parameters` → `inputSchema` (in tool definition) + +```typescript +// ❌ Incorrect +const weatherTool = tool({ + description: "Get weather for a location", + parameters: z.object({ + // deprecated: use `inputSchema` instead + location: z.string(), + }), + execute: async ({ location }) => ({ location, temp: 72 }), +}) + +// ✅ Correct +const weatherTool = tool({ + description: "Get weather for a location", + inputSchema: z.object({ + location: z.string(), + }), + execute: async ({ location }) => ({ location, temp: 72 }), +}) +``` + +## `generateObject` → `generateText` with `output` + +`generateObject` is deprecated. Use `generateText` with the `output` option instead. + +```typescript +// ❌ Deprecated +import { generateObject } from "ai" // deprecated: use `generateText` with `output` instead + +const result = await generateObject({ + // deprecated function + model: "anthropic/claude-opus-4.5", + schema: z.object({ + // deprecated: use `Output.object({ schema })` instead + recipe: z.object({ + name: z.string(), + ingredients: z.array(z.string()), + }), + }), + prompt: "Generate a recipe for chocolate cake", +}) + +// ✅ Correct +import { generateText, Output } from "ai" + +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + output: Output.object({ + schema: z.object({ + recipe: z.object({ + name: z.string(), + ingredients: z.array(z.string()), + }), + }), + }), + prompt: "Generate a recipe for chocolate cake", +}) + +console.log(result.output) // typed object +``` + +## Manual JSON parsing → `generateText` with `output` + +```typescript +// ❌ Incorrect +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + prompt: `Extract the user info as JSON: { "name": string, "age": number } + + Input: John is 25 years old`, +}) +const parsed = JSON.parse(result.text) + +// ✅ Correct +import { generateText, Output } from "ai" + +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + output: Output.object({ + schema: z.object({ + name: z.string(), + age: z.number(), + }), + }), + prompt: "Extract the user info: John is 25 years old", +}) + +console.log(result.output) // { name: 'John', age: 25 } +``` + +## Other `output` options + +```typescript +// Output.array - for generating arrays of items +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + output: Output.array({ + element: z.object({ + city: z.string(), + country: z.string(), + }), + }), + prompt: "List 5 capital cities", +}) + +// Output.choice - for selecting from predefined options +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + output: Output.choice({ + options: ["positive", "negative", "neutral"] as const, + }), + prompt: "Classify the sentiment: I love this product!", +}) + +// Output.json - for untyped JSON output +const result = await generateText({ + model: "anthropic/claude-opus-4.5", + output: Output.json(), + prompt: "Return some JSON data", +}) +``` + +## `toDataStreamResponse` → `toUIMessageStreamResponse` + +When using `useChat` on the frontend, use `toUIMessageStreamResponse()` instead of `toDataStreamResponse()`. The UI message stream format is designed to work with the chat UI components and handles message state correctly. + +```typescript +// ❌ Incorrect (when using useChat) +const result = streamText({ + // config +}) + +return result.toDataStreamResponse() // deprecated for useChat: use toUIMessageStreamResponse + +// ✅ Correct +const result = streamText({ + // config +}) + +return result.toUIMessageStreamResponse() +``` + +## Removed managed input state in `useChat` + +The `useChat` hook no longer manages input state internally. You must now manage input state manually. + +```tsx +// ❌ Deprecated +import { useChat } from "@ai-sdk/react" + +export default function Page() { + const { + input, // deprecated: manage input state manually with useState + handleInputChange, // deprecated: use custom onChange handler + handleSubmit, // deprecated: use sendMessage() instead + } = useChat({ + api: "/api/chat", // deprecated: use `transport: new DefaultChatTransport({ api })` instead + }) + + return ( +
+ + +
+ ) +} + +// ✅ Correct +import { useChat } from "@ai-sdk/react" +import { DefaultChatTransport } from "ai" +import { useState } from "react" + +export default function Page() { + const [input, setInput] = useState("") + const { sendMessage } = useChat({ + transport: new DefaultChatTransport({ api: "/api/chat" }), + }) + + const handleSubmit = (e) => { + e.preventDefault() + sendMessage({ text: input }) + setInput("") + } + + return ( +
+ setInput(e.target.value)} /> + +
+ ) +} +``` + +## `tool-invocation` → `tool-{toolName}` (typed tool parts) + +When rendering messages with `useChat`, use the typed tool part names (`tool-{toolName}`) instead of the generic `tool-invocation` type. This provides better type safety and access to tool-specific input/output types. + +> For end-to-end type-safety, see [Type-Safe Agents](type-safe-agents.md). + +Typed tool parts also use different property names: + +- `part.args` → `part.input` +- `part.result` → `part.output` + +```tsx +// ❌ Incorrect - using generic tool-invocation +{ + message.parts.map((part, i) => { + switch (part.type) { + case "text": + return
{part.text}
+ case "tool-invocation": // deprecated: use typed tool parts instead + return ( +
+            {JSON.stringify(part.toolInvocation, null, 2)}
+          
+ ) + } + }) +} + +// ✅ Correct - using typed tool parts (recommended) +{ + message.parts.map((part) => { + switch (part.type) { + case "text": + return part.text + case "tool-askForConfirmation": + // handle askForConfirmation tool + break + case "tool-getWeatherInformation": + // handle getWeatherInformation tool + break + } + }) +} + +// ✅ Alternative - using isToolUIPart as a catch-all +import { isToolUIPart } from "ai" + +{ + message.parts.map((part) => { + if (part.type === "text") { + return part.text + } + if (isToolUIPart(part)) { + // handle any tool part generically + return ( +
+ {part.toolName}: {part.state} +
+ ) + } + }) +} +``` + +## `useChat` state-dependent property access + +Tool part properties are only available in certain states. TypeScript will error if you access them without checking state first. + +```tsx +// ❌ Incorrect - input may be undefined during streaming +// TS18048: 'part.input' is possibly 'undefined' +if (part.type === "tool-getWeather") { + const location = part.input.location +} + +// ✅ Correct - check for input-available or output-available +if ( + part.type === "tool-getWeather" && + (part.state === "input-available" || part.state === "output-available") +) { + const location = part.input.location +} + +// ❌ Incorrect - output is only available after execution +// TS18048: 'part.output' is possibly 'undefined' +if (part.type === "tool-getWeather") { + const weather = part.output +} + +// ✅ Correct - check for output-available +if (part.type === "tool-getWeather" && part.state === "output-available") { + const location = part.input.location + const weather = part.output +} +``` + +## `part.toolInvocation.args` → `part.input` + +```tsx +// ❌ Incorrect +if (part.type === "tool-invocation") { + // deprecated: use `part.input` on typed tool parts instead + const location = part.toolInvocation.args.location +} + +// ✅ Correct +if ( + part.type === "tool-getWeather" && + (part.state === "input-available" || part.state === "output-available") +) { + const location = part.input.location +} +``` + +## `part.toolInvocation.result` → `part.output` + +```tsx +// ❌ Incorrect +if (part.type === "tool-invocation") { + // deprecated: use `part.output` on typed tool parts instead + const weather = part.toolInvocation.result +} + +// ✅ Correct +if (part.type === "tool-getWeather" && part.state === "output-available") { + const weather = part.output +} +``` + +## `part.toolInvocation.toolCallId` → `part.toolCallId` + +```tsx +// ❌ Incorrect +if (part.type === "tool-invocation") { + // deprecated: use `part.toolCallId` on typed tool parts instead + const id = part.toolInvocation.toolCallId +} + +// ✅ Correct +if (part.type === "tool-getWeather") { + const id = part.toolCallId +} +``` + +## Tool invocation states renamed + +```tsx +// ❌ Incorrect +switch (part.toolInvocation.state) { + case "partial-call": // deprecated: use `input-streaming` instead + return
Loading...
+ case "call": // deprecated: use `input-available` instead + return
Executing...
+ case "result": // deprecated: use `output-available` instead + return
Done
+} + +// ✅ Correct +switch (part.state) { + case "input-streaming": + return
Loading...
+ case "input-available": + return
Executing...
+ case "output-available": + return
Done
+} +``` + +## `addToolResult` → `addToolOutput` + +```tsx +// ❌ Incorrect +addToolResult({ + // deprecated: use `addToolOutput` instead + toolCallId: part.toolInvocation.toolCallId, + result: "Yes, confirmed.", // deprecated: use `output` instead +}) + +// ✅ Correct +addToolOutput({ + tool: "askForConfirmation", + toolCallId: part.toolCallId, + output: "Yes, confirmed.", +}) +``` + +## `messages` → `uiMessages` in `createAgentUIStreamResponse` + +```typescript +// ❌ Incorrect +return createAgentUIStreamResponse({ + agent: myAgent, + messages, // incorrect: use `uiMessages` instead +}) + +// ✅ Correct +return createAgentUIStreamResponse({ + agent: myAgent, + uiMessages: messages, +}) +``` diff --git a/.agents/skills/ai-sdk/references/devtools.md b/.agents/skills/ai-sdk/references/devtools.md new file mode 100644 index 00000000..cbcff76e --- /dev/null +++ b/.agents/skills/ai-sdk/references/devtools.md @@ -0,0 +1,52 @@ +--- +title: AI SDK DevTools +description: Debug AI SDK calls by inspecting captured runs and steps. +--- + +# AI SDK DevTools + +## Why Use DevTools + +DevTools captures all AI SDK calls (`generateText`, `streamText`, `ToolLoopAgent`) to a local JSON file. This lets you inspect LLM requests, responses, tool calls, and multi-step interactions without manually logging. + +## Setup + +Requires AI SDK 6. Install `@ai-sdk/devtools` using your project's package manager. + +Wrap your model with the middleware: + +```ts +import { wrapLanguageModel, gateway } from "ai" +import { devToolsMiddleware } from "@ai-sdk/devtools" + +const model = wrapLanguageModel({ + model: gateway("anthropic/claude-sonnet-4.5"), + middleware: devToolsMiddleware(), +}) +``` + +## Viewing Captured Data + +All runs and steps are saved to: + +``` +.devtools/generations.json +``` + +Read this file directly to inspect captured data: + +```bash +cat .devtools/generations.json | jq +``` + +Or launch the web UI: + +```bash +npx @ai-sdk/devtools +# Open http://localhost:4983 +``` + +## Data Structure + +- **Run**: A complete multi-step interaction grouped by initial prompt +- **Step**: A single LLM call within a run (includes input, output, tool calls, token usage) diff --git a/.agents/skills/ai-sdk/references/type-safe-agents.md b/.agents/skills/ai-sdk/references/type-safe-agents.md new file mode 100644 index 00000000..454a41b7 --- /dev/null +++ b/.agents/skills/ai-sdk/references/type-safe-agents.md @@ -0,0 +1,204 @@ +--- +title: Type-Safe useChat with Agents +description: Build end-to-end type-safe agents by inferring UIMessage types from your agent definition. +--- + +# Type-Safe useChat with Agents + +Build end-to-end type-safe agents by inferring `UIMessage` types from your agent definition for type-safe UI rendering with `useChat`. + +## Recommended Structure + +``` +lib/ + agents/ + my-agent.ts # Agent definition + type export + tools/ + weather-tool.ts # Individual tool definitions + calculator-tool.ts +``` + +## Define Tools + +```ts +// lib/tools/weather-tool.ts +import { tool } from "ai" +import { z } from "zod" + +export const weatherTool = tool({ + description: "Get current weather for a location", + inputSchema: z.object({ + location: z.string().describe("City name"), + }), + execute: async ({ location }) => { + return { temperature: 72, condition: "sunny", location } + }, +}) +``` + +## Define Agent and Export Type + +```ts +// lib/agents/my-agent.ts +import { ToolLoopAgent, InferAgentUIMessage } from "ai" +import { weatherTool } from "../tools/weather-tool" +import { calculatorTool } from "../tools/calculator-tool" + +export const myAgent = new ToolLoopAgent({ + model: "anthropic/claude-sonnet-4", + instructions: "You are a helpful assistant.", + tools: { + weather: weatherTool, + calculator: calculatorTool, + }, +}) + +// Infer the UIMessage type from the agent +export type MyAgentUIMessage = InferAgentUIMessage +``` + +### With Custom Metadata + +```ts +// lib/agents/my-agent.ts +import { z } from "zod" + +const metadataSchema = z.object({ + createdAt: z.number(), + model: z.string().optional(), +}) + +type MyMetadata = z.infer + +export type MyAgentUIMessage = InferAgentUIMessage +``` + +## Use with `useChat` + +```tsx +// app/chat.tsx +import { useChat } from "@ai-sdk/react" +import type { MyAgentUIMessage } from "@/lib/agents/my-agent" + +export function Chat() { + const { messages } = useChat() + + return ( +
+ {messages.map((message) => ( + + ))} +
+ ) +} +``` + +## Rendering Parts with Type Safety + +Tool parts are typed as `tool-{toolName}` based on your agent's tools: + +```tsx +function Message({ message }: { message: MyAgentUIMessage }) { + return ( +
+ {message.parts.map((part, i) => { + switch (part.type) { + case "text": + return

{part.text}

+ + case "tool-weather": + // part.input and part.output are fully typed + if (part.state === "output-available") { + return ( +
+ Weather in {part.input.location}: {part.output.temperature}F +
+ ) + } + return
Loading weather...
+ + case "tool-calculator": + // TypeScript knows this is the calculator tool + return
Calculating...
+ + default: + return null + } + })} +
+ ) +} +``` + +The `part.type` discriminant narrows the type, giving you autocomplete and type checking for `input` and `output` based on each tool's schema. + +## Splitting Tool Rendering into Components + +When rendering many tools, you may want to split each tool into its own component. Use `UIToolInvocation` to derive a typed invocation from your tool and export it alongside the tool definition: + +```ts +// lib/tools/weather-tool.ts +import { tool, UIToolInvocation } from "ai" +import { z } from "zod" + +export const weatherTool = tool({ + description: "Get current weather for a location", + inputSchema: z.object({ + location: z.string().describe("City name"), + }), + execute: async ({ location }) => { + return { temperature: 72, condition: "sunny", location } + }, +}) + +// Export the invocation type for use in UI components +export type WeatherToolInvocation = UIToolInvocation +``` + +Then import only the type in your component: + +```tsx +// components/weather-tool.tsx +import type { WeatherToolInvocation } from "@/lib/tools/weather-tool" + +export function WeatherToolComponent({ + invocation, +}: { + invocation: WeatherToolInvocation +}) { + // invocation.input and invocation.output are fully typed + if (invocation.state === "output-available") { + return ( +
+ Weather in {invocation.input.location}: {invocation.output.temperature}F +
+ ) + } + return
Loading weather for {invocation.input?.location}...
+} +``` + +Use the component in your message renderer: + +```tsx +function Message({ message }: { message: MyAgentUIMessage }) { + return ( +
+ {message.parts.map((part, i) => { + switch (part.type) { + case "text": + return

{part.text}

+ case "tool-weather": + return + case "tool-calculator": + return + default: + return null + } + })} +
+ ) +} +``` + +This approach keeps your tool rendering logic organized while maintaining full type safety, without needing to import the tool implementation into your UI components. diff --git a/.agents/skills/gathering-task-context/SKILL.md b/.agents/skills/gathering-task-context/SKILL.md new file mode 100644 index 00000000..88b20112 --- /dev/null +++ b/.agents/skills/gathering-task-context/SKILL.md @@ -0,0 +1,170 @@ +--- +name: gathering-task-context +description: Gathers complete development context before any implementation begins. Use when starting a new task — whether from a GitHub issue, Linear ticket, or rough idea. Explores external sources and codebase autonomously, identifies gaps by task type, and fills them through targeted questions. Produces a context document. Does NOT write code or make technical decisions. +--- + +# Gathering Task Context + +Understand a development task completely before any implementation begins. + +## Hard Gate + +**NEVER write code. NEVER make technical decisions. NEVER suggest implementation approaches.** + +This skill only gathers and structures understanding. All decisions happen in the next step. + +## Quick Start + +User provides one of: + +- GitHub issue URL → fetch and explore +- Linear ticket URL → fetch and explore +- Rough idea in natural language → clarify and structure + +Output: a context document the user approves before moving on. + +## Flow + +### Step 1: Detect Input Type + +Examine what the user gave you: + +- **URL (github.com/_/issues/_)** → GitHub mode +- **URL (linear.app/\*)** → Linear mode +- **Free text / rough idea** → Idea mode + +If unclear, ask: "GitHub issue나 Linear 티켓 링크가 있어, 아니면 아이디어부터 얘기할까?" + +### Step 2: Autonomous Exploration + +**Before asking the user anything**, explore what's available. + +**GitHub mode:** + +- Fetch the issue body, labels, assignees +- Follow any linked issues or PRs in the body +- Fetch parent issue if referenced +- Check linked PR diffs if present +- Search the codebase for mentioned file paths or function names + +**Linear mode:** + +- Fetch ticket title, description, status, labels +- Follow linked issues or sub-issues +- Search the codebase for mentioned areas + +**Idea mode:** + +- Search the codebase for areas related to the idea +- Check recent commits in relevant files +- Look for existing patterns or similar implementations + +Explore up to 2 levels deep. Stop when you have enough to assess task type and gaps. + +### Step 3: Detect Task Type + +Based on gathered context, classify: + +``` +switch(taskType) { + + case "bug-fix": + // Something is broken, needs to be fixed + gaps = [재현경로, 에러내용, 기대동작, 영향범위] + + case "feature": + // New capability being added + gaps = [왜필요한가, 누가쓰는가, 범위, 완료기준] + + case "refactor": + // Existing code being restructured + gaps = [무엇이문제인가, 어디까지건드리나, 바꾸면안되는것] + + case "unknown": + // Ask the user to clarify task type first + → "이게 버그 픽스야, 새 기능 추가야, 아니면 리팩토링이야?" +} +``` + +If the task type is ambiguous after exploration, ask before proceeding. + +### Step 4: Fill Gaps Iteratively + +Check each gap item. For each: + +- If answerable from explored context → fill it, no question needed +- If unanswerable → ask the user + +**Rules:** + +- One question per message, never multiple at once +- Ask the most important gap first +- After each answer, re-check remaining gaps before asking the next +- If a gap can be answered by codebase exploration, do that instead of asking + +### Step 5: Confirm and Write Context Document + +When all gaps are filled, present a summary: + +> "이 정도면 작업 시작할 수 있을 것 같아. 확인해줘." + +After user confirms, write the context document. + +## Context Document Format + +Save to `docs/context/YYYY-MM-DD-.md`: + +```markdown +# [Task Title] + +## 타입 + +Bug Fix / Feature / Refactor + +## 목표 + +무엇을, 왜 해야 하는가 + +## 현재 상태 (AS-IS) + +관련 코드 위치, 현재 동작 방식 + +## 완료 기준 + +이게 되면 끝 (구체적으로) + +## 제약 / 참고 + +- 관련 이슈 링크 +- 기술적 제약 +- 건드리면 안 되는 것 +``` + +## Gap Checklist by Task Type + +### Bug Fix + +- [ ] 재현 경로: 어떻게 하면 발생하는가? +- [ ] 에러 내용: 정확히 무슨 일이 벌어지는가? +- [ ] 기대 동작: 원래 어떻게 되어야 하는가? +- [ ] 영향 범위: 어디에, 누구에게 영향을 미치는가? + +### Feature + +- [ ] Why: 왜 이 기능이 필요한가? +- [ ] Who: 누가 사용하는가? +- [ ] Scope: 어디까지인가? 무엇이 포함되지 않는가? +- [ ] Done: 완료 기준이 무엇인가? + +### Refactor + +- [ ] Problem: 현재 무엇이 문제인가? +- [ ] Scope: 어디까지 변경하는가? +- [ ] Constraints: 무엇을 바꾸면 안 되는가? (인터페이스, 동작 등) + +## Key Principles + +- **탐색 먼저, 질문 나중** — 이미 알 수 있는 건 직접 파악하고 물어보지 않는다 +- **질문은 하나씩** — 여러 질문을 한꺼번에 쏟아붓지 않는다 +- **결정 없음** — 어떻게 구현할지는 일절 언급하지 않는다 +- **코드 없음** — 코드 스니펫, 접근법 제안, 아키텍처 의견 없음 From c1619da4d65915760b193658c6e656eec0d422f7 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 23:11:16 +0900 Subject: [PATCH 6/8] fix(ui): include vite env types --- packages/ui/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 2274baa3..61aede36 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -9,5 +9,6 @@ "react": ["./node_modules/@types/react"] } }, + "include": ["src", "vite-env.d.ts"], "exclude": ["node_modules", "dist"] } From 202ed33d467916a2fa540fb2924a1d7bb11cc038 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 23:11:33 +0900 Subject: [PATCH 7/8] chore(tokens): remove legacy tokens file --- tokens.json | 348 ---------------------------------------------------- 1 file changed, 348 deletions(-) delete mode 100644 tokens.json diff --git a/tokens.json b/tokens.json deleted file mode 100644 index 9ad9bf62..00000000 --- a/tokens.json +++ /dev/null @@ -1,348 +0,0 @@ -{ - "core": { - "borderRadius": { - "sm": { - "value": "3px", - "type": "borderRadius" - }, - "lg": { - "value": "5px", - "type": "borderRadius" - }, - "rounded": { - "value": "30px", - "type": "borderRadius", - "description": "둥근 모양일때 사용" - } - }, - "fontSize": { - "xs": { - "value": "0.75rem", - "type": "fontSizes" - }, - "sm": { - "value": "1rem", - "type": "fontSizes" - }, - "lg": { - "value": "1.5rem", - "type": "fontSizes" - }, - "xl": { - "value": "1.75rem", - "type": "fontSizes" - }, - "2xl": { - "value": "2rem", - "type": "fontSizes" - }, - "3xl": { - "value": "3rem", - "type": "fontSizes" - } - }, - "typography": { - "header": { - "value": { - "fontSize": "4rem", - "lineHeight": "36px", - "fontWeight": "{fontWeight.bold}", - "letterSpacing": "1px" - }, - "type": "typography" - }, - "subHeader": { - "value": { - "fontSize": "3rem", - "lineHeight": "33px", - "fontWeight": "{fontWeight.bold}", - "letterSpacing": "1px" - }, - "type": "typography" - }, - "body": { - "value": { - "fontSize": "1.6rem", - "lineHeight": "24px", - "fontWeight": "{fontWeight.regular}" - }, - "type": "typography" - }, - "caption": { - "value": { - "fontSize": "1.2rem", - "lineHeight": "18px", - "fontWeight": "{fontWeight.regular}" - }, - "type": "typography" - } - }, - "fontWeight": { - "regular": { - "value": "400", - "type": "fontWeights" - }, - "bold": { - "value": "700", - "type": "fontWeights" - } - } - }, - "light": { - "background": { - "value": "{grey_100}", - "type": "color" - }, - "muted": { - "value": "{blue_300}", - "type": "color" - }, - "secondary": { - "value": "{grey_300}", - "type": "color" - }, - "foreground": { - "value": "{grey_600}", - "type": "color" - }, - "destructive": { - "value": "{red_500}", - "type": "color" - }, - "primary": { - "value": "{blue_500}", - "type": "color" - }, - "grey_100": { - "value": "#f9f9fb", - "type": "color" - }, - "grey_200": { - "value": "#e7e8ec", - "type": "color" - }, - "grey_300": { - "value": "#d8d9e0", - "type": "color" - }, - "grey_400": { - "value": "#b9bbc6", - "type": "color" - }, - "grey_500": { - "value": "#80828d", - "type": "color" - }, - "grey_600": { - "value": "#1e1f24", - "type": "color" - }, - "white": { - "value": "#FFFFFF", - "type": "color" - }, - "blue_100": { - "value": "#0055ff09", - "type": "color" - }, - "blue_200": { - "value": "#006eff23", - "type": "color" - }, - "blue_300": { - "value": "#0064ff48", - "type": "color" - }, - "blue_400": { - "value": "#0057ee84", - "type": "color" - }, - "blue_500": { - "value": "#004bd1e4", - "type": "color" - }, - "blue_600": { - "value": "#001e58ec", - "type": "color" - }, - "red_100": { - "value": "#fef8f7", - "type": "color" - }, - "red_200": { - "value": "#ffdcd5", - "type": "color" - }, - "red_300": { - "value": "#fbbeb3", - "type": "color" - }, - "red_400": { - "value": "#ea9082", - "type": "color" - }, - "red_500": { - "value": "#d64232", - "type": "color" - }, - "red_600": { - "value": "#5d251e", - "type": "color" - }, - "primary_foreground": { - "value": "{grey_100}", - "type": "color" - }, - "secondary_foreground": { - "value": "{grey_600}", - "type": "color" - }, - "destructive_foreground": { - "value": "{red_400}", - "type": "color" - }, - "muted_foreground": { - "value": "{grey_600}", - "type": "color" - }, - "border": { - "value": "{grey_300}", - "type": "color" - }, - "input": { - "value": "{grey_300}", - "type": "color" - } - }, - "dark": { - "background": { - "value": "{grey_100}", - "type": "color" - }, - "border": { - "value": "{grey_400}", - "type": "color" - }, - "muted_foreground": { - "value": "{grey_500}", - "type": "color" - }, - "secondary_foreground": { - "value": "{grey_500}", - "type": "color" - }, - "destructive": { - "value": "{red_500}", - "type": "color" - }, - "grey_100": { - "value": "#19191b", - "type": "color" - }, - "grey_200": { - "value": "#292a2e", - "type": "color" - }, - "grey_300": { - "value": "#393a40", - "type": "color" - }, - "grey_400": { - "value": "#5f606a", - "type": "color" - }, - "grey_500": { - "value": "#797b86", - "type": "color" - }, - "grey_600": { - "value": "#eeeef0", - "type": "color" - }, - "red_100": { - "value": "#1e1413", - "type": "color" - }, - "red_200": { - "value": "#4d160f", - "type": "color" - }, - "red_300": { - "value": "#6d2a21", - "type": "color" - }, - "red_400": { - "value": "#ac4d40", - "type": "color" - }, - "red_500": { - "value": "#d54333", - "type": "color" - }, - "blue_100": { - "value": "#0f1826", - "type": "color" - }, - "blue_200": { - "value": "#122f65", - "type": "color" - }, - "blue_300": { - "value": "#214588", - "type": "color" - }, - "blue_400": { - "value": "#3060b7", - "type": "color" - }, - "blue_500": { - "value": "#3364bb", - "type": "color" - }, - "blue_600": { - "value": "#d1e3ff", - "type": "color" - }, - "white": { - "value": "#FFFFFF", - "type": "color" - }, - "red_600": { - "value": "#fbd3cc", - "type": "color" - }, - "primary": { - "value": "{blue_500}", - "type": "color" - }, - "muted": { - "value": "{blue_600}", - "type": "color" - }, - "foreground": { - "value": "{grey_600}", - "type": "color" - }, - "secondary": { - "value": "{grey_500}", - "type": "color" - }, - "destructive_foreground": { - "value": "{grey_600}", - "type": "color" - }, - "primary_foreground": { - "value": "{grey_600}", - "type": "color" - }, - "input": { - "value": "{grey_400}", - "type": "color" - } - }, - "theme": {}, - "$themes": [], - "$metadata": { - "tokenSetOrder": ["core", "light", "dark", "theme"] - } -} From 6eae462f4ec062f647d18e42879879c12cdea9ba Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 7 May 2026 23:41:27 +0900 Subject: [PATCH 8/8] fix(system-v2): resolve workspace package entries --- packages/new-v2/system-cli/package.json | 25 ++++++++++++++------ packages/new-v2/system-compiler/package.json | 19 +++++++++++---- packages/new-v2/system-core/package.json | 19 +++++++++++---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/new-v2/system-cli/package.json b/packages/new-v2/system-cli/package.json index 8445ac19..3229085f 100644 --- a/packages/new-v2/system-cli/package.json +++ b/packages/new-v2/system-cli/package.json @@ -4,11 +4,8 @@ "private": true, "type": "module", "description": "디자인 시스템 설정을 CSS 파일로 생성하는 CLI입니다.", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "jds-system": "./dist/index.js" - }, + "main": "./src/index.ts", + "types": "./src/index.ts", "files": [ "dist", "package.json" @@ -24,11 +21,25 @@ }, "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./src/index.ts", + "default": "./src/index.ts" }, "./package.json": "./package.json" }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "jds-system": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } + }, "dependencies": { "@jongh/new-v2-system-compiler": "workspace:*", "@jongh/new-v2-system-core": "workspace:*", diff --git a/packages/new-v2/system-compiler/package.json b/packages/new-v2/system-compiler/package.json index d3b0faf9..ecfea438 100644 --- a/packages/new-v2/system-compiler/package.json +++ b/packages/new-v2/system-compiler/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "description": "디자인 시스템 토큰/레시피를 CSS로 컴파일합니다.", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", "files": [ "dist", "package.json" @@ -21,11 +21,22 @@ }, "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./src/index.ts", + "default": "./src/index.ts" }, "./package.json": "./package.json" }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } + }, "devDependencies": { "@jongh/eslint": "workspace:*", "@jongh/tsconfig": "workspace:*", diff --git a/packages/new-v2/system-core/package.json b/packages/new-v2/system-core/package.json index d38909c5..b74b9071 100644 --- a/packages/new-v2/system-core/package.json +++ b/packages/new-v2/system-core/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "description": "type-safe하게 토큰을 선언할수있는 API를 제공합니다.", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", "files": [ "dist", "package.json" @@ -21,11 +21,22 @@ }, "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./src/index.ts", + "default": "./src/index.ts" }, "./package.json": "./package.json" }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } + }, "devDependencies": { "@types/node": "^22.8.6", "@jongh/eslint": "workspace:*",