@spfn/cms 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (233) hide show
  1. package/README.md +28 -416
  2. package/dist/{helpers/locale.actions.d.ts → actions-BEFWwQsh.d.ts} +70 -7
  3. package/dist/actions.d.ts +2 -9
  4. package/dist/actions.js +99 -10
  5. package/dist/actions.js.map +1 -1
  6. package/dist/api.d.ts +319 -0
  7. package/dist/api.js +467 -0
  8. package/dist/api.js.map +1 -0
  9. package/dist/client.d.ts +135 -127
  10. package/dist/client.js +1318 -59
  11. package/dist/client.js.map +1 -1
  12. package/dist/{types.d.ts → index-Dh5FjWzR.d.ts} +45 -7
  13. package/dist/index.d.ts +112 -16
  14. package/dist/index.js +625 -23
  15. package/dist/index.js.map +1 -1
  16. package/dist/label-sync-generator-B0EmvtWM.d.ts +32 -0
  17. package/dist/lib/contracts/labels.d.ts +244 -0
  18. package/dist/lib/contracts/labels.js +269 -0
  19. package/dist/lib/contracts/labels.js.map +1 -0
  20. package/dist/lib/contracts/published-cache.d.ts +48 -0
  21. package/dist/lib/contracts/published-cache.js +49 -0
  22. package/dist/lib/contracts/published-cache.js.map +1 -0
  23. package/dist/lib/contracts/values.d.ts +71 -0
  24. package/dist/lib/contracts/values.js +104 -0
  25. package/dist/lib/contracts/values.js.map +1 -0
  26. package/dist/locale.constants-BNkSdNP1.d.ts +108 -0
  27. package/dist/{entities → server/entities}/cms-audit-logs.d.ts +15 -70
  28. package/dist/server/entities/cms-audit-logs.js +78 -0
  29. package/dist/server/entities/cms-audit-logs.js.map +1 -0
  30. package/dist/{entities → server/entities}/cms-draft-cache.d.ts +13 -73
  31. package/dist/server/entities/cms-draft-cache.js +38 -0
  32. package/dist/server/entities/cms-draft-cache.js.map +1 -0
  33. package/dist/{entities → server/entities}/cms-label-values.d.ts +16 -67
  34. package/dist/server/entities/cms-label-values.js +81 -0
  35. package/dist/server/entities/cms-label-values.js.map +1 -0
  36. package/dist/{entities → server/entities}/cms-labels.d.ts +17 -14
  37. package/dist/server/entities/cms-labels.js +42 -0
  38. package/dist/server/entities/cms-labels.js.map +1 -0
  39. package/dist/{entities → server/entities}/cms-published-cache.d.ts +14 -69
  40. package/dist/server/entities/cms-published-cache.js +36 -0
  41. package/dist/server/entities/cms-published-cache.js.map +1 -0
  42. package/dist/server/entities/index.d.ts +6 -0
  43. package/dist/server/entities/index.js +185 -0
  44. package/dist/server/entities/index.js.map +1 -0
  45. package/dist/server/generators/index.d.ts +19 -0
  46. package/dist/server/generators/index.js +731 -0
  47. package/dist/server/generators/index.js.map +1 -0
  48. package/dist/server/labels/index.d.ts +1 -0
  49. package/dist/server/labels/index.js +33 -0
  50. package/dist/server/labels/index.js.map +1 -0
  51. package/dist/server/repositories/index.d.ts +212 -0
  52. package/dist/server/repositories/index.js +418 -0
  53. package/dist/server/repositories/index.js.map +1 -0
  54. package/dist/server/routes/labels/[id]/admin/index.js +679 -0
  55. package/dist/server/routes/labels/[id]/admin/index.js.map +1 -0
  56. package/dist/server/routes/labels/[id]/index.js +576 -0
  57. package/dist/server/routes/labels/[id]/index.js.map +1 -0
  58. package/dist/server/routes/labels/[id]/publish/index.js +720 -0
  59. package/dist/server/routes/labels/[id]/publish/index.js.map +1 -0
  60. package/dist/server/routes/labels/[id]/versions/index.js +548 -0
  61. package/dist/server/routes/labels/[id]/versions/index.js.map +1 -0
  62. package/dist/server/routes/labels/_id_/admin/index.d.ts +11 -0
  63. package/dist/{routes/labels/[id] → server/routes/labels/_id_}/index.d.ts +5 -3
  64. package/dist/server/routes/labels/_id_/publish/index.d.ts +11 -0
  65. package/dist/server/routes/labels/_id_/versions/index.d.ts +11 -0
  66. package/dist/server/routes/labels/by-key/[key]/index.js +525 -0
  67. package/dist/server/routes/labels/by-key/[key]/index.js.map +1 -0
  68. package/dist/server/routes/labels/by-key/_key_/index.d.ts +10 -0
  69. package/dist/server/routes/labels/index.d.ts +12 -0
  70. package/dist/server/routes/labels/index.js +684 -0
  71. package/dist/server/routes/labels/index.js.map +1 -0
  72. package/dist/server/routes/published-cache/index.d.ts +11 -0
  73. package/dist/server/routes/published-cache/index.js +337 -0
  74. package/dist/server/routes/published-cache/index.js.map +1 -0
  75. package/dist/server/routes/values/[labelId]/[version]/index.js +457 -0
  76. package/dist/server/routes/values/[labelId]/[version]/index.js.map +1 -0
  77. package/dist/server/routes/values/[labelId]/index.js +452 -0
  78. package/dist/server/routes/values/[labelId]/index.js.map +1 -0
  79. package/dist/server/routes/values/_labelId_/_version_/index.d.ts +10 -0
  80. package/dist/server/routes/values/_labelId_/index.d.ts +10 -0
  81. package/dist/server.d.ts +77 -7
  82. package/dist/server.js +1747 -247
  83. package/dist/server.js.map +1 -1
  84. package/migrations/0000_init.sql +3 -0
  85. package/migrations/0001_far_lady_vermin.sql +86 -0
  86. package/migrations/0002_heavy_the_enforcers.sql +2 -0
  87. package/migrations/0003_rare_runaways.sql +1 -0
  88. package/migrations/meta/0000_snapshot.json +15 -0
  89. package/migrations/meta/0001_snapshot.json +687 -0
  90. package/migrations/meta/0002_snapshot.json +686 -0
  91. package/migrations/meta/0003_snapshot.json +563 -0
  92. package/migrations/meta/_journal.json +34 -0
  93. package/package.json +55 -36
  94. package/dist/actions.d.ts.map +0 -1
  95. package/dist/client.d.ts.map +0 -1
  96. package/dist/cms.config.d.ts +0 -77
  97. package/dist/cms.config.d.ts.map +0 -1
  98. package/dist/cms.config.js +0 -111
  99. package/dist/cms.config.js.map +0 -1
  100. package/dist/entities/cms-audit-logs.d.ts.map +0 -1
  101. package/dist/entities/cms-audit-logs.js +0 -103
  102. package/dist/entities/cms-audit-logs.js.map +0 -1
  103. package/dist/entities/cms-draft-cache.d.ts.map +0 -1
  104. package/dist/entities/cms-draft-cache.js +0 -112
  105. package/dist/entities/cms-draft-cache.js.map +0 -1
  106. package/dist/entities/cms-label-values.d.ts.map +0 -1
  107. package/dist/entities/cms-label-values.js +0 -105
  108. package/dist/entities/cms-label-values.js.map +0 -1
  109. package/dist/entities/cms-label-versions.d.ts +0 -207
  110. package/dist/entities/cms-label-versions.d.ts.map +0 -1
  111. package/dist/entities/cms-label-versions.js +0 -80
  112. package/dist/entities/cms-label-versions.js.map +0 -1
  113. package/dist/entities/cms-labels.d.ts.map +0 -1
  114. package/dist/entities/cms-labels.js +0 -48
  115. package/dist/entities/cms-labels.js.map +0 -1
  116. package/dist/entities/cms-published-cache.d.ts.map +0 -1
  117. package/dist/entities/cms-published-cache.js +0 -103
  118. package/dist/entities/cms-published-cache.js.map +0 -1
  119. package/dist/entities/index.d.ts +0 -10
  120. package/dist/entities/index.d.ts.map +0 -1
  121. package/dist/entities/index.js +0 -10
  122. package/dist/entities/index.js.map +0 -1
  123. package/dist/generators/index.d.ts +0 -19
  124. package/dist/generators/index.d.ts.map +0 -1
  125. package/dist/generators/index.js +0 -19
  126. package/dist/generators/index.js.map +0 -1
  127. package/dist/generators/label-sync-generator.d.ts +0 -33
  128. package/dist/generators/label-sync-generator.d.ts.map +0 -1
  129. package/dist/generators/label-sync-generator.js +0 -86
  130. package/dist/generators/label-sync-generator.js.map +0 -1
  131. package/dist/helpers/locale.actions.d.ts.map +0 -1
  132. package/dist/helpers/locale.actions.js +0 -210
  133. package/dist/helpers/locale.actions.js.map +0 -1
  134. package/dist/helpers/locale.constants.d.ts +0 -10
  135. package/dist/helpers/locale.constants.d.ts.map +0 -1
  136. package/dist/helpers/locale.constants.js +0 -10
  137. package/dist/helpers/locale.constants.js.map +0 -1
  138. package/dist/helpers/locale.d.ts +0 -17
  139. package/dist/helpers/locale.d.ts.map +0 -1
  140. package/dist/helpers/locale.js +0 -20
  141. package/dist/helpers/locale.js.map +0 -1
  142. package/dist/helpers/sync.d.ts +0 -41
  143. package/dist/helpers/sync.d.ts.map +0 -1
  144. package/dist/helpers/sync.js +0 -309
  145. package/dist/helpers/sync.js.map +0 -1
  146. package/dist/index.d.ts.map +0 -1
  147. package/dist/init.d.ts +0 -31
  148. package/dist/init.d.ts.map +0 -1
  149. package/dist/init.js +0 -36
  150. package/dist/init.js.map +0 -1
  151. package/dist/labels/helpers.d.ts +0 -31
  152. package/dist/labels/helpers.d.ts.map +0 -1
  153. package/dist/labels/helpers.js +0 -60
  154. package/dist/labels/helpers.js.map +0 -1
  155. package/dist/labels/index.d.ts +0 -7
  156. package/dist/labels/index.d.ts.map +0 -1
  157. package/dist/labels/index.js +0 -7
  158. package/dist/labels/index.js.map +0 -1
  159. package/dist/repositories/cms-draft-cache.repository.d.ts +0 -62
  160. package/dist/repositories/cms-draft-cache.repository.d.ts.map +0 -1
  161. package/dist/repositories/cms-draft-cache.repository.js +0 -56
  162. package/dist/repositories/cms-draft-cache.repository.js.map +0 -1
  163. package/dist/repositories/cms-label-values.repository.d.ts +0 -32
  164. package/dist/repositories/cms-label-values.repository.d.ts.map +0 -1
  165. package/dist/repositories/cms-label-values.repository.js +0 -72
  166. package/dist/repositories/cms-label-values.repository.js.map +0 -1
  167. package/dist/repositories/cms-labels.repository.d.ts +0 -53
  168. package/dist/repositories/cms-labels.repository.d.ts.map +0 -1
  169. package/dist/repositories/cms-labels.repository.js +0 -77
  170. package/dist/repositories/cms-labels.repository.js.map +0 -1
  171. package/dist/repositories/cms-published-cache.repository.d.ts +0 -53
  172. package/dist/repositories/cms-published-cache.repository.d.ts.map +0 -1
  173. package/dist/repositories/cms-published-cache.repository.js +0 -54
  174. package/dist/repositories/cms-published-cache.repository.js.map +0 -1
  175. package/dist/repositories/index.d.ts +0 -8
  176. package/dist/repositories/index.d.ts.map +0 -1
  177. package/dist/repositories/index.js +0 -9
  178. package/dist/repositories/index.js.map +0 -1
  179. package/dist/routes/labels/[id]/contract.d.ts +0 -68
  180. package/dist/routes/labels/[id]/contract.d.ts.map +0 -1
  181. package/dist/routes/labels/[id]/contract.js +0 -84
  182. package/dist/routes/labels/[id]/contract.js.map +0 -1
  183. package/dist/routes/labels/[id]/index.d.ts.map +0 -1
  184. package/dist/routes/labels/[id]/index.js +0 -96
  185. package/dist/routes/labels/[id]/index.js.map +0 -1
  186. package/dist/routes/labels/by-key/[key]/contract.d.ts +0 -24
  187. package/dist/routes/labels/by-key/[key]/contract.d.ts.map +0 -1
  188. package/dist/routes/labels/by-key/[key]/contract.js +0 -28
  189. package/dist/routes/labels/by-key/[key]/contract.js.map +0 -1
  190. package/dist/routes/labels/by-key/[key]/index.d.ts +0 -8
  191. package/dist/routes/labels/by-key/[key]/index.d.ts.map +0 -1
  192. package/dist/routes/labels/by-key/[key]/index.js +0 -32
  193. package/dist/routes/labels/by-key/[key]/index.js.map +0 -1
  194. package/dist/routes/labels/contract.d.ts +0 -59
  195. package/dist/routes/labels/contract.d.ts.map +0 -1
  196. package/dist/routes/labels/contract.js +0 -75
  197. package/dist/routes/labels/contract.js.map +0 -1
  198. package/dist/routes/labels/index.d.ts +0 -10
  199. package/dist/routes/labels/index.d.ts.map +0 -1
  200. package/dist/routes/labels/index.js +0 -73
  201. package/dist/routes/labels/index.js.map +0 -1
  202. package/dist/routes/published-cache/contract.d.ts +0 -25
  203. package/dist/routes/published-cache/contract.d.ts.map +0 -1
  204. package/dist/routes/published-cache/contract.js +0 -35
  205. package/dist/routes/published-cache/contract.js.map +0 -1
  206. package/dist/routes/published-cache/index.d.ts +0 -8
  207. package/dist/routes/published-cache/index.d.ts.map +0 -1
  208. package/dist/routes/published-cache/index.js +0 -33
  209. package/dist/routes/published-cache/index.js.map +0 -1
  210. package/dist/routes/values/[labelId]/[version]/contract.d.ts +0 -29
  211. package/dist/routes/values/[labelId]/[version]/contract.d.ts.map +0 -1
  212. package/dist/routes/values/[labelId]/[version]/contract.js +0 -33
  213. package/dist/routes/values/[labelId]/[version]/contract.js.map +0 -1
  214. package/dist/routes/values/[labelId]/[version]/index.d.ts +0 -8
  215. package/dist/routes/values/[labelId]/[version]/index.d.ts.map +0 -1
  216. package/dist/routes/values/[labelId]/[version]/index.js +0 -45
  217. package/dist/routes/values/[labelId]/[version]/index.js.map +0 -1
  218. package/dist/routes/values/[labelId]/contract.d.ts +0 -38
  219. package/dist/routes/values/[labelId]/contract.d.ts.map +0 -1
  220. package/dist/routes/values/[labelId]/contract.js +0 -59
  221. package/dist/routes/values/[labelId]/contract.js.map +0 -1
  222. package/dist/routes/values/[labelId]/index.d.ts +0 -8
  223. package/dist/routes/values/[labelId]/index.d.ts.map +0 -1
  224. package/dist/routes/values/[labelId]/index.js +0 -42
  225. package/dist/routes/values/[labelId]/index.js.map +0 -1
  226. package/dist/server.d.ts.map +0 -1
  227. package/dist/store.d.ts +0 -87
  228. package/dist/store.d.ts.map +0 -1
  229. package/dist/store.js +0 -205
  230. package/dist/store.js.map +0 -1
  231. package/dist/types.d.ts.map +0 -1
  232. package/dist/types.js +0 -7
  233. package/dist/types.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,264 +1,1764 @@
