sh-ui-cli 0.14.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +354 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/registry.json +835 -0
  152. package/data/summaries/flutter.json +42 -0
  153. package/data/summaries/react.json +50 -0
  154. package/data/tokens/build.mjs +553 -0
  155. package/data/tokens/src/primitives.json +146 -0
  156. package/data/tokens/src/semantic.json +146 -0
  157. package/package.json +13 -4
  158. package/src/add.mjs +13 -12
  159. package/src/list.mjs +3 -11
  160. package/src/mcp.mjs +308 -0
  161. package/src/paths.mjs +52 -0
  162. package/src/remove.mjs +4 -11
@@ -0,0 +1,42 @@
1
+ {
2
+ "$description": "Flutter 위젯 summary — llms.txt 생성용. key는 registry.json의 name과 동일.",
3
+ "summaries": {
4
+ "tokens": "ShUiTheme (ThemeExtension) — 색상/간격/타이포/반경/지속시간 토큰. Theme.of(ctx).extension<ShUiTheme>()로 접근.",
5
+ "button": "ShUiButton — variant(primary/secondary/ghost/danger/link) + size(sm/md/lg).",
6
+ "card": "ShUiCard — header/body/footer named slot.",
7
+ "input": "ShUiInput — TextField 래퍼 + sh-ui 토큰 스타일.",
8
+ "textarea": "ShUiTextarea — 다중 행 입력.",
9
+ "label": "ShUiLabel — 폼 레이블.",
10
+ "checkbox": "ShUiCheckbox — tristate 지원.",
11
+ "radio": "ShUiRadio / ShUiRadioGroup — 단일 선택.",
12
+ "switch": "ShUiSwitch — 토글 스위치.",
13
+ "toggle": "ShUiToggle — 프레스 상태 유지 버튼.",
14
+ "slider": "ShUiSlider — 단일 값, step.",
15
+ "select": "ShUiSelect — 네이티브 대체 드롭다운 셀렉트.",
16
+ "combobox": "ShUiCombobox — 검색 가능 셀렉트.",
17
+ "date-picker": "ShUiDatePicker — 캘린더 팝업.",
18
+ "color-picker": "ShUiColorPicker — 색상 선택.",
19
+ "file-upload": "ShUiFileUpload — 파일 선택/드롭.",
20
+ "dialog": "ShUiDialog — header/body/footer named slot, showDialog로 호출.",
21
+ "popover": "ShUiPopover — 트리거 기준 오버레이.",
22
+ "tooltip": "ShUiTooltip — long-press / hover 힌트.",
23
+ "toast": "ShUiToast — ShUiToastOverlay로 스택 관리.",
24
+ "dropdown-menu": "ShUiDropdownMenu — 트리거 기준 메뉴.",
25
+ "context-menu": "ShUiContextMenu — 길게 누르기/우클릭 메뉴.",
26
+ "menubar": "ShUiMenubar — 수평 메뉴바.",
27
+ "tabs": "ShUiTabs / ShUiTabsController — 탭 내비.",
28
+ "accordion": "ShUiAccordion — 펼침/접힘. single/multiple.",
29
+ "carousel": "ShUiCarousel — PageView 기반, autoplay/dots.",
30
+ "sidebar": "ShUiSidebar — 접이식 사이드바.",
31
+ "app-shell": "ShUiAppShell — 사이드바 + 상단 바 + 본문 레이아웃.",
32
+ "header": "ShUiHeader — 로고/네비/액션 영역.",
33
+ "breadcrumb": "ShUiBreadcrumb(items: [...]) — items 기반 경로 내비.",
34
+ "pagination": "ShUiPagination(page:, pageCount:, siblingCount:, onPageChanged:) — 페이지 범위 계산 내장.",
35
+ "avatar": "ShUiAvatar — 이미지/initials fallback.",
36
+ "badge": "ShUiBadge — 상태 뱃지.",
37
+ "progress": "ShUiProgress — 선형 진행률, indeterminate.",
38
+ "spinner": "ShUiSpinner — 원형 로딩.",
39
+ "separator": "ShUiSeparator — 구분선 (horizontal/vertical).",
40
+ "skeleton": "ShUiSkeleton — 로딩 플레이스홀더."
41
+ }
42
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "$description": "React 컴포넌트 summary — llms.txt 생성용. key는 registry.json의 name과 동일.",
3
+ "summaries": {
4
+ "button": "기본 버튼 — variant(primary/secondary/ghost/danger/link) + size(sm/md/lg).",
5
+ "card": "카드 컨테이너 — compound (Card.Header / Card.Body / Card.Footer / Card.Title / Card.Description).",
6
+ "input": "단일 행 텍스트 입력 — hasError 지원.",
7
+ "textarea": "여러 행 텍스트 입력 — rows, autoResize.",
8
+ "label": "폼 레이블 — htmlFor로 입력과 연결.",
9
+ "checkbox": "체크박스 — indeterminate 지원 (Base UI).",
10
+ "radio": "라디오 그룹 — RadioGroup + RadioGroupItem (Base UI).",
11
+ "switch": "토글 스위치 — controlled/uncontrolled 모두 지원 (Base UI).",
12
+ "toggle": "단일 토글 / 토글 그룹 — pressed 상태 (Base UI).",
13
+ "slider": "수평 슬라이더 — 단일/범위 값, step.",
14
+ "select": "네이티브 대체 셀렉트 — compound (Select.Trigger / Select.Content / Select.Item) (Base UI).",
15
+ "combobox": "검색 가능 셀렉트 — 자동 필터링, 키보드 내비 (Base UI).",
16
+ "color-picker": "색상 선택 — hex/rgb, 프리셋 팔레트.",
17
+ "date-picker": "날짜 선택 — 캘린더 팝업 (Base UI).",
18
+ "file-upload": "파일 업로드 — 드롭존, 다중 파일, 진행률.",
19
+ "dialog": "모달 다이얼로그 — compound (Dialog.Trigger / Dialog.Content / Dialog.Title / Dialog.Description) + 포커스 트랩 (Base UI).",
20
+ "popover": "floating 팝오버 — 트리거 기준 위치 (Base UI).",
21
+ "tooltip": "짧은 힌트 — hover/focus 표시, delay/closeDelay (Base UI).",
22
+ "toast": "임시 알림 — useToast 훅 + ToastProvider. aria-live 자동.",
23
+ "dropdown-menu": "드롭다운 메뉴 — compound, sub-menu 지원 (Base UI).",
24
+ "context-menu": "우클릭 컨텍스트 메뉴 — DropdownMenu와 같은 구조 (Base UI).",
25
+ "menubar": "수평 메뉴바 — dropdown-menu 위에 구성 (Base UI).",
26
+ "tabs": "탭 — compound (Tabs.List / Tabs.Trigger / Tabs.Content) (Base UI).",
27
+ "accordion": "펼침/접힘 아코디언 — single/multiple (Base UI).",
28
+ "carousel": "슬라이드 캐러셀 — Embla 기반, autoplay/autoscroll.",
29
+ "sidebar": "앱 사이드바 — collapsible, SidebarMenu/SidebarGroup 조합.",
30
+ "header": "앱 헤더 — 로고/네비/액션 compound.",
31
+ "breadcrumb": "경로 내비게이션 — compound (Breadcrumb.List/Item/Link/Page/Separator/Ellipsis). aria-current 자동.",
32
+ "pagination": "페이지 단위 내비게이션 — compound (PaginationContent/Item/Link/Previous/Next/Ellipsis). getPaginationRange 유틸 동봉. aria-current 자동.",
33
+ "avatar": "프로필 아바타 — 이미지 fallback → initials (Base UI).",
34
+ "badge": "상태 뱃지 — variant, size.",
35
+ "progress": "선형 진행률 — value 0~100, indeterminate.",
36
+ "spinner": "로딩 스피너 — size.",
37
+ "separator": "구분선 — orientation(horizontal/vertical).",
38
+ "skeleton": "스켈레톤 로딩 플레이스홀더.",
39
+ "theme": "테마 프로바이더 + useTheme 훅 — light/dark/system.",
40
+ "code-panel": "Shiki 기반 코드 하이라이트 패널 — 복사 버튼 포함.",
41
+ "base": "CSS 리셋 — base.css.",
42
+ "breakpoints": "반응형 미디어 쿼리 토큰 — breakpoints.css.",
43
+ "focus-ring": "공용 포커스 링 스타일 — focus-ring.css.",
44
+ "z-index": "z-index 레이어 토큰 — z-index.css.",
45
+ "animations": "공용 애니메이션 키프레임 — animations.css.",
46
+ "cn": "className 머지 유틸 (tailwind-merge 미사용, 순수 concat).",
47
+ "use-media-query": "미디어 쿼리 매칭 훅.",
48
+ "use-active-section": "스크롤 위치로 현재 섹션 감지 훅."
49
+ }
50
+ }
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @sh-ui/tokens 빌드 스크립트
4
+ *
5
+ * primitives.json + semantic.json + sh-ui.config.json
6
+ * → tokens.css (React / 웹)
7
+ * → tokens.dart (Flutter)
8
+ *
9
+ * 처리: {base}·{radius} 치환 → 프리미티브 참조 해석 → 타입별 플랫폼 포맷 출력.
10
+ */
11
+
12
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, resolve } from "node:path";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const REPO_ROOT = resolve(__dirname, "../..");
18
+
19
+ function getPath(obj, path) {
20
+ return path.split(".").reduce((acc, key) => (acc == null ? acc : acc[key]), obj);
21
+ }
22
+
23
+ function resolveString(str, primitives, context, depth = 0) {
24
+ if (depth > 10) throw new Error(`순환 참조 의심: ${str}`);
25
+ let out = str.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (m, k) =>
26
+ k in context ? context[k] : m,
27
+ );
28
+ out = out.replace(/\{([a-zA-Z0-9_.]+)\}/g, (m, path) => {
29
+ const node = getPath(primitives, path);
30
+ if (node && typeof node === "object" && "$value" in node) return String(node.$value);
31
+ if (typeof node === "string" || typeof node === "number") return String(node);
32
+ throw new Error(`해석 실패: ${m}`);
33
+ });
34
+ if (/\{[^}]+\}/.test(out)) return resolveString(out, primitives, context, depth + 1);
35
+ return out;
36
+ }
37
+
38
+ /** $value 참조를 해석한 뒤 { value, type } 항목 맵으로 펼친다. */
39
+ function flatten(obj, primitives, context, prefix = []) {
40
+ const result = {};
41
+ for (const [key, node] of Object.entries(obj)) {
42
+ if (key.startsWith("$")) continue;
43
+ if (node && typeof node === "object" && "$value" in node) {
44
+ result[[...prefix, key].join(".")] = {
45
+ value: resolveString(String(node.$value), primitives, context),
46
+ type: node.$type || "string",
47
+ };
48
+ } else if (node && typeof node === "object") {
49
+ Object.assign(result, flatten(node, primitives, context, [...prefix, key]));
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+
55
+ /** 설정으로 semantic을 해석해 카테고리별 평탄 맵을 반환 */
56
+ async function resolveTokens(config) {
57
+ const primitives = JSON.parse(
58
+ await readFile(resolve(__dirname, "src/primitives.json"), "utf8"),
59
+ );
60
+ const semantic = JSON.parse(
61
+ await readFile(resolve(__dirname, "src/semantic.json"), "utf8"),
62
+ );
63
+ const ctx = { base: config.theme.base, radius: config.theme.radius };
64
+
65
+ const result = {};
66
+ for (const [cat, value] of Object.entries(semantic)) {
67
+ if (cat.startsWith("$")) continue;
68
+ const prefix = cat === "light" || cat === "dark" ? [] : [cat];
69
+ result[cat] = flatten(value, primitives, ctx, prefix);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ /* ───────── CSS ───────── */
75
+
76
+ /** "border-width.default" → "--border-width", "space.1" → "--space-1" */
77
+ function toCssVar(path) {
78
+ const parts = path.split(".");
79
+ if (parts[parts.length - 1] === "default") parts.pop();
80
+ return `--${parts.join("-")}`;
81
+ }
82
+
83
+ function toCssValue(entry) {
84
+ // 모든 타입은 저장 시 이미 CSS-호환 문자열로 직렬화돼있다(dimension=rem/px, duration=ms, easing=cubic-bezier, shadow=css 단축, color=hex, number=숫자, fontWeight=숫자).
85
+ return entry.value;
86
+ }
87
+
88
+ function emitCssBlock(selector, entries) {
89
+ const lines = Object.entries(entries)
90
+ .map(([p, entry]) => ` ${toCssVar(p)}: ${toCssValue(entry)};`)
91
+ .join("\n");
92
+ return `${selector} {\n${lines}\n}`;
93
+ }
94
+
95
+ /** 테마 독립 카테고리(light/dark 제외) 전체를 하나의 맵으로 병합 */
96
+ function mergeThemeIndependent(tokens) {
97
+ const out = {};
98
+ for (const [cat, map] of Object.entries(tokens)) {
99
+ if (cat === "light" || cat === "dark") continue;
100
+ Object.assign(out, map);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ export async function buildTokensCss(config) {
106
+ const tokens = await resolveTokens(config);
107
+ const mode = config.theme.mode;
108
+ const themeIndep = mergeThemeIndependent(tokens);
109
+ const blocks = [];
110
+ if (mode === "light" || mode === "light-dark") {
111
+ blocks.push(emitCssBlock(":root", { ...tokens.light, ...themeIndep }));
112
+ }
113
+ if (mode === "dark") {
114
+ blocks.push(emitCssBlock(":root", { ...tokens.dark, ...themeIndep }));
115
+ }
116
+ if (mode === "light-dark") {
117
+ blocks.push(emitCssBlock(".dark", tokens.dark));
118
+ }
119
+ const header = `/* Generated by @sh-ui/tokens — do not edit directly */\n/* base=${config.theme.base} radius=${config.theme.radius} mode=${config.theme.mode} */\n`;
120
+ return header + "\n" + blocks.join("\n\n") + "\n";
121
+ }
122
+
123
+ /* ───────── Dart 변환기 ───────── */
124
+
125
+ /** "#RRGGBB" → "Color(0xFFRRGGBB)", "#RRGGBBAA" → "Color(0xAARRGGBB)" */
126
+ function hexToDartColor(hex) {
127
+ const m = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.exec(hex);
128
+ if (!m) throw new Error(`지원하지 않는 색상 포맷: ${hex}`);
129
+ const rgb = m[1].toUpperCase();
130
+ const a = (m[2] || "FF").toUpperCase();
131
+ return `Color(0x${a}${rgb})`;
132
+ }
133
+
134
+ /** rgba()/hex CSS 색 → Color(0xAARRGGBB) */
135
+ function cssColorToDart(css) {
136
+ css = css.trim();
137
+ if (css.startsWith("#")) return hexToDartColor(css);
138
+ const rgba = /^rgba?\(([^)]+)\)$/i.exec(css);
139
+ if (rgba) {
140
+ const parts = rgba[1].split(",").map((s) => s.trim());
141
+ const r = parseInt(parts[0], 10);
142
+ const g = parseInt(parts[1], 10);
143
+ const b = parseInt(parts[2], 10);
144
+ const a = parts[3] !== undefined ? parseFloat(parts[3]) : 1;
145
+ const toHex = (n) => n.toString(16).padStart(2, "0").toUpperCase();
146
+ const alpha = toHex(Math.round(a * 255));
147
+ return `Color(0x${alpha}${toHex(r)}${toHex(g)}${toHex(b)})`;
148
+ }
149
+ throw new Error(`지원하지 않는 CSS 색상: ${css}`);
150
+ }
151
+
152
+ /** "0.5rem" → 8.0, "16px" → 16.0 */
153
+ function dimensionToDart(value) {
154
+ const rem = /^(-?\d*\.?\d+)rem$/.exec(value);
155
+ if (rem) return (parseFloat(rem[1]) * 16).toFixed(1);
156
+ const px = /^(-?\d*\.?\d+)px$/.exec(value);
157
+ if (px) return parseFloat(px[1]).toFixed(1);
158
+ throw new Error(`지원하지 않는 치수 포맷: ${value}`);
159
+ }
160
+
161
+ /** "120ms" → "Duration(milliseconds: 120)" */
162
+ function durationToDart(value) {
163
+ const m = /^(\d+)ms$/.exec(value);
164
+ if (!m) throw new Error(`지원하지 않는 duration: ${value}`);
165
+ return `Duration(milliseconds: ${m[1]})`;
166
+ }
167
+
168
+ /** "cubic-bezier(a,b,c,d)" → "Cubic(a, b, c, d)" */
169
+ function easingToDart(value) {
170
+ const m = /^cubic-bezier\s*\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)$/i.exec(
171
+ value,
172
+ );
173
+ if (m) return `Cubic(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})`;
174
+ const named = { linear: "Curves.linear", ease: "Curves.ease" };
175
+ if (value in named) return named[value];
176
+ throw new Error(`지원하지 않는 easing: ${value}`);
177
+ }
178
+
179
+ /** 괄호 그룹을 보존하며 최상위 공백으로 토큰화 */
180
+ function splitByTopLevelSpaces(str) {
181
+ const out = [];
182
+ let cur = "";
183
+ let depth = 0;
184
+ for (const ch of str) {
185
+ if (ch === "(") depth++;
186
+ if (ch === ")") depth--;
187
+ if (ch === " " && depth === 0) {
188
+ if (cur) out.push(cur);
189
+ cur = "";
190
+ } else {
191
+ cur += ch;
192
+ }
193
+ }
194
+ if (cur) out.push(cur);
195
+ return out;
196
+ }
197
+
198
+ /** "0 8px 24px rgba(0,0,0,0.08)" → "BoxShadow(...)" 리터럴 1개 */
199
+ function shadowLayerToDart(css) {
200
+ const tokens = splitByTopLevelSpaces(css.trim());
201
+ const color = tokens[tokens.length - 1];
202
+ const dims = tokens.slice(0, -1);
203
+ const [x, y, blur, spread] = dims;
204
+ const withUnit = (v) => (v.endsWith("px") || v.endsWith("rem") ? v : `${v}px`);
205
+ return `BoxShadow(offset: Offset(${dimensionToDart(withUnit(x))}, ${dimensionToDart(
206
+ withUnit(y),
207
+ )}), blurRadius: ${dimensionToDart(withUnit(blur))}, spreadRadius: ${dimensionToDart(
208
+ withUnit(spread || "0"),
209
+ )}, color: ${cssColorToDart(color)})`;
210
+ }
211
+
212
+ function shadowToDart(value) {
213
+ return `<BoxShadow>[${shadowLayerToDart(value)}]`;
214
+ }
215
+
216
+ function fontWeightToDart(value) {
217
+ const n = typeof value === "number" ? value : parseInt(value, 10);
218
+ return `FontWeight.w${n}`;
219
+ }
220
+
221
+ function numberToDart(value) {
222
+ const n = typeof value === "number" ? value : parseFloat(value);
223
+ return Number.isInteger(n) ? `${n}.0` : `${n}`;
224
+ }
225
+
226
+ function toDartLiteral(entry) {
227
+ switch (entry.type) {
228
+ case "color":
229
+ return hexToDartColor(entry.value);
230
+ case "dimension":
231
+ return dimensionToDart(entry.value);
232
+ case "duration":
233
+ return durationToDart(entry.value);
234
+ case "easing":
235
+ return easingToDart(entry.value);
236
+ case "shadow":
237
+ return shadowToDart(entry.value);
238
+ case "number":
239
+ return numberToDart(entry.value);
240
+ case "fontWeight":
241
+ return fontWeightToDart(entry.value);
242
+ default:
243
+ throw new Error(`지원하지 않는 타입: ${entry.type}`);
244
+ }
245
+ }
246
+
247
+ function toDartType(type) {
248
+ switch (type) {
249
+ case "color":
250
+ return "Color";
251
+ case "dimension":
252
+ return "double";
253
+ case "duration":
254
+ return "Duration";
255
+ case "easing":
256
+ return "Curve";
257
+ case "shadow":
258
+ return "List<BoxShadow>";
259
+ case "number":
260
+ return "double";
261
+ case "fontWeight":
262
+ return "FontWeight";
263
+ default:
264
+ throw new Error(`지원하지 않는 타입: ${type}`);
265
+ }
266
+ }
267
+
268
+ /* ───────── Dart 필드명 규칙 ───────── */
269
+
270
+ /** path 끝에 있는 카테고리 prefix를 제거. "space.1" → "1", "radius.default" → "default" */
271
+ function stripCategoryPrefix(path) {
272
+ const i = path.indexOf(".");
273
+ return i < 0 ? path : path.slice(i + 1);
274
+ }
275
+
276
+ /** Dart 식별자로 변환. 숫자로 시작/특수문자 등 처리. */
277
+ function toIdentifier(name, category) {
278
+ // "default" 키는 카테고리명 + "Default" 또는 별칭으로 대체
279
+ if (name === "default") {
280
+ if (category === "radius") return "defaultRadius";
281
+ if (category === "border-width") return "normal";
282
+ return "defaultValue";
283
+ }
284
+ // "2xl", "3xl" 등 숫자 시작
285
+ if (/^\d/.test(name)) {
286
+ return name.replace(/^(\d+)(.*)$/, (_, d, rest) => `${rest || "n"}${d}`);
287
+ }
288
+ // 하이픈 케밥 → 카멜
289
+ return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
290
+ }
291
+
292
+ /** space.1 → s1, space.16 → s16 (숫자만 카테고리는 짧은 약어 사용) */
293
+ function toShortNumericField(name) {
294
+ if (/^\d+$/.test(name)) return `s${name}`;
295
+ return toIdentifier(name);
296
+ }
297
+
298
+ /* ───────── Dart 카테고리별 클래스 생성 ───────── */
299
+
300
+ const CATEGORY_META = {
301
+ spacing: {
302
+ // semantic "space" → 클래스 ShUiSpacingTokens, 필드 s0..s16
303
+ semanticKey: "space",
304
+ className: "ShUiSpacingTokens",
305
+ themeField: "spacing",
306
+ fieldNameFn: toShortNumericField,
307
+ },
308
+ text: {
309
+ semanticKey: "text",
310
+ className: "ShUiTextTokens",
311
+ themeField: "text",
312
+ fieldNameFn: (n) => toIdentifier(n, "text"),
313
+ },
314
+ weight: {
315
+ semanticKey: "weight",
316
+ className: "ShUiWeightTokens",
317
+ themeField: "weight",
318
+ fieldNameFn: (n) => toIdentifier(n, "weight"),
319
+ },
320
+ shadow: {
321
+ semanticKey: "shadow",
322
+ className: "ShUiShadowTokens",
323
+ themeField: "shadow",
324
+ fieldNameFn: (n) => toIdentifier(n, "shadow"),
325
+ },
326
+ duration: {
327
+ semanticKey: "duration",
328
+ className: "ShUiDurationTokens",
329
+ themeField: "duration",
330
+ fieldNameFn: (n) => toIdentifier(n, "duration"),
331
+ },
332
+ ease: {
333
+ semanticKey: "ease",
334
+ className: "ShUiEaseTokens",
335
+ themeField: "ease",
336
+ fieldNameFn: (n) => toIdentifier(n, "ease"),
337
+ },
338
+ control: {
339
+ semanticKey: "control",
340
+ className: "ShUiControlTokens",
341
+ themeField: "control",
342
+ fieldNameFn: (n) => toIdentifier(n, "control"),
343
+ },
344
+ borderWidth: {
345
+ semanticKey: "border-width",
346
+ className: "ShUiBorderWidthTokens",
347
+ themeField: "borderWidth",
348
+ fieldNameFn: (n) => toIdentifier(n, "border-width"),
349
+ },
350
+ opacity: {
351
+ semanticKey: "opacity",
352
+ className: "ShUiOpacityTokens",
353
+ themeField: "opacity",
354
+ fieldNameFn: (n) => toIdentifier(n, "opacity"),
355
+ },
356
+ breakpoint: {
357
+ semanticKey: "bp",
358
+ className: "ShUiBreakpointTokens",
359
+ themeField: "breakpoint",
360
+ fieldNameFn: (n) => toIdentifier(n, "bp"),
361
+ },
362
+ };
363
+
364
+ function emitCategoryClass(map, meta) {
365
+ // map: { "space.1": {value, type}, ... }
366
+ const entries = Object.entries(map).map(([path, entry]) => {
367
+ const name = stripCategoryPrefix(path);
368
+ const field = meta.fieldNameFn(name);
369
+ return { field, entry };
370
+ });
371
+ const fieldDecls = entries
372
+ .map(({ field, entry }) => ` final ${toDartType(entry.type)} ${field};`)
373
+ .join("\n");
374
+ const ctorArgs = entries.map(({ field }) => ` required this.${field},`).join("\n");
375
+ const staticFields = entries
376
+ .map(({ field, entry }) => ` ${field}: ${toDartLiteral(entry)},`)
377
+ .join("\n");
378
+ return `@immutable
379
+ class ${meta.className} {
380
+ ${fieldDecls}
381
+
382
+ const ${meta.className}({
383
+ ${ctorArgs}
384
+ });
385
+
386
+ static const tokens = ${meta.className}(
387
+ ${staticFields}
388
+ );
389
+ }`;
390
+ }
391
+
392
+ function emitColorClass(lightMap, darkMap) {
393
+ const fields = Object.keys(lightMap).map((path) =>
394
+ stripCategoryPrefix(path) === path
395
+ ? toIdentifier(path, "color")
396
+ : toIdentifier(stripCategoryPrefix(path), "color"),
397
+ );
398
+ // 기존 규칙 유지: "primary.foreground" → "primaryForeground", "background.default" → "background"
399
+ const toColorField = (path) => {
400
+ const parts = path.split(".");
401
+ if (parts[parts.length - 1] === "default") parts.pop();
402
+ return parts
403
+ .map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1)))
404
+ .join("");
405
+ };
406
+ const colorFields = Object.keys(lightMap).map(toColorField);
407
+ const decl = colorFields.map((f) => ` final Color ${f};`).join("\n");
408
+ const ctor = colorFields.map((f) => ` required this.${f},`).join("\n");
409
+ const lightVals = Object.entries(lightMap)
410
+ .map(([path, entry]) => ` ${toColorField(path)}: ${hexToDartColor(entry.value)},`)
411
+ .join("\n");
412
+ const darkVals = Object.entries(darkMap)
413
+ .map(([path, entry]) => ` ${toColorField(path)}: ${hexToDartColor(entry.value)},`)
414
+ .join("\n");
415
+ return `@immutable
416
+ class ShUiColorTokens {
417
+ ${decl}
418
+
419
+ const ShUiColorTokens({
420
+ ${ctor}
421
+ });
422
+
423
+ static const light = ShUiColorTokens(
424
+ ${lightVals}
425
+ );
426
+
427
+ static const dark = ShUiColorTokens(
428
+ ${darkVals}
429
+ );
430
+ }`;
431
+ }
432
+
433
+ function emitRadiusClass(radiusMap) {
434
+ // 기존 규칙: "radius.default" → defaultRadius
435
+ const fields = Object.keys(radiusMap).map((p) => {
436
+ const name = stripCategoryPrefix(p);
437
+ return name === "default" ? "defaultRadius" : toIdentifier(name, "radius");
438
+ });
439
+ const decl = fields.map((f) => ` final double ${f};`).join("\n");
440
+ const ctor = fields.map((f) => ` required this.${f},`).join("\n");
441
+ const staticFields = Object.entries(radiusMap)
442
+ .map(([p, entry]) => {
443
+ const name = stripCategoryPrefix(p);
444
+ const field = name === "default" ? "defaultRadius" : toIdentifier(name, "radius");
445
+ return ` ${field}: ${toDartLiteral(entry)},`;
446
+ })
447
+ .join("\n");
448
+ return `@immutable
449
+ class ShUiRadiusTokens {
450
+ ${decl}
451
+
452
+ const ShUiRadiusTokens({
453
+ ${ctor}
454
+ });
455
+
456
+ static const tokens = ShUiRadiusTokens(
457
+ ${staticFields}
458
+ );
459
+ }`;
460
+ }
461
+
462
+ export async function buildTokensDart(config) {
463
+ const tokens = await resolveTokens(config);
464
+
465
+ const classes = [];
466
+ classes.push(emitColorClass(tokens.light, tokens.dark));
467
+ classes.push(emitRadiusClass(tokens.radius));
468
+
469
+ for (const [, meta] of Object.entries(CATEGORY_META)) {
470
+ const map = tokens[meta.semanticKey];
471
+ if (!map) continue;
472
+ classes.push(emitCategoryClass(map, meta));
473
+ }
474
+
475
+ // ShUiTheme 확장 클래스
476
+ const themeFields = [
477
+ ["colors", "ShUiColorTokens"],
478
+ ["radius", "ShUiRadiusTokens"],
479
+ ];
480
+ for (const [, meta] of Object.entries(CATEGORY_META)) {
481
+ if (!tokens[meta.semanticKey]) continue;
482
+ themeFields.push([meta.themeField, meta.className]);
483
+ }
484
+
485
+ const themeDecls = themeFields.map(([f, t]) => ` final ${t} ${f};`).join("\n");
486
+ const themeCtor = themeFields.map(([f]) => ` required this.${f},`).join("\n");
487
+ const themeLight = themeFields
488
+ .map(([f, t]) => {
489
+ const src = f === "colors" ? "ShUiColorTokens.light" : `${t}.tokens`;
490
+ return ` ${f}: ${src},`;
491
+ })
492
+ .join("\n");
493
+ const themeDark = themeFields
494
+ .map(([f, t]) => {
495
+ const src = f === "colors" ? "ShUiColorTokens.dark" : `${t}.tokens`;
496
+ return ` ${f}: ${src},`;
497
+ })
498
+ .join("\n");
499
+ const copyParams = themeFields.map(([f, t]) => `${t}? ${f}`).join(", ");
500
+ const copyBody = themeFields.map(([f]) => `${f}: ${f} ?? this.${f}`).join(", ");
501
+
502
+ const themeClass = `class ShUiTheme extends ThemeExtension<ShUiTheme> {
503
+ ${themeDecls}
504
+
505
+ const ShUiTheme({
506
+ ${themeCtor}
507
+ });
508
+
509
+ static const light = ShUiTheme(
510
+ ${themeLight}
511
+ );
512
+ static const dark = ShUiTheme(
513
+ ${themeDark}
514
+ );
515
+
516
+ @override
517
+ ShUiTheme copyWith({${copyParams}}) =>
518
+ ShUiTheme(${copyBody});
519
+
520
+ @override
521
+ ShUiTheme lerp(ThemeExtension<ShUiTheme>? other, double t) {
522
+ if (other is! ShUiTheme) return this;
523
+ return t < 0.5 ? this : other;
524
+ }
525
+ }`;
526
+
527
+ const header = `// Generated by @sh-ui/tokens — do not edit directly
528
+ // base=${config.theme.base} radius=${config.theme.radius} mode=${config.theme.mode}
529
+
530
+ import 'package:flutter/material.dart';
531
+ `;
532
+
533
+ return `${header}\n${classes.join("\n\n")}\n\n${themeClass}\n`;
534
+ }
535
+
536
+ /* ───────── CLI ───────── */
537
+
538
+ if (import.meta.url === `file://${process.argv[1]}`) {
539
+ const configPath = resolve(REPO_ROOT, "sh-ui.config.example.json");
540
+ const config = JSON.parse(await readFile(configPath, "utf8"));
541
+ const outDir = resolve(__dirname, "dist");
542
+ await mkdir(outDir, { recursive: true });
543
+
544
+ const css = await buildTokensCss(config);
545
+ const cssPath = resolve(outDir, "tokens.css");
546
+ await writeFile(cssPath, css, "utf8");
547
+ console.log(`✓ ${cssPath}`);
548
+
549
+ const dart = await buildTokensDart(config);
550
+ const dartPath = resolve(outDir, "sh_ui_tokens.dart");
551
+ await writeFile(dartPath, dart, "utf8");
552
+ console.log(`✓ ${dartPath}`);
553
+ }