1
- import "server-only";
2
- /**
3
- * CMS Server Module
4
- *
5
- * Next.js 서버 컴포넌트용 CMS 유틸리티
6
- * - React cache를 사용한 데이터 중복 제거
7
- * - SPFN API를 통한 contract-based 호출
8
- * - 변수 치환 지원
9
- * - 쿠키 기반 locale 자동 관리
10
- */
11
- import { cache } from 'react';
12
- import { client } from '@spfn/core/client';
13
- import { getPublishedCacheContract } from './routes/published-cache/contract.js';
14
- import { getLocale } from './helpers/locale.actions.js';
15
- /**
16
- * 변수 치환 헬퍼
17
- *
18
- * @param text - 치환할 텍스트 (예: 'Hello {name}!')
19
- * @param replace - 치환 맵 (예: { name: 'World' })
20
- * @returns 치환된 텍스트 (예: 'Hello World!')
21
- */
22
- function replaceVariables(text, replace) {
23
- return text.replace(/\{(\w+)}/g, (match, key) => {
24
- const value = replace[key];
25
- return value !== undefined ? String(value) : match;
1
+ // src/server.ts
2
+ import { cache } from "react";
3
+ import { client } from "@spfn/core/client";
4
+
5
+ // src/lib/contracts/published-cache.ts
6
+ import { Type } from "@sinclair/typebox";
7
+ var SectionData = Type.Object({
8
+ section: Type.String(),
9
+ locale: Type.String(),
10
+ content: Type.Record(Type.String(), Type.Any()),
11
+ version: Type.Number(),
12
+ publishedAt: Type.Union([Type.String(), Type.Null()])
13
+ });
14
+ var getPublishedCacheContract = {
15
+ method: "GET",
16
+ path: "/_cms/published-cache",
17
+ query: Type.Object({
18
+ sections: Type.Union([
19
+ Type.String({ description: "\uB2E8\uC77C \uC139\uC158 \uC774\uB984 (\uC608: home)" }),
20
+ Type.Array(Type.String(), { description: '\uC5EC\uB7EC \uC139\uC158 \uC774\uB984 (\uC608: ["home", "footer"])' })
21
+ ]),
22
+ locale: Type.Optional(Type.String({ default: "ko", description: "\uC5B8\uC5B4 \uCF54\uB4DC" }))
23
+ }),
24
+ response: Type.Union([
25
+ // 성공: 항상 배열로 반환
26
+ Type.Array(SectionData),
27
+ // 에러
28
+ Type.Object({
29
+ error: Type.String()
30
+ })
31
+ ])
32
+ };
33
+ var upsertPublishedCacheContract = {
34
+ method: "POST",
35
+ path: "/_cms/published-cache",
36
+ body: Type.Object({
37
+ section: Type.String({ description: "\uC139\uC158 \uC774\uB984 (\uC608: home)" }),
38
+ locale: Type.String({ description: "\uC5B8\uC5B4 \uCF54\uB4DC (\uC608: ko, en, ja)" }),
39
+ content: Type.Record(Type.String(), Type.Any(), { description: "\uBC1C\uD589\uD560 \uCF58\uD150\uCE20 (key-value \uD615\uD0DC)" }),
40
+ version: Type.Number({ description: "\uBC84\uC804 \uBC88\uD638" })
41
+ }),
42
+ response: Type.Union([
43
+ SectionData,
44
+ Type.Object({
45
+ error: Type.String()
46
+ })
47
+ ])
48
+ };
49
+
50
+ // src/server/helpers/locale.actions.ts
51
+ import { cookies, headers } from "next/headers.js";
52
+
53
+ // src/server/config/cms.config.ts
54
+ function getEnvVar(key, defaultValue) {
55
+ return process.env[key] || defaultValue;
56
+ }
57
+ function getEnvBoolean(key, defaultValue) {
58
+ const value = process.env[key];
59
+ if (value === void 0) return defaultValue;
60
+ return value === "true" || value === "1";
61
+ }
62
+ function loadConfigFromEnv() {
63
+ const defaultLocale = getEnvVar("SPFN_CMS_DEFAULT_LOCALE", "en");
64
+ const supportedLocalesStr = getEnvVar("SPFN_CMS_SUPPORTED_LOCALES", "en,ko");
65
+ const detectBrowserLanguage2 = getEnvBoolean("SPFN_CMS_DETECT_BROWSER_LANGUAGE", true);
66
+ const locales = supportedLocalesStr.split(",").map((locale) => locale.trim()).filter((locale) => locale.length > 0);
67
+ if (!locales.includes(defaultLocale)) {
68
+ locales.unshift(defaultLocale);
69
+ }
70
+ return {
71
+ defaultLocale,
72
+ locales,
73
+ supportedLocales: locales,
74
+ // backward compatibility
75
+ detectBrowserLanguage: detectBrowserLanguage2
76
+ };
77
+ }
78
+ var currentConfig = loadConfigFromEnv();
79
+ function getCmsConfig() {
80
+ return currentConfig;
81
+ }
82
+
83
+ // src/lib/constants/locale.constants.ts
84
+ var LOCALE_COOKIE_KEY = "spfn-locale";
85
+ var LOCALE_INFO_MAP = {
86
+ // 한국어
87
+ ko: {
88
+ locale: "ko",
89
+ countryCode: "KR",
90
+ flag: "🇰🇷",
91
+ dialCode: "+82",
92
+ nativeName: "\uD55C\uAD6D\uC5B4",
93
+ englishName: "Korean",
94
+ currencyCode: "KRW",
95
+ dateFormat: "YYYY.MM.DD"
96
+ },
97
+ // 영어 (미국)
98
+ en: {
99
+ locale: "en",
100
+ countryCode: "US",
101
+ flag: "🇺🇸",
102
+ dialCode: "+1",
103
+ nativeName: "English",
104
+ englishName: "English",
105
+ currencyCode: "USD",
106
+ dateFormat: "MM/DD/YYYY"
107
+ },
108
+ // 일본어
109
+ ja: {
110
+ locale: "ja",
111
+ countryCode: "JP",
112
+ flag: "🇯🇵",
113
+ dialCode: "+81",
114
+ nativeName: "\u65E5\u672C\u8A9E",
115
+ englishName: "Japanese",
116
+ currencyCode: "JPY",
117
+ dateFormat: "YYYY/MM/DD"
118
+ },
119
+ // 중국어 (간체)
120
+ zh: {
121
+ locale: "zh",
122
+ countryCode: "CN",
123
+ flag: "🇨🇳",
124
+ dialCode: "+86",
125
+ nativeName: "\u7B80\u4F53\u4E2D\u6587",
126
+ englishName: "Chinese (Simplified)",
127
+ currencyCode: "CNY",
128
+ dateFormat: "YYYY-MM-DD"
129
+ },
130
+ // 중국어 (번체, 대만)
131
+ "zh-TW": {
132
+ locale: "zh-TW",
133
+ countryCode: "TW",
134
+ flag: "🇹🇼",
135
+ dialCode: "+886",
136
+ nativeName: "\u7E41\u9AD4\u4E2D\u6587",
137
+ englishName: "Chinese (Traditional)",
138
+ currencyCode: "TWD",
139
+ dateFormat: "YYYY/MM/DD"
140
+ },
141
+ // 스페인어
142
+ es: {
143
+ locale: "es",
144
+ countryCode: "ES",
145
+ flag: "🇪🇸",
146
+ dialCode: "+34",
147
+ nativeName: "Espa\xF1ol",
148
+ englishName: "Spanish",
149
+ currencyCode: "EUR",
150
+ dateFormat: "DD/MM/YYYY"
151
+ },
152
+ // 프랑스어
153
+ fr: {
154
+ locale: "fr",
155
+ countryCode: "FR",
156
+ flag: "🇫🇷",
157
+ dialCode: "+33",
158
+ nativeName: "Fran\xE7ais",
159
+ englishName: "French",
160
+ currencyCode: "EUR",
161
+ dateFormat: "DD/MM/YYYY"
162
+ },
163
+ // 독일어
164
+ de: {
165
+ locale: "de",
166
+ countryCode: "DE",
167
+ flag: "🇩🇪",
168
+ dialCode: "+49",
169
+ nativeName: "Deutsch",
170
+ englishName: "German",
171
+ currencyCode: "EUR",
172
+ dateFormat: "DD.MM.YYYY"
173
+ },
174
+ // 이탈리아어
175
+ it: {
176
+ locale: "it",
177
+ countryCode: "IT",
178
+ flag: "🇮🇹",
179
+ dialCode: "+39",
180
+ nativeName: "Italiano",
181
+ englishName: "Italian",
182
+ currencyCode: "EUR",
183
+ dateFormat: "DD/MM/YYYY"
184
+ },
185
+ // 포르투갈어 (브라질)
186
+ pt: {
187
+ locale: "pt",
188
+ countryCode: "BR",
189
+ flag: "🇧🇷",
190
+ dialCode: "+55",
191
+ nativeName: "Portugu\xEAs",
192
+ englishName: "Portuguese",
193
+ currencyCode: "BRL",
194
+ dateFormat: "DD/MM/YYYY"
195
+ },
196
+ // 러시아어
197
+ ru: {
198
+ locale: "ru",
199
+ countryCode: "RU",
200
+ flag: "🇷🇺",
201
+ dialCode: "+7",
202
+ nativeName: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439",
203
+ englishName: "Russian",
204
+ currencyCode: "RUB",
205
+ dateFormat: "DD.MM.YYYY"
206
+ },
207
+ // 아랍어
208
+ ar: {
209
+ locale: "ar",
210
+ countryCode: "SA",
211
+ flag: "🇸🇦",
212
+ dialCode: "+966",
213
+ nativeName: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629",
214
+ englishName: "Arabic",
215
+ rtl: true,
216
+ currencyCode: "SAR",
217
+ dateFormat: "DD/MM/YYYY"
218
+ },
219
+ // 힌디어
220
+ hi: {
221
+ locale: "hi",
222
+ countryCode: "IN",
223
+ flag: "🇮🇳",
224
+ dialCode: "+91",
225
+ nativeName: "\u0939\u093F\u0928\u094D\u0926\u0940",
226
+ englishName: "Hindi",
227
+ currencyCode: "INR",
228
+ dateFormat: "DD/MM/YYYY"
229
+ },
230
+ // 태국어
231
+ th: {
232
+ locale: "th",
233
+ countryCode: "TH",
234
+ flag: "🇹🇭",
235
+ dialCode: "+66",
236
+ nativeName: "\u0E44\u0E17\u0E22",
237
+ englishName: "Thai",
238
+ currencyCode: "THB",
239
+ dateFormat: "DD/MM/YYYY"
240
+ },
241
+ // 베트남어
242
+ vi: {
243
+ locale: "vi",
244
+ countryCode: "VN",
245
+ flag: "🇻🇳",
246
+ dialCode: "+84",
247
+ nativeName: "Ti\u1EBFng Vi\u1EC7t",
248
+ englishName: "Vietnamese",
249
+ currencyCode: "VND",
250
+ dateFormat: "DD/MM/YYYY"
251
+ },
252
+ // 인도네시아어
253
+ id: {
254
+ locale: "id",
255
+ countryCode: "ID",
256
+ flag: "🇮🇩",
257
+ dialCode: "+62",
258
+ nativeName: "Bahasa Indonesia",
259
+ englishName: "Indonesian",
260
+ currencyCode: "IDR",
261
+ dateFormat: "DD/MM/YYYY"
262
+ },
263
+ // 터키어
264
+ tr: {
265
+ locale: "tr",
266
+ countryCode: "TR",
267
+ flag: "🇹🇷",
268
+ dialCode: "+90",
269
+ nativeName: "T\xFCrk\xE7e",
270
+ englishName: "Turkish",
271
+ currencyCode: "TRY",
272
+ dateFormat: "DD.MM.YYYY"
273
+ },
274
+ // 폴란드어
275
+ pl: {
276
+ locale: "pl",
277
+ countryCode: "PL",
278
+ flag: "🇵🇱",
279
+ dialCode: "+48",
280
+ nativeName: "Polski",
281
+ englishName: "Polish",
282
+ currencyCode: "PLN",
283
+ dateFormat: "DD.MM.YYYY"
284
+ },
285
+ // 네덜란드어
286
+ nl: {
287
+ locale: "nl",
288
+ countryCode: "NL",
289
+ flag: "🇳🇱",
290
+ dialCode: "+31",
291
+ nativeName: "Nederlands",
292
+ englishName: "Dutch",
293
+ currencyCode: "EUR",
294
+ dateFormat: "DD-MM-YYYY"
295
+ },
296
+ // 중국어 (홍콩)
297
+ "zh-HK": {
298
+ locale: "zh-HK",
299
+ countryCode: "HK",
300
+ flag: "🇭🇰",
301
+ dialCode: "+852",
302
+ nativeName: "\u7E41\u9AD4\u4E2D\u6587 (\u9999\u6E2F)",
303
+ englishName: "Chinese (Hong Kong)",
304
+ currencyCode: "HKD",
305
+ dateFormat: "YYYY/MM/DD"
306
+ },
307
+ // 말레이어
308
+ ms: {
309
+ locale: "ms",
310
+ countryCode: "MY",
311
+ flag: "🇲🇾",
312
+ dialCode: "+60",
313
+ nativeName: "Bahasa Melayu",
314
+ englishName: "Malay",
315
+ currencyCode: "MYR",
316
+ dateFormat: "DD/MM/YYYY"
317
+ },
318
+ // 영어 (영국)
319
+ "en-GB": {
320
+ locale: "en-GB",
321
+ countryCode: "GB",
322
+ flag: "🇬🇧",
323
+ dialCode: "+44",
324
+ nativeName: "English (UK)",
325
+ englishName: "English (United Kingdom)",
326
+ currencyCode: "GBP",
327
+ dateFormat: "DD/MM/YYYY"
328
+ },
329
+ // 영어 (캐나다)
330
+ "en-CA": {
331
+ locale: "en-CA",
332
+ countryCode: "CA",
333
+ flag: "🇨🇦",
334
+ dialCode: "+1",
335
+ nativeName: "English (Canada)",
336
+ englishName: "English (Canada)",
337
+ currencyCode: "CAD",
338
+ dateFormat: "YYYY-MM-DD"
339
+ },
340
+ // 영어 (호주)
341
+ "en-AU": {
342
+ locale: "en-AU",
343
+ countryCode: "AU",
344
+ flag: "🇦🇺",
345
+ dialCode: "+61",
346
+ nativeName: "English (Australia)",
347
+ englishName: "English (Australia)",
348
+ currencyCode: "AUD",
349
+ dateFormat: "DD/MM/YYYY"
350
+ },
351
+ // 영어 (뉴질랜드)
352
+ "en-NZ": {
353
+ locale: "en-NZ",
354
+ countryCode: "NZ",
355
+ flag: "🇳🇿",
356
+ dialCode: "+64",
357
+ nativeName: "English (New Zealand)",
358
+ englishName: "English (New Zealand)",
359
+ currencyCode: "NZD",
360
+ dateFormat: "DD/MM/YYYY"
361
+ },
362
+ // 스페인어 (멕시코)
363
+ "es-MX": {
364
+ locale: "es-MX",
365
+ countryCode: "MX",
366
+ flag: "🇲🇽",
367
+ dialCode: "+52",
368
+ nativeName: "Espa\xF1ol (M\xE9xico)",
369
+ englishName: "Spanish (Mexico)",
370
+ currencyCode: "MXN",
371
+ dateFormat: "DD/MM/YYYY"
372
+ },
373
+ // 스페인어 (아르헨티나)
374
+ "es-AR": {
375
+ locale: "es-AR",
376
+ countryCode: "AR",
377
+ flag: "🇦🇷",
378
+ dialCode: "+54",
379
+ nativeName: "Espa\xF1ol (Argentina)",
380
+ englishName: "Spanish (Argentina)",
381
+ currencyCode: "ARS",
382
+ dateFormat: "DD/MM/YYYY"
383
+ },
384
+ // 스페인어 (콜롬비아)
385
+ "es-CO": {
386
+ locale: "es-CO",
387
+ countryCode: "CO",
388
+ flag: "🇨🇴",
389
+ dialCode: "+57",
390
+ nativeName: "Espa\xF1ol (Colombia)",
391
+ englishName: "Spanish (Colombia)",
392
+ currencyCode: "COP",
393
+ dateFormat: "DD/MM/YYYY"
394
+ },
395
+ // 스웨덴어
396
+ sv: {
397
+ locale: "sv",
398
+ countryCode: "SE",
399
+ flag: "🇸🇪",
400
+ dialCode: "+46",
401
+ nativeName: "Svenska",
402
+ englishName: "Swedish",
403
+ currencyCode: "SEK",
404
+ dateFormat: "YYYY-MM-DD"
405
+ },
406
+ // 노르웨이어
407
+ no: {
408
+ locale: "no",
409
+ countryCode: "NO",
410
+ flag: "🇳🇴",
411
+ dialCode: "+47",
412
+ nativeName: "Norsk",
413
+ englishName: "Norwegian",
414
+ currencyCode: "NOK",
415
+ dateFormat: "DD.MM.YYYY"
416
+ },
417
+ // 덴마크어
418
+ da: {
419
+ locale: "da",
420
+ countryCode: "DK",
421
+ flag: "🇩🇰",
422
+ dialCode: "+45",
423
+ nativeName: "Dansk",
424
+ englishName: "Danish",
425
+ currencyCode: "DKK",
426
+ dateFormat: "DD-MM-YYYY"
427
+ },
428
+ // 핀란드어
429
+ fi: {
430
+ locale: "fi",
431
+ countryCode: "FI",
432
+ flag: "🇫🇮",
433
+ dialCode: "+358",
434
+ nativeName: "Suomi",
435
+ englishName: "Finnish",
436
+ currencyCode: "EUR",
437
+ dateFormat: "DD.MM.YYYY"
438
+ },
439
+ // 우크라이나어
440
+ uk: {
441
+ locale: "uk",
442
+ countryCode: "UA",
443
+ flag: "🇺🇦",
444
+ dialCode: "+380",
445
+ nativeName: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430",
446
+ englishName: "Ukrainian",
447
+ currencyCode: "UAH",
448
+ dateFormat: "DD.MM.YYYY"
449
+ },
450
+ // 체코어
451
+ cs: {
452
+ locale: "cs",
453
+ countryCode: "CZ",
454
+ flag: "🇨🇿",
455
+ dialCode: "+420",
456
+ nativeName: "\u010Ce\u0161tina",
457
+ englishName: "Czech",
458
+ currencyCode: "CZK",
459
+ dateFormat: "DD.MM.YYYY"
460
+ },
461
+ // 헝가리어
462
+ hu: {
463
+ locale: "hu",
464
+ countryCode: "HU",
465
+ flag: "🇭🇺",
466
+ dialCode: "+36",
467
+ nativeName: "Magyar",
468
+ englishName: "Hungarian",
469
+ currencyCode: "HUF",
470
+ dateFormat: "YYYY.MM.DD."
471
+ },
472
+ // 루마니아어
473
+ ro: {
474
+ locale: "ro",
475
+ countryCode: "RO",
476
+ flag: "🇷🇴",
477
+ dialCode: "+40",
478
+ nativeName: "Rom\xE2n\u0103",
479
+ englishName: "Romanian",
480
+ currencyCode: "RON",
481
+ dateFormat: "DD.MM.YYYY"
482
+ },
483
+ // 불가리아어
484
+ bg: {
485
+ locale: "bg",
486
+ countryCode: "BG",
487
+ flag: "🇧🇬",
488
+ dialCode: "+359",
489
+ nativeName: "\u0411\u044A\u043B\u0433\u0430\u0440\u0441\u043A\u0438",
490
+ englishName: "Bulgarian",
491
+ currencyCode: "BGN",
492
+ dateFormat: "DD.MM.YYYY"
493
+ },
494
+ // 크로아티아어
495
+ hr: {
496
+ locale: "hr",
497
+ countryCode: "HR",
498
+ flag: "🇭🇷",
499
+ dialCode: "+385",
500
+ nativeName: "Hrvatski",
501
+ englishName: "Croatian",
502
+ currencyCode: "HRK",
503
+ dateFormat: "DD.MM.YYYY."
504
+ },
505
+ // 세르비아어
506
+ sr: {
507
+ locale: "sr",
508
+ countryCode: "RS",
509
+ flag: "🇷🇸",
510
+ dialCode: "+381",
511
+ nativeName: "\u0421\u0440\u043F\u0441\u043A\u0438",
512
+ englishName: "Serbian",
513
+ currencyCode: "RSD",
514
+ dateFormat: "DD.MM.YYYY."
515
+ },
516
+ // 슬로바키아어
517
+ sk: {
518
+ locale: "sk",
519
+ countryCode: "SK",
520
+ flag: "🇸🇰",
521
+ dialCode: "+421",
522
+ nativeName: "Sloven\u010Dina",
523
+ englishName: "Slovak",
524
+ currencyCode: "EUR",
525
+ dateFormat: "DD.MM.YYYY"
526
+ },
527
+ // 슬로베니아어
528
+ sl: {
529
+ locale: "sl",
530
+ countryCode: "SI",
531
+ flag: "🇸🇮",
532
+ dialCode: "+386",
533
+ nativeName: "Sloven\u0161\u010Dina",
534
+ englishName: "Slovenian",
535
+ currencyCode: "EUR",
536
+ dateFormat: "DD.MM.YYYY"
537
+ },
538
+ // 리투아니아어
539
+ lt: {
540
+ locale: "lt",
541
+ countryCode: "LT",
542
+ flag: "🇱🇹",
543
+ dialCode: "+370",
544
+ nativeName: "Lietuvi\u0173",
545
+ englishName: "Lithuanian",
546
+ currencyCode: "EUR",
547
+ dateFormat: "YYYY-MM-DD"
548
+ },
549
+ // 라트비아어
550
+ lv: {
551
+ locale: "lv",
552
+ countryCode: "LV",
553
+ flag: "🇱🇻",
554
+ dialCode: "+371",
555
+ nativeName: "Latvie\u0161u",
556
+ englishName: "Latvian",
557
+ currencyCode: "EUR",
558
+ dateFormat: "DD.MM.YYYY."
559
+ },
560
+ // 에스토니아어
561
+ et: {
562
+ locale: "et",
563
+ countryCode: "EE",
564
+ flag: "🇪🇪",
565
+ dialCode: "+372",
566
+ nativeName: "Eesti",
567
+ englishName: "Estonian",
568
+ currencyCode: "EUR",
569
+ dateFormat: "DD.MM.YYYY"
570
+ },
571
+ // 그리스어
572
+ el: {
573
+ locale: "el",
574
+ countryCode: "GR",
575
+ flag: "🇬🇷",
576
+ dialCode: "+30",
577
+ nativeName: "\u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC",
578
+ englishName: "Greek",
579
+ currencyCode: "EUR",
580
+ dateFormat: "DD/MM/YYYY"
581
+ },
582
+ // 페르시아어
583
+ fa: {
584
+ locale: "fa",
585
+ countryCode: "IR",
586
+ flag: "🇮🇷",
587
+ dialCode: "+98",
588
+ nativeName: "\u0641\u0627\u0631\u0633\u06CC",
589
+ englishName: "Persian",
590
+ rtl: true,
591
+ currencyCode: "IRR",
592
+ dateFormat: "YYYY/MM/DD"
593
+ },
594
+ // 히브리어
595
+ he: {
596
+ locale: "he",
597
+ countryCode: "IL",
598
+ flag: "🇮🇱",
599
+ dialCode: "+972",
600
+ nativeName: "\u05E2\u05D1\u05E8\u05D9\u05EA",
601
+ englishName: "Hebrew",
602
+ rtl: true,
603
+ currencyCode: "ILS",
604
+ dateFormat: "DD/MM/YYYY"
605
+ },
606
+ // 스와힐리어
607
+ sw: {
608
+ locale: "sw",
609
+ countryCode: "KE",
610
+ flag: "🇰🇪",
611
+ dialCode: "+254",
612
+ nativeName: "Kiswahili",
613
+ englishName: "Swahili",
614
+ currencyCode: "KES",
615
+ dateFormat: "DD/MM/YYYY"
616
+ }
617
+ };
618
+ function getLocaleInfo(locale) {
619
+ return LOCALE_INFO_MAP[locale];
620
+ }
621
+ function getAllLocales() {
622
+ return Object.keys(LOCALE_INFO_MAP);
623
+ }
624
+ function getSupportedLocales() {
625
+ return getAllLocales();
626
+ }
627
+ function getFlag(locale) {
628
+ return LOCALE_INFO_MAP[locale]?.flag ?? "";
629
+ }
630
+ function getDialCode(locale) {
631
+ return LOCALE_INFO_MAP[locale]?.dialCode ?? "";
632
+ }
633
+ function isRTL(locale) {
634
+ return LOCALE_INFO_MAP[locale]?.rtl ?? false;
635
+ }
636
+
637
+ // src/server/helpers/locale.actions.ts
638
+ async function detectBrowserLanguage() {
639
+ try {
640
+ const headersList = await headers();
641
+ const acceptLanguage = headersList.get("accept-language");
642
+ if (!acceptLanguage) {
643
+ return null;
644
+ }
645
+ const languages = acceptLanguage.split(",").map((lang) => {
646
+ const [code] = lang.split(";");
647
+ return code.split("-")[0].trim();
26
648
  });
649
+ const config = getCmsConfig();
650
+ for (const lang of languages) {
651
+ if (config.locales.includes(lang)) {
652
+ return lang;
653
+ }
654
+ }
655
+ return null;
656
+ } catch (error) {
657
+ return null;
658
+ }
27
659
  }
28
- /**
29
- * 섹션 데이터 로드 (React cache 적용)
30
- *
31
- * 동일한 요청 내에서 같은 섹션을 여러 번 요청해도 한 번만 API 호출
32
- *
33
- * @param section - 섹션 이름 (예: 'home', 'why-futureplay')
34
- * @param locale - 언어 코드 (선택, 미지정시 쿠키에서 자동 조회)
35
- * @returns Section API ({ t, data })
36
- *
37
- * @example
38
- * ```tsx
39
- * // Server Component
40
- * import { getSection } from '@spfn/cms/server';
41
- *
42
- * export default async function HomePage()
43
- * {
44
- * // locale을 지정하지 않으면 쿠키에서 자동으로 가져옴
45
- * const { t } = await getSection('home');
46
- *
47
- * // 또는 명시적으로 locale 지정
48
- * const { t: tEn } = await getSection('home', 'en');
49
- *
50
- * return (
51
- * <div>
52
- * <h1>{t('hero.title')}</h1>
53
- * <p>{t('hero.subtitle', 'Default Subtitle')}</p>
54
- * <p>{t('hero.greeting', 'Hello {name}!', { name: 'World' })}</p>
55
- * </div>
56
- * );
57
- * }
58
- * ```
59
- */
60
- export const getSection = cache(async (section, locale) => {
61
- // locale이 지정되지 않으면 쿠키에서 가져옴
62
- const actualLocale = locale ?? await getLocale();
63
- try {
64
- // Call SPFN API via contract (uses singleton client)
65
- const response = await client.call('/cms/published-cache', getPublishedCacheContract, {
66
- query: { sections: section, locale: actualLocale },
67
- });
68
- // Check if response has error
69
- if ('error' in response) {
70
- console.warn(`[CMS] ${response.error}`);
71
- // Return empty section data
72
- const sectionData = {
73
- section,
74
- locale: actualLocale,
75
- content: {},
76
- version: 0,
77
- publishedAt: null,
78
- };
79
- const t = (_key, defaultValue) => defaultValue ?? '';
80
- return { t, data: sectionData };
660
+ async function getLocale() {
661
+ const config = getCmsConfig();
662
+ const cookieStore = await cookies();
663
+ const cookieLocale = cookieStore.get(LOCALE_COOKIE_KEY)?.value;
664
+ if (cookieLocale && config.locales.includes(cookieLocale)) {
665
+ return cookieLocale;
666
+ }
667
+ if (config.detectBrowserLanguage) {
668
+ const browserLang = await detectBrowserLanguage();
669
+ if (browserLang) {
670
+ return browserLang;
671
+ }
672
+ }
673
+ return config.defaultLocale;
674
+ }
675
+
676
+ // src/server/helpers/sync.ts
677
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
678
+ import { basename, extname, join } from "path";
679
+
680
+ // src/server/labels/helpers.ts
681
+ function flattenLabels(labels) {
682
+ const result = [];
683
+ function isLabelDefinition(obj) {
684
+ return "key" in obj && "defaultValue" in obj && typeof obj.key === "string" && (typeof obj.defaultValue === "string" || typeof obj.defaultValue === "object");
685
+ }
686
+ function traverse(obj) {
687
+ if (isLabelDefinition(obj)) {
688
+ result.push({
689
+ key: obj.key,
690
+ type: obj.type,
691
+ defaultValue: obj.defaultValue,
692
+ description: obj.description
693
+ });
694
+ } else {
695
+ Object.values(obj).forEach((value) => {
696
+ if (typeof value === "object" && value !== null) {
697
+ traverse(value);
698
+ }
699
+ });
700
+ }
701
+ }
702
+ traverse(labels);
703
+ return result;
704
+ }
705
+ function extractLabels(definition) {
706
+ return flattenLabels(definition.labels);
707
+ }
708
+
709
+ // src/server/repositories/cms-labels.repository.ts
710
+ import { findOne, findMany as findManyHelper, create as createHelper, updateOne, deleteOne, count as countHelper } from "@spfn/core/db";
711
+ import { asc } from "drizzle-orm";
712
+
713
+ // src/server/entities/cms-labels.ts
714
+ import { index, integer, serial, text, timestamp } from "drizzle-orm/pg-core";
715
+ import { createFunctionSchema } from "@spfn/core/db";
716
+ var schema = createFunctionSchema("@spfn/cms");
717
+ var cmsLabels = schema.table("labels", {
718
+ // Primary Key
719
+ id: serial("id").primaryKey(),
720
+ // 라벨 식별자
721
+ key: text("key").notNull().unique(),
722
+ // 예: "home.hero.title", "why-futureplay.hero.subtitle"
723
+ // 구조: {section}.{component}.{property}
724
+ // 섹션 분류 (페이지 단위)
725
+ section: text("section").notNull(),
726
+ // 예: "home", "why-futureplay", "team"
727
+ // 값 타입
728
+ type: text("type").notNull(),
729
+ // "text" | "image" | "video" | "file" | "object"
730
+ // 기본값
731
+ defaultValue: text("default_value"),
732
+ // 라벨의 기본값 (sync 시 설정)
733
+ // 설명
734
+ description: text("description"),
735
+ // 라벨에 대한 설명 (optional)
736
+ // 현재 발행된 버전 번호
737
+ publishedVersion: integer("published_version"),
738
+ // null = 미발행 상태
739
+ // 1, 2, 3... = 발행된 버전 번호
740
+ // 생성자 추적
741
+ createdBy: text("created_by"),
742
+ // 타임스탬프
743
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
744
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
745
+ }, (table) => [
746
+ // 인덱스: 섹션별 조회 최적화
747
+ index("cms_labels_section_idx").on(table.section),
748
+ // 인덱스: key로 조회 최적화 (unique 제약으로 자동 생성되지만 명시)
749
+ index("cms_labels_key_idx").on(table.key)
750
+ ]);
751
+
752
+ // src/server/entities/cms-label-values.ts
753
+ import { serial as serial2, integer as integer2, text as text2, jsonb, timestamp as timestamp2, index as index2, unique } from "drizzle-orm/pg-core";
754
+ import { createFunctionSchema as createFunctionSchema2 } from "@spfn/core/db";
755
+ var schema2 = createFunctionSchema2("@spfn/cms");
756
+ var cmsLabelValues = schema2.table("label_values", {
757
+ // Primary Key
758
+ id: serial2("id").primaryKey(),
759
+ // Foreign Key: cms_labels
760
+ labelId: integer2("label_id").notNull().references(() => cmsLabels.id, { onDelete: "cascade" }),
761
+ // 버전 번호 (null = draft, number = published version)
762
+ version: integer2("version"),
763
+ // 언어 코드
764
+ locale: text2("locale").notNull().default("ko"),
765
+ // "ko" | "en" | "ja"
766
+ // 반응형 브레이크포인트
767
+ breakpoint: text2("breakpoint"),
768
+ // null = 기본값 (모든 화면 크기)
769
+ // "sm" | "md" | "lg" | "xl" | "2xl"
770
+ // 실제 값 (JSONB)
771
+ value: jsonb("value").notNull(),
772
+ // LabelValue 타입:
773
+ // - TextValue: { type: "text", content: string }
774
+ // - ImageValue: { type: "image", url: string, alt?: string, width?: number, height?: number }
775
+ // - VideoValue: { type: "video", url: string, thumbnail?: string, duration?: number }
776
+ // - FileValue: { type: "file", url: string, filename: string, size?: number }
777
+ // - ObjectValue: { type: "object", fields: Record<string, LabelValue> }
778
+ // 생성 시각
779
+ createdAt: timestamp2("created_at", { withTimezone: true }).notNull().defaultNow()
780
+ }, (table) => [
781
+ // UNIQUE 제약: 같은 버전에서 locale + breakpoint 조합은 유일
782
+ unique("cms_label_values_locale_breakpoint_unique").on(table.labelId, table.version, table.locale, table.breakpoint),
783
+ // 인덱스: labelId + version 복합 조회 최적화
784
+ index2("cms_label_values_label_version_idx").on(table.labelId, table.version),
785
+ // 인덱스: locale 필터링 최적화
786
+ index2("cms_label_values_locale_idx").on(table.locale)
787
+ ]);
788
+
789
+ // src/server/entities/cms-draft-cache.ts
790
+ import { serial as serial3, text as text3, jsonb as jsonb2, timestamp as timestamp3, index as index3, unique as unique2 } from "drizzle-orm/pg-core";
791
+ import { createFunctionSchema as createFunctionSchema3 } from "@spfn/core/db";
792
+ var schema3 = createFunctionSchema3("@spfn/cms");
793
+ var cmsDraftCache = schema3.table("draft_cache", {
794
+ // Primary Key
795
+ id: serial3("id").primaryKey(),
796
+ // 섹션 (페이지 단위)
797
+ section: text3("section").notNull(),
798
+ // "home" | "why-futureplay" | "team" | "our-companies" | "apply"
799
+ // 언어
800
+ locale: text3("locale").notNull(),
801
+ // "ko" | "en" | "ja"
802
+ // 사용자 ID (핵심 필드!)
803
+ userId: text3("user_id").notNull(),
804
+ // 각 관리자의 독립적인 작업 공간
805
+ // Draft 콘텐츠 (JSONB)
806
+ content: jsonb2("content").notNull(),
807
+ // Record<string, LabelValue>
808
+ // {
809
+ // "home.hero.title": { type: "text", content: "수정 중..." },
810
+ // "home.hero.subtitle": { type: "text", content: "새로운 문구" },
811
+ // ...
812
+ // }
813
+ // 최종 수정 시각
814
+ updatedAt: timestamp3("updated_at", { withTimezone: true }).notNull().defaultNow()
815
+ }, (table) => [
816
+ // UNIQUE 제약: section + locale + userId 조합은 유일
817
+ unique2("cms_draft_cache_unique").on(table.section, table.locale, table.userId),
818
+ // 인덱스: section으로 조회 최적화
819
+ index3("cms_draft_cache_section_idx").on(table.section),
820
+ // 인덱스: userId로 사용자의 모든 draft 조회 최적화
821
+ index3("cms_draft_cache_user_idx").on(table.userId)
822
+ ]);
823
+
824
+ // src/server/entities/cms-published-cache.ts
825
+ import { serial as serial4, text as text4, jsonb as jsonb3, integer as integer3, timestamp as timestamp4, index as index4, unique as unique3 } from "drizzle-orm/pg-core";
826
+ import { createFunctionSchema as createFunctionSchema4 } from "@spfn/core/db";
827
+ var schema4 = createFunctionSchema4("@spfn/cms");
828
+ var cmsPublishedCache = schema4.table("published_cache", {
829
+ // Primary Key
830
+ id: serial4("id").primaryKey(),
831
+ // 섹션 (페이지 단위)
832
+ section: text4("section").notNull(),
833
+ // "home" | "why-futureplay" | "team" | "our-companies" | "apply"
834
+ // 언어
835
+ locale: text4("locale").notNull(),
836
+ // "ko" | "en" | "ja"
837
+ // 캐시된 콘텐츠 (JSONB)
838
+ content: jsonb3("content").notNull(),
839
+ // Record<string, LabelValue>
840
+ // {
841
+ // "home.hero.title": { type: "text", content: "..." },
842
+ // "home.hero.image": { type: "image", url: "...", alt: "..." },
843
+ // ...
844
+ // }
845
+ // 발행 정보
846
+ publishedAt: timestamp4("published_at", { withTimezone: true }).notNull(),
847
+ publishedBy: text4("published_by"),
848
+ // 캐시 버전 (클라이언트 캐싱용)
849
+ version: integer3("version").notNull().default(1)
850
+ }, (table) => [
851
+ // UNIQUE 제약: section + locale 조합은 유일
852
+ unique3("cms_published_cache_unique").on(table.section, table.locale),
853
+ // 인덱스: section으로 조회 최적화
854
+ index4("cms_published_cache_section_idx").on(table.section)
855
+ ]);
856
+
857
+ // src/server/entities/cms-audit-logs.ts
858
+ import { serial as serial5, integer as integer4, text as text5, jsonb as jsonb4, timestamp as timestamp5, index as index5 } from "drizzle-orm/pg-core";
859
+ import { createFunctionSchema as createFunctionSchema5 } from "@spfn/core/db";
860
+ var schema5 = createFunctionSchema5("@spfn/cms");
861
+ var cmsAuditLogs = schema5.table("audit_logs", {
862
+ // Primary Key
863
+ id: serial5("id").primaryKey(),
864
+ // Foreign Key: cms_labels (nullable - 라벨 삭제 시 로그는 유지)
865
+ labelId: integer4("label_id").references(() => cmsLabels.id, { onDelete: "set null" }),
866
+ // 작업 유형
867
+ action: text5("action").notNull(),
868
+ // "create" | "update" | "publish" | "unpublish" | "archive" | "delete" | "rollback" | "duplicate"
869
+ // 사용자 정보
870
+ userId: text5("user_id").notNull(),
871
+ userName: text5("user_name"),
872
+ // 변경 내용 (before/after)
873
+ changes: jsonb4("changes"),
874
+ // { before: {...}, after: {...} }
875
+ // 추가 메타데이터
876
+ metadata: jsonb4("metadata"),
877
+ // { version: number, ip: string, userAgent: string, ... }
878
+ // 작업 시각
879
+ createdAt: timestamp5("created_at", { withTimezone: true }).notNull().defaultNow()
880
+ }, (table) => [
881
+ // 인덱스: labelId로 이력 조회 최적화
882
+ index5("cms_audit_logs_label_id_idx").on(table.labelId),
883
+ // 인덱스: userId로 사용자 활동 조회 최적화
884
+ index5("cms_audit_logs_user_id_idx").on(table.userId),
885
+ // 인덱스: action 필터링 최적화
886
+ index5("cms_audit_logs_action_idx").on(table.action),
887
+ // 인덱스: 시간순 조회 최적화
888
+ index5("cms_audit_logs_created_at_idx").on(table.createdAt)
889
+ ]);
890
+
891
+ // src/server/repositories/cms-labels.repository.ts
892
+ async function findMany(options) {
893
+ const { section } = options || {};
894
+ return findManyHelper(cmsLabels, {
895
+ where: section ? { section } : void 0,
896
+ orderBy: asc(cmsLabels.key)
897
+ // key 오름차순 정렬 (JSON 파일의 순서 유지)
898
+ });
899
+ }
900
+ async function count(section) {
901
+ return countHelper(cmsLabels, section ? { section } : void 0);
902
+ }
903
+ async function findById(id) {
904
+ return findOne(cmsLabels, { id });
905
+ }
906
+ async function findByKey(key) {
907
+ return findOne(cmsLabels, { key });
908
+ }
909
+ async function findBySection(section) {
910
+ return findManyHelper(cmsLabels, {
911
+ where: { section },
912
+ orderBy: asc(cmsLabels.key)
913
+ // key 오름차순 정렬 (JSON 파일의 순서 유지)
914
+ });
915
+ }
916
+ async function create(data) {
917
+ return createHelper(cmsLabels, data);
918
+ }
919
+ async function updateById(id, data) {
920
+ return updateOne(cmsLabels, { id }, { ...data, updatedAt: /* @__PURE__ */ new Date() });
921
+ }
922
+ async function deleteById(id) {
923
+ return deleteOne(cmsLabels, { id });
924
+ }
925
+ var cmsLabelsRepository = {
926
+ findMany,
927
+ count,
928
+ findById,
929
+ findByKey,
930
+ findBySection,
931
+ create,
932
+ updateById,
933
+ deleteById
934
+ };
935
+
936
+ // src/server/repositories/cms-label-values.repository.ts
937
+ import { findOne as findOne2, findMany as findMany2, create as create2, updateOne as updateOne2, deleteMany } from "@spfn/core/db";
938
+ import { eq, and, isNull } from "drizzle-orm";
939
+ async function findByLabelIdAndVersion(labelId, version, options) {
940
+ const { locale, breakpoint } = options || {};
941
+ const conditions = [
942
+ eq(cmsLabelValues.labelId, labelId),
943
+ eq(cmsLabelValues.version, version)
944
+ ];
945
+ if (locale) {
946
+ conditions.push(eq(cmsLabelValues.locale, locale));
947
+ }
948
+ if (breakpoint !== void 0) {
949
+ conditions.push(
950
+ breakpoint === null ? isNull(cmsLabelValues.breakpoint) : eq(cmsLabelValues.breakpoint, breakpoint)
951
+ );
952
+ }
953
+ return findMany2(cmsLabelValues, {
954
+ where: and(...conditions)
955
+ });
956
+ }
957
+ async function upsert(data) {
958
+ const versionCondition = data.version === null || data.version === void 0 ? isNull(cmsLabelValues.version) : eq(cmsLabelValues.version, data.version);
959
+ const existing = await findOne2(
960
+ cmsLabelValues,
961
+ and(
962
+ eq(cmsLabelValues.labelId, data.labelId),
963
+ versionCondition,
964
+ eq(cmsLabelValues.locale, data.locale || "ko"),
965
+ data.breakpoint ? eq(cmsLabelValues.breakpoint, data.breakpoint) : isNull(cmsLabelValues.breakpoint)
966
+ )
967
+ );
968
+ if (existing) {
969
+ if (data.version === null || data.version === void 0) {
970
+ const updated = await updateOne2(
971
+ cmsLabelValues,
972
+ { id: existing.id },
973
+ { value: data.value }
974
+ );
975
+ return updated;
976
+ } else {
977
+ throw new Error(`Published version ${data.version} already exists and cannot be overwritten`);
978
+ }
979
+ } else {
980
+ return create2(cmsLabelValues, data);
981
+ }
982
+ }
983
+ async function findDraftsByLabelId(labelId) {
984
+ return findMany2(cmsLabelValues, {
985
+ where: and(
986
+ eq(cmsLabelValues.labelId, labelId),
987
+ isNull(cmsLabelValues.version)
988
+ )
989
+ });
990
+ }
991
+ async function upsertMany(values) {
992
+ const results = [];
993
+ for (const value of values) {
994
+ const result = await upsert(value);
995
+ results.push(result);
996
+ }
997
+ return results;
998
+ }
999
+ async function deleteByVersion(labelId, version) {
1000
+ return deleteMany(
1001
+ cmsLabelValues,
1002
+ and(
1003
+ eq(cmsLabelValues.labelId, labelId),
1004
+ eq(cmsLabelValues.version, version)
1005
+ )
1006
+ );
1007
+ }
1008
+ var cmsLabelValuesRepository = {
1009
+ findByLabelIdAndVersion,
1010
+ findDraftsByLabelId,
1011
+ upsert,
1012
+ upsertMany,
1013
+ deleteByVersion
1014
+ };
1015
+
1016
+ // src/server/repositories/cms-draft-cache.repository.ts
1017
+ import { findOne as findOne3, findMany as findMany3, deleteOne as deleteOne2, deleteMany as deleteMany2, upsert as upsertHelper } from "@spfn/core/db";
1018
+ import { eq as eq2, and as and2, lt } from "drizzle-orm";
1019
+ async function findByUser(section, locale, userId) {
1020
+ return findOne3(
1021
+ cmsDraftCache,
1022
+ and2(
1023
+ eq2(cmsDraftCache.section, section),
1024
+ eq2(cmsDraftCache.locale, locale),
1025
+ eq2(cmsDraftCache.userId, userId)
1026
+ )
1027
+ );
1028
+ }
1029
+ async function upsert2(data) {
1030
+ return upsertHelper(cmsDraftCache, data, {
1031
+ target: [cmsDraftCache.section, cmsDraftCache.locale, cmsDraftCache.userId],
1032
+ set: {
1033
+ content: data.content,
1034
+ updatedAt: /* @__PURE__ */ new Date()
1035
+ }
1036
+ });
1037
+ }
1038
+ async function findAllByUser(userId) {
1039
+ return findMany3(cmsDraftCache, {
1040
+ where: eq2(cmsDraftCache.userId, userId)
1041
+ });
1042
+ }
1043
+ async function deleteByUser(section, locale, userId) {
1044
+ await deleteOne2(
1045
+ cmsDraftCache,
1046
+ and2(
1047
+ eq2(cmsDraftCache.section, section),
1048
+ eq2(cmsDraftCache.locale, locale),
1049
+ eq2(cmsDraftCache.userId, userId)
1050
+ )
1051
+ );
1052
+ }
1053
+ async function cleanupOldDrafts(daysOld = 30) {
1054
+ const cutoffDate = /* @__PURE__ */ new Date();
1055
+ cutoffDate.setDate(cutoffDate.getDate() - daysOld);
1056
+ return deleteMany2(
1057
+ cmsDraftCache,
1058
+ lt(cmsDraftCache.updatedAt, cutoffDate)
1059
+ );
1060
+ }
1061
+ var cmsDraftCacheRepository = {
1062
+ findByUser,
1063
+ upsert: upsert2,
1064
+ findAllByUser,
1065
+ deleteByUser,
1066
+ cleanupOldDrafts
1067
+ };
1068
+
1069
+ // src/server/repositories/cms-published-cache.repository.ts
1070
+ import { findOne as findOne4, findMany as findMany4, deleteOne as deleteOne3, deleteMany as deleteMany3, upsert as upsertHelper2 } from "@spfn/core/db";
1071
+ import { eq as eq3, and as and3, sql } from "drizzle-orm";
1072
+ async function findBySection2(section, locale = "ko") {
1073
+ return findOne4(
1074
+ cmsPublishedCache,
1075
+ and3(
1076
+ eq3(cmsPublishedCache.section, section),
1077
+ eq3(cmsPublishedCache.locale, locale)
1078
+ )
1079
+ );
1080
+ }
1081
+ async function upsert3(data) {
1082
+ return upsertHelper2(cmsPublishedCache, data, {
1083
+ target: [cmsPublishedCache.section, cmsPublishedCache.locale],
1084
+ set: {
1085
+ content: data.content,
1086
+ publishedAt: data.publishedAt,
1087
+ publishedBy: data.publishedBy,
1088
+ version: sql`${cmsPublishedCache.version} + 1`
1089
+ // 버전 증가로 클라이언트 캐시 무효화
1090
+ }
1091
+ });
1092
+ }
1093
+ async function findAllLanguages(section) {
1094
+ return findMany4(cmsPublishedCache, {
1095
+ where: eq3(cmsPublishedCache.section, section)
1096
+ });
1097
+ }
1098
+ async function deleteBySection(section, locale) {
1099
+ if (locale) {
1100
+ await deleteOne3(
1101
+ cmsPublishedCache,
1102
+ and3(
1103
+ eq3(cmsPublishedCache.section, section),
1104
+ eq3(cmsPublishedCache.locale, locale)
1105
+ )
1106
+ );
1107
+ } else {
1108
+ await deleteMany3(
1109
+ cmsPublishedCache,
1110
+ eq3(cmsPublishedCache.section, section)
1111
+ );
1112
+ }
1113
+ }
1114
+ var cmsPublishedCacheRepository = {
1115
+ findBySection: findBySection2,
1116
+ upsert: upsert3,
1117
+ findAllLanguages,
1118
+ deleteBySection
1119
+ };
1120
+
1121
+ // src/lib/constants/index.ts
1122
+ var DEFAULT_LABELS_DIR = "src/lib/labels";
1123
+
1124
+ // src/server/helpers/sync.ts
1125
+ async function syncAll(sections, options = {}) {
1126
+ const results = [];
1127
+ for (const definition of sections) {
1128
+ const result = await syncSection(definition, options);
1129
+ results.push(result);
1130
+ }
1131
+ return results;
1132
+ }
1133
+ function loadLabelsFromJson(labelsDir) {
1134
+ const sections = [];
1135
+ if (!existsSync(labelsDir)) {
1136
+ console.warn(`[CMS] Labels directory not found: ${labelsDir}`);
1137
+ console.warn(`[CMS] Expected directory structure:`);
1138
+ console.warn(`[CMS] ${labelsDir}/`);
1139
+ console.warn(`[CMS] \u251C\u2500\u2500 common/ # Section directory`);
1140
+ console.warn(`[CMS] \u2502 \u251C\u2500\u2500 messages.json`);
1141
+ console.warn(`[CMS] \u2502 \u2514\u2500\u2500 errors.json`);
1142
+ console.warn(`[CMS] \u2514\u2500\u2500 home/ # Section directory`);
1143
+ console.warn(`[CMS] \u2514\u2500\u2500 hero.json`);
1144
+ return sections;
1145
+ }
1146
+ try {
1147
+ const entries = readdirSync(labelsDir);
1148
+ if (entries.length === 0) {
1149
+ console.warn(`[CMS] Labels directory is empty: ${labelsDir}`);
1150
+ console.warn(`[CMS] Create section directories with JSON files inside`);
1151
+ return sections;
1152
+ }
1153
+ const jsonFiles = entries.filter((e) => extname(e) === ".json");
1154
+ if (jsonFiles.length > 0) {
1155
+ console.warn(`[CMS] Found JSON files directly in ${labelsDir}:`);
1156
+ jsonFiles.forEach((f) => console.warn(`[CMS] - ${f} (will be ignored)`));
1157
+ console.warn(`[CMS] JSON files should be inside section directories`);
1158
+ console.warn(`[CMS] Example: Move ${jsonFiles[0]} to ${labelsDir}/${basename(jsonFiles[0], ".json")}/${jsonFiles[0]}`);
1159
+ }
1160
+ for (const entry of entries) {
1161
+ const sectionPath = join(labelsDir, entry);
1162
+ const stat = statSync(sectionPath);
1163
+ if (stat.isDirectory()) {
1164
+ const sectionName = entry;
1165
+ const labels = loadSectionLabels(sectionPath);
1166
+ if (Object.keys(labels).length > 0) {
1167
+ sections.push({ section: sectionName, labels });
1168
+ } else {
1169
+ console.warn(`[CMS] Section directory "${sectionName}" has no valid JSON files`);
81
1170
  }
82
- // Response is an array, get first element
83
- const found = response[0];
84
- if (!found) {
85
- // Section not found, return empty
86
- const sectionData = {
87
- section,
88
- locale: actualLocale,
89
- content: {},
90
- version: 0,
91
- publishedAt: null,
92
- };
93
- const t = (_key, defaultValue) => defaultValue ?? '';
94
- return { t, data: sectionData };
1171
+ }
1172
+ }
1173
+ if (sections.length === 0) {
1174
+ console.warn(`[CMS] No valid section directories found in ${labelsDir}`);
1175
+ }
1176
+ } catch (error) {
1177
+ console.warn(`[CMS] Could not scan labels directory: ${labelsDir}`);
1178
+ console.error(`[CMS] Error:`, error);
1179
+ }
1180
+ return sections;
1181
+ }
1182
+ function loadSectionLabels(sectionPath) {
1183
+ const labels = {};
1184
+ try {
1185
+ const files = readdirSync(sectionPath);
1186
+ for (const file of files) {
1187
+ if (extname(file) === ".json") {
1188
+ const filePath = join(sectionPath, file);
1189
+ const categoryName = basename(file, ".json");
1190
+ try {
1191
+ const content = readFileSync(filePath, "utf-8");
1192
+ labels[categoryName] = JSON.parse(content);
1193
+ } catch (error) {
1194
+ console.warn(`[CMS] Failed to parse ${filePath}`);
95
1195
  }
96
- // Success response
97
- const sectionData = {
98
- section: found.section,
99
- locale: found.locale,
100
- content: found.content || {},
101
- version: found.version,
102
- publishedAt: found.publishedAt,
103
- };
104
- // Translation function
105
- const t = (key, defaultValue, replace) => {
106
- const fullKey = `${section}.${key}`;
107
- let value = sectionData.content[fullKey];
108
- if (value === undefined || value === null) {
109
- value = defaultValue ?? '';
1196
+ }
1197
+ }
1198
+ } catch (error) {
1199
+ console.warn(`[CMS] Could not read section directory: ${sectionPath}`);
1200
+ }
1201
+ return labels;
1202
+ }
1203
+ async function syncSection(definition, options = {}) {
1204
+ const {
1205
+ dryRun = false,
1206
+ updateExisting = false,
1207
+ removeUnused = false,
1208
+ verbose = false
1209
+ } = options;
1210
+ const { section } = definition;
1211
+ const result = {
1212
+ section,
1213
+ created: 0,
1214
+ updated: 0,
1215
+ deleted: 0,
1216
+ unchanged: 0,
1217
+ errors: []
1218
+ };
1219
+ try {
1220
+ const definedLabels = extractLabels(definition);
1221
+ const definedKeys = new Set(definedLabels.map((l) => l.key));
1222
+ const existingLabels = await cmsLabelsRepository.findBySection(section);
1223
+ const existingMap = new Map(existingLabels.map((l) => [l.key, l]));
1224
+ if (verbose) {
1225
+ console.log(`
1226
+ [${section}] Found ${definedLabels.length} labels in definition`);
1227
+ console.log(`[${section}] Found ${existingLabels.length} labels in DB`);
1228
+ }
1229
+ for (const label of definedLabels) {
1230
+ const existing = existingMap.get(label.key);
1231
+ if (!existing) {
1232
+ if (verbose) console.log(` [CREATE] ${label.key}`);
1233
+ if (!dryRun) {
1234
+ try {
1235
+ const defaultValue = typeof label.defaultValue === "object" ? JSON.stringify(label.defaultValue) : label.defaultValue;
1236
+ await cmsLabelsRepository.create({
1237
+ section,
1238
+ key: label.key,
1239
+ type: label.type || "text",
1240
+ // 라벨 타입 (기본값: 'text')
1241
+ defaultValue,
1242
+ description: label.description
1243
+ });
1244
+ } catch (error) {
1245
+ result.errors.push({
1246
+ key: label.key,
1247
+ error: error instanceof Error ? error.message : String(error)
1248
+ });
1249
+ continue;
1250
+ }
1251
+ }
1252
+ result.created++;
1253
+ } else if (updateExisting) {
1254
+ const newDefaultValue = typeof label.defaultValue === "object" ? JSON.stringify(label.defaultValue) : label.defaultValue;
1255
+ const newType = label.type || "text";
1256
+ const hasChanged = existing.defaultValue !== newDefaultValue || existing.type !== newType;
1257
+ if (hasChanged) {
1258
+ if (verbose) {
1259
+ console.log(` [UPDATE] ${label.key}`);
1260
+ console.log(` Old: "${existing.defaultValue}"`);
1261
+ console.log(` New: "${newDefaultValue}"`);
1262
+ }
1263
+ if (!dryRun) {
1264
+ try {
1265
+ await cmsLabelsRepository.updateById(existing.id, {
1266
+ type: label.type || "text",
1267
+ defaultValue: newDefaultValue,
1268
+ description: label.description
1269
+ });
1270
+ } catch (error) {
1271
+ result.errors.push({
1272
+ key: label.key,
1273
+ error: error instanceof Error ? error.message : String(error)
1274
+ });
1275
+ continue;
110
1276
  }
111
- // 문자열이 아니면 빈 문자열 반환
112
- if (typeof value !== 'string') {
113
- return '';
1277
+ }
1278
+ result.updated++;
1279
+ } else {
1280
+ result.unchanged++;
1281
+ }
1282
+ } else {
1283
+ result.unchanged++;
1284
+ }
1285
+ }
1286
+ if (removeUnused) {
1287
+ for (const existing of existingLabels) {
1288
+ if (!definedKeys.has(existing.key)) {
1289
+ if (verbose) console.log(` [DELETE] ${existing.key}`);
1290
+ if (!dryRun) {
1291
+ try {
1292
+ await cmsLabelsRepository.deleteById(existing.id);
1293
+ } catch (error) {
1294
+ result.errors.push({
1295
+ key: existing.key,
1296
+ error: error instanceof Error ? error.message : String(error)
1297
+ });
1298
+ continue;
114
1299
  }
115
- // 문자열이고 치환 맵이 있으면 변수 치환
116
- if (replace) {
117
- value = replaceVariables(value, replace);
1300
+ }
1301
+ result.deleted++;
1302
+ }
1303
+ }
1304
+ }
1305
+ if (!dryRun && (result.created > 0 || result.updated > 0 || result.deleted > 0)) {
1306
+ if (verbose) console.log(` [CACHE] Updating published cache for section: ${section}`);
1307
+ await updatePublishedCache(section);
1308
+ }
1309
+ } catch (error) {
1310
+ result.errors.push({
1311
+ key: "__section__",
1312
+ error: error instanceof Error ? error.message : String(error)
1313
+ });
1314
+ }
1315
+ return result;
1316
+ }
1317
+ async function updatePublishedCache(section) {
1318
+ const labels = await cmsLabelsRepository.findBySection(section);
1319
+ const localesSet = /* @__PURE__ */ new Set();
1320
+ const labelsByLocale = {};
1321
+ const singleValueLabels = [];
1322
+ labels.forEach((label) => {
1323
+ try {
1324
+ const parsed = JSON.parse(label.defaultValue || "{}");
1325
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
1326
+ Object.keys(parsed).forEach((locale) => localesSet.add(locale));
1327
+ Object.entries(parsed).forEach(([locale, value]) => {
1328
+ if (!labelsByLocale[locale]) labelsByLocale[locale] = {};
1329
+ labelsByLocale[locale][label.key] = value;
1330
+ });
1331
+ } else {
1332
+ singleValueLabels.push({ key: label.key, value: label.defaultValue });
1333
+ }
1334
+ } catch {
1335
+ singleValueLabels.push({ key: label.key, value: label.defaultValue });
1336
+ }
1337
+ });
1338
+ if (localesSet.size === 0) {
1339
+ localesSet.add("ko");
1340
+ localesSet.add("en");
1341
+ }
1342
+ singleValueLabels.forEach(({ key, value }) => {
1343
+ localesSet.forEach((locale) => {
1344
+ if (!labelsByLocale[locale]) labelsByLocale[locale] = {};
1345
+ labelsByLocale[locale][key] = value;
1346
+ });
1347
+ });
1348
+ const timestamp6 = /* @__PURE__ */ new Date();
1349
+ for (const locale of localesSet) {
1350
+ await cmsPublishedCacheRepository.upsert({
1351
+ section,
1352
+ locale,
1353
+ content: labelsByLocale[locale] || {},
1354
+ publishedAt: timestamp6,
1355
+ publishedBy: "system"
1356
+ });
1357
+ }
1358
+ }
1359
+ async function initLabelSync(options = {}) {
1360
+ const isDevelopment = process.env.NODE_ENV === "development";
1361
+ const verbose = options.verbose ?? isDevelopment;
1362
+ const labelsDir = options.labelsDir ?? DEFAULT_LABELS_DIR;
1363
+ if (verbose) {
1364
+ console.log("\n\u{1F504} Initializing label sync...\n");
1365
+ }
1366
+ const sections = loadLabelsFromJson(labelsDir);
1367
+ if (sections.length === 0) {
1368
+ if (verbose) {
1369
+ console.log("\u26A0\uFE0F No labels found in", labelsDir);
1370
+ console.log("");
1371
+ }
1372
+ return;
1373
+ }
1374
+ const results = await syncAll(sections, {
1375
+ updateExisting: true,
1376
+ // 🔄 항상 업데이트 (프로덕션 포함)
1377
+ ...options,
1378
+ verbose
1379
+ });
1380
+ const totalCreated = results.reduce((sum, r) => sum + r.created, 0);
1381
+ const totalUpdated = results.reduce((sum, r) => sum + r.updated, 0);
1382
+ const totalUnchanged = results.reduce((sum, r) => sum + r.unchanged, 0);
1383
+ const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0);
1384
+ if (verbose) {
1385
+ console.log("\u2705 Label sync completed\n");
1386
+ console.log(` Sections: ${results.length}`);
1387
+ console.log(` Created: ${totalCreated}`);
1388
+ console.log(` Updated: ${totalUpdated}`);
1389
+ console.log(` Unchanged: ${totalUnchanged}`);
1390
+ if (totalErrors > 0) {
1391
+ console.log(` Errors: ${totalErrors}
1392
+ `);
1393
+ } else {
1394
+ console.log("");
1395
+ }
1396
+ }
1397
+ if (totalErrors > 0) {
1398
+ results.forEach((result) => {
1399
+ result.errors.forEach((error) => {
1400
+ console.error(`[${result.section}] ${error.key}: ${error.error}`);
1401
+ });
1402
+ });
1403
+ }
1404
+ }
1405
+
1406
+ // src/server/generators/label-sync-generator.ts
1407
+ import { logger } from "@spfn/core/logger";
1408
+ import { join as join2, relative, extname as extname2 } from "path";
1409
+ import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
1410
+ var syncLogger = logger.child("label-sync");
1411
+ function createLabelSyncGenerator(config = {}) {
1412
+ const labelsDir = config.labelsDir ?? DEFAULT_LABELS_DIR;
1413
+ const runOn = config.runOn ?? ["watch", "manual", "build"];
1414
+ return {
1415
+ name: "label-sync",
1416
+ watchPatterns: [`${labelsDir}/**/*.json`],
1417
+ runOn,
1418
+ async generate(options) {
1419
+ const labelsPath = join2(options.cwd, labelsDir);
1420
+ if (!existsSync2(labelsPath)) {
1421
+ if (options.debug) {
1422
+ syncLogger.warn(`Labels directory not found: ${labelsPath}`);
1423
+ }
1424
+ return;
1425
+ }
1426
+ try {
1427
+ const changedFile = options.trigger?.changedFile;
1428
+ if (changedFile && changedFile.event === "change") {
1429
+ const success = await attemptIncrementalSync({
1430
+ cwd: options.cwd,
1431
+ labelsPath,
1432
+ changedFilePath: changedFile.path,
1433
+ debug: options.debug
1434
+ });
1435
+ if (success) {
1436
+ if (options.debug) {
1437
+ syncLogger.info("Incremental sync successful");
118
1438
  }
119
- return value;
120
- };
121
- return {
122
- t,
123
- data: sectionData,
124
- };
1439
+ return;
1440
+ }
1441
+ if (options.debug) {
1442
+ syncLogger.info("Incremental sync failed, doing full sync");
1443
+ }
1444
+ }
1445
+ if (options.debug) {
1446
+ syncLogger.info("Starting full label sync...");
1447
+ }
1448
+ const sections = loadLabelsFromJson(labelsPath);
1449
+ if (sections.length === 0) {
1450
+ syncLogger.warn(`No labels found in ${labelsPath}`);
1451
+ return;
1452
+ }
1453
+ syncLogger.info(`Found ${sections.length} sections`);
1454
+ const results = await syncAll(sections, {
1455
+ verbose: options.debug ?? false,
1456
+ updateExisting: true
1457
+ });
1458
+ const totalCreated = results.reduce((sum, r) => sum + r.created, 0);
1459
+ const totalUpdated = results.reduce((sum, r) => sum + r.updated, 0);
1460
+ const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0);
1461
+ if (options.debug || totalCreated > 0 || totalUpdated > 0) {
1462
+ syncLogger.info("Label sync completed", {
1463
+ sections: results.length,
1464
+ created: totalCreated,
1465
+ updated: totalUpdated,
1466
+ errors: totalErrors
1467
+ });
1468
+ }
1469
+ if (totalErrors > 0) {
1470
+ results.forEach((result) => {
1471
+ result.errors.forEach((error) => {
1472
+ syncLogger.error(`[${result.section}] ${error.key}: ${error.error}`);
1473
+ });
1474
+ });
1475
+ }
1476
+ } catch (error) {
1477
+ const err = error instanceof Error ? error : new Error(String(error));
1478
+ syncLogger.error("Label sync failed", err);
1479
+ throw err;
1480
+ }
1481
+ }
1482
+ };
1483
+ }
1484
+ async function attemptIncrementalSync(options) {
1485
+ const { cwd, labelsPath, changedFilePath, debug } = options;
1486
+ try {
1487
+ const fullPath = join2(cwd, changedFilePath);
1488
+ if (!existsSync2(fullPath)) {
1489
+ return false;
1490
+ }
1491
+ const relativePath = relative(labelsPath, fullPath);
1492
+ const parts = relativePath.split("/");
1493
+ if (parts.length < 2) {
1494
+ return false;
1495
+ }
1496
+ const sectionName = parts[0];
1497
+ if (debug) {
1498
+ syncLogger.info("Attempting incremental sync", {
1499
+ section: sectionName,
1500
+ file: changedFilePath
1501
+ });
1502
+ }
1503
+ const sectionPath = join2(labelsPath, sectionName);
1504
+ const labels = loadSectionLabels2(sectionPath);
1505
+ if (Object.keys(labels).length === 0) {
1506
+ if (debug) {
1507
+ syncLogger.warn("Section has no valid labels");
1508
+ }
1509
+ return false;
125
1510
  }
126
- catch (error) {
127
- console.error(`[CMS] Failed to fetch section "${section}":`, error);
128
- // Return empty section data on error
129
- const sectionData = {
1511
+ const result = await syncSection(
1512
+ { section: sectionName, labels },
1513
+ { verbose: debug, updateExisting: true }
1514
+ );
1515
+ if (debug || result.created > 0 || result.updated > 0) {
1516
+ syncLogger.info(`[${sectionName}] Incremental sync completed`, {
1517
+ created: result.created,
1518
+ updated: result.updated,
1519
+ unchanged: result.unchanged,
1520
+ errors: result.errors.length
1521
+ });
1522
+ }
1523
+ if (result.errors.length > 0) {
1524
+ result.errors.forEach((error) => {
1525
+ syncLogger.error(`[${sectionName}] ${error.key}: ${error.error}`);
1526
+ });
1527
+ }
1528
+ return true;
1529
+ } catch (error) {
1530
+ if (debug) {
1531
+ const err = error instanceof Error ? error : new Error(String(error));
1532
+ syncLogger.warn("Incremental sync failed", err);
1533
+ }
1534
+ return false;
1535
+ }
1536
+ }
1537
+ function loadSectionLabels2(sectionPath) {
1538
+ const labels = {};
1539
+ if (!existsSync2(sectionPath)) {
1540
+ return labels;
1541
+ }
1542
+ try {
1543
+ const entries = readdirSync2(sectionPath);
1544
+ for (const entry of entries) {
1545
+ const filePath = join2(sectionPath, entry);
1546
+ const stat = statSync2(filePath);
1547
+ if (stat.isFile() && extname2(entry) === ".json") {
1548
+ try {
1549
+ const content = readFileSync2(filePath, "utf-8");
1550
+ const data = JSON.parse(content);
1551
+ if (typeof data === "object" && data !== null) {
1552
+ Object.assign(labels, data);
1553
+ }
1554
+ } catch (error) {
1555
+ const err = error instanceof Error ? error : new Error(String(error));
1556
+ syncLogger.warn(`Failed to parse ${filePath}`, err);
1557
+ }
1558
+ }
1559
+ }
1560
+ } catch (error) {
1561
+ const err = error instanceof Error ? error : new Error(String(error));
1562
+ syncLogger.warn(`Failed to read section ${sectionPath}`, err);
1563
+ }
1564
+ return labels;
1565
+ }
1566
+
1567
+ // src/server.ts
1568
+ function replaceVariables(text6, replace) {
1569
+ return text6.replace(/\{(\w+)}/g, (match, key) => {
1570
+ const value = replace[key];
1571
+ return value !== void 0 ? String(value) : match;
1572
+ });
1573
+ }
1574
+ var getSection = cache(async (section, locale) => {
1575
+ const actualLocale = locale ?? await getLocale();
1576
+ try {
1577
+ const response = await client.call(
1578
+ getPublishedCacheContract,
1579
+ {
1580
+ query: { sections: section, locale: actualLocale }
1581
+ }
1582
+ );
1583
+ if ("error" in response) {
1584
+ console.warn(`[CMS] ${response.error}`);
1585
+ const sectionData2 = {
1586
+ section,
1587
+ locale: actualLocale,
1588
+ content: {},
1589
+ version: 0,
1590
+ publishedAt: null
1591
+ };
1592
+ const t2 = (_key, defaultValue) => defaultValue ?? "";
1593
+ return { t: t2, data: sectionData2 };
1594
+ }
1595
+ const found = response[0];
1596
+ if (!found) {
1597
+ const sectionData2 = {
1598
+ section,
1599
+ locale: actualLocale,
1600
+ content: {},
1601
+ version: 0,
1602
+ publishedAt: null
1603
+ };
1604
+ const t2 = (_key, defaultValue) => defaultValue ?? "";
1605
+ return { t: t2, data: sectionData2 };
1606
+ }
1607
+ const sectionData = {
1608
+ section: found.section,
1609
+ locale: found.locale,
1610
+ content: found.content || {},
1611
+ version: found.version,
1612
+ publishedAt: found.publishedAt
1613
+ };
1614
+ const t = (key, defaultValue, replace) => {
1615
+ const fullKey = `${section}.${key}`;
1616
+ let value = sectionData.content[fullKey];
1617
+ if (value === void 0 || value === null) {
1618
+ value = defaultValue ?? "";
1619
+ }
1620
+ if (typeof value === "object" && value !== null && value.type === "text" && "content" in value) {
1621
+ value = value.content;
1622
+ }
1623
+ if (typeof value === "string") {
1624
+ if (replace) {
1625
+ value = replaceVariables(value, replace);
1626
+ }
1627
+ return value;
1628
+ }
1629
+ return value;
1630
+ };
1631
+ return {
1632
+ t,
1633
+ data: sectionData
1634
+ };
1635
+ } catch (error) {
1636
+ console.error(`[CMS] Failed to fetch section "${section}":`, error);
1637
+ const sectionData = {
1638
+ section,
1639
+ locale: actualLocale,
1640
+ content: {},
1641
+ version: 0,
1642
+ publishedAt: null
1643
+ };
1644
+ const t = (_key, defaultValue) => defaultValue ?? "";
1645
+ return { t, data: sectionData };
1646
+ }
1647
+ });
1648
+ var getSections = cache(async (sections, locale) => {
1649
+ const actualLocale = locale ?? await getLocale();
1650
+ try {
1651
+ const response = await client.call(
1652
+ getPublishedCacheContract,
1653
+ {
1654
+ query: { sections, locale: actualLocale }
1655
+ }
1656
+ );
1657
+ if ("error" in response) {
1658
+ console.warn(`[CMS] ${response.error}`);
1659
+ const sectionsMap2 = {};
1660
+ sections.forEach((section) => {
1661
+ sectionsMap2[section] = {
1662
+ t: (_key, defaultValue) => defaultValue ?? "",
1663
+ data: {
130
1664
  section,
131
1665
  locale: actualLocale,
132
1666
  content: {},
133
1667
  version: 0,
134
- publishedAt: null,
1668
+ publishedAt: null
1669
+ }
135
1670
  };
136
- const t = (_key, defaultValue) => defaultValue ?? '';
137
- return { t, data: sectionData };
1671
+ });
1672
+ return sectionsMap2;
138
1673
  }
139
- });
140
- /**
141
- * 여러 섹션 한번에 로드 (React cache 적용)
142
- * 단일 API 호출로 여러 섹션을 효율적으로 가져옵니다
143
- *
144
- * @param sections - 섹션 이름 배열
145
- * @param locale - 언어 코드 (선택, 미지정시 쿠키에서 자동 조회)
146
- * @returns Section API 맵 ({ home: { t, data }, ... })
147
- *
148
- * @example
149
- * ```tsx
150
- * // Server Component
151
- * import { getSections } from '@spfn/cms/server';
152
- *
153
- * export default async function Page()
154
- * {
155
- * // locale을 지정하지 않으면 쿠키에서 자동으로 가져옴
156
- * const sections = await getSections(['home', 'why-futureplay']);
157
- *
158
- * // 또는 명시적으로 locale 지정
159
- * const sectionsEn = await getSections(['home', 'why-futureplay'], 'en');
160
- *
161
- * return (
162
- * <div>
163
- * <h1>{sections.home.t('hero.title')}</h1>
164
- * <p>{sections['why-futureplay'].t('intro.text')}</p>
165
- * </div>
166
- * );
167
- * }
168
- * ```
169
- */
170
- export const getSections = cache(async (sections, locale) => {
171
- // locale이 지정되지 않으면 쿠키에서 가져옴
172
- const actualLocale = locale ?? await getLocale();
173
- try {
174
- // Call SPFN API with array of sections (single HTTP request)
175
- const response = await client.call('/cms/published-cache', getPublishedCacheContract, {
176
- query: { sections, locale: actualLocale },
177
- });
178
- // Check if response has error
179
- if ('error' in response) {
180
- console.warn(`[CMS] ${response.error}`);
181
- // Return empty sections
182
- const sectionsMap = {};
183
- sections.forEach(section => {
184
- sectionsMap[section] = {
185
- t: (_key, defaultValue) => defaultValue ?? '',
186
- data: {
187
- section,
188
- locale: actualLocale,
189
- content: {},
190
- version: 0,
191
- publishedAt: null,
192
- }
193
- };
194
- });
195
- return sectionsMap;
1674
+ const sectionsMap = {};
1675
+ sections.forEach((section) => {
1676
+ sectionsMap[section] = {
1677
+ t: (_key, defaultValue) => defaultValue ?? "",
1678
+ data: {
1679
+ section,
1680
+ locale: actualLocale,
1681
+ content: {},
1682
+ version: 0,
1683
+ publishedAt: null
196
1684
  }
197
- // Build sections map from response
198
- const sectionsMap = {};
199
- // First, create empty entries for all requested sections
200
- sections.forEach(section => {
201
- sectionsMap[section] = {
202
- t: (_key, defaultValue) => defaultValue ?? '',
203
- data: {
204
- section,
205
- locale: actualLocale,
206
- content: {},
207
- version: 0,
208
- publishedAt: null,
209
- }
210
- };
211
- });
212
- // Then, fill in data for found sections
213
- response.forEach(sectionData => {
214
- const createTranslationFn = (section, content) => {
215
- return (key, defaultValue, replace) => {
216
- const fullKey = `${section}.${key}`;
217
- let value = content[fullKey];
218
- if (value === undefined || value === null) {
219
- value = defaultValue ?? '';
220
- }
221
- // 문자열이 아니면 빈 문자열 반환
222
- if (typeof value !== 'string') {
223
- return '';
224
- }
225
- // 문자열이고 치환 맵이 있으면 변수 치환
226
- if (replace) {
227
- value = replaceVariables(value, replace);
228
- }
229
- return value;
230
- };
231
- };
232
- sectionsMap[sectionData.section] = {
233
- t: createTranslationFn(sectionData.section, sectionData.content),
234
- data: {
235
- section: sectionData.section,
236
- locale: sectionData.locale,
237
- content: sectionData.content,
238
- version: sectionData.version,
239
- publishedAt: sectionData.publishedAt,
240
- }
241
- };
242
- });
243
- return sectionsMap;
244
- }
245
- catch (error) {
246
- console.error(`[CMS] Failed to fetch sections:`, error);
247
- // Return empty sections on error
248
- const sectionsMap = {};
249
- sections.forEach(section => {
250
- sectionsMap[section] = {
251
- t: (_key, defaultValue) => defaultValue ?? '',
252
- data: {
253
- section,
254
- locale: actualLocale,
255
- content: {},
256
- version: 0,
257
- publishedAt: null,
258
- }
259
- };
260
- });
261
- return sectionsMap;
262
- }
1685
+ };
1686
+ });
1687
+ response.forEach((sectionData) => {
1688
+ const createTranslationFn = (section, content) => {
1689
+ return (key, defaultValue, replace) => {
1690
+ const fullKey = `${section}.${key}`;
1691
+ let value = content[fullKey];
1692
+ if (value === void 0 || value === null) {
1693
+ value = defaultValue ?? "";
1694
+ }
1695
+ if (typeof value === "object" && value !== null && value.type === "text" && "content" in value) {
1696
+ value = value.content;
1697
+ }
1698
+ if (typeof value === "string") {
1699
+ if (replace) {
1700
+ value = replaceVariables(value, replace);
1701
+ }
1702
+ return value;
1703
+ }
1704
+ return value;
1705
+ };
1706
+ };
1707
+ sectionsMap[sectionData.section] = {
1708
+ t: createTranslationFn(sectionData.section, sectionData.content),
1709
+ data: {
1710
+ section: sectionData.section,
1711
+ locale: sectionData.locale,
1712
+ content: sectionData.content,
1713
+ version: sectionData.version,
1714
+ publishedAt: sectionData.publishedAt
1715
+ }
1716
+ };
1717
+ });
1718
+ return sectionsMap;
1719
+ } catch (error) {
1720
+ console.error(`[CMS] Failed to fetch sections:`, error);
1721
+ const sectionsMap = {};
1722
+ sections.forEach((section) => {
1723
+ sectionsMap[section] = {
1724
+ t: (_key, defaultValue) => defaultValue ?? "",
1725
+ data: {
1726
+ section,
1727
+ locale: actualLocale,
1728
+ content: {},
1729
+ version: 0,
1730
+ publishedAt: null
1731
+ }
1732
+ };
1733
+ });
1734
+ return sectionsMap;
1735
+ }
263
1736
  });
1737
+ export {
1738
+ LOCALE_COOKIE_KEY,
1739
+ LOCALE_INFO_MAP,
1740
+ cmsAuditLogs,
1741
+ cmsDraftCache,
1742
+ cmsDraftCacheRepository,
1743
+ cmsLabelValues,
1744
+ cmsLabelValuesRepository,
1745
+ cmsLabels,
1746
+ cmsLabelsRepository,
1747
+ cmsPublishedCache,
1748
+ cmsPublishedCacheRepository,
1749
+ createLabelSyncGenerator,
1750
+ extractLabels,
1751
+ flattenLabels,
1752
+ getDialCode,
1753
+ getFlag,
1754
+ getLocaleInfo,
1755
+ getSection,
1756
+ getSections,
1757
+ getSupportedLocales,
1758
+ initLabelSync,
1759
+ isRTL,
1760
+ loadLabelsFromJson,
1761
+ syncAll,
1762
+ syncSection
1763
+ };
264
1764
  //# sourceMappingURL=server.js.map