@spfn/cms 0.2.0-beta.2 → 0.2.0-beta.21
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.
- package/LICENSE +1 -1
- package/README.md +138 -5
- package/dist/actions.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +62 -1
- package/dist/index.js +2 -12
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +55 -0
- package/dist/server.js +249 -17
- package/dist/server.js.map +1 -1
- package/package.json +15 -10
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ pnpm spfn add @spfn/cms
|
|
|
26
26
|
```typescript
|
|
27
27
|
// labels.ts
|
|
28
28
|
import { defineLabelConfig, defineLabels, createCmsClient } from '@spfn/cms';
|
|
29
|
+
import { getLocale } from '@spfn/cms/actions';
|
|
29
30
|
|
|
30
31
|
// Configure locales
|
|
31
32
|
export const labelConfig = defineLabelConfig({
|
|
@@ -52,7 +53,11 @@ export const labelsDefinition = defineLabels({
|
|
|
52
53
|
// Create client with API, getLabel, getLabels, and format
|
|
53
54
|
export const { api, getLabel, getLabels, format } = createCmsClient(
|
|
54
55
|
labelsDefinition,
|
|
55
|
-
|
|
56
|
+
{
|
|
57
|
+
defaultLocale: labelConfig.defaultLocale,
|
|
58
|
+
fallbackLocale: labelConfig.fallbackLocale,
|
|
59
|
+
getLocale: () => getLocale(labelConfig.defaultLocale),
|
|
60
|
+
}
|
|
56
61
|
);
|
|
57
62
|
```
|
|
58
63
|
|
|
@@ -281,7 +286,11 @@ Labels synchronize automatically on server startup:
|
|
|
281
286
|
Factory function to create CMS client with API, getLabel, getLabels, and format utilities.
|
|
282
287
|
|
|
283
288
|
```typescript
|
|
284
|
-
const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition,
|
|
289
|
+
const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, {
|
|
290
|
+
defaultLocale: labelConfig.defaultLocale,
|
|
291
|
+
fallbackLocale: labelConfig.fallbackLocale,
|
|
292
|
+
getLocale: () => getLocale(labelConfig.defaultLocale),
|
|
293
|
+
});
|
|
285
294
|
```
|
|
286
295
|
|
|
287
296
|
**Returns:**
|
|
@@ -383,6 +392,133 @@ await syncLabels(labelsDefinition, {
|
|
|
383
392
|
|
|
384
393
|
**Returns:** `{ added, updated, removed, unchanged }`
|
|
385
394
|
|
|
395
|
+
## Admin API
|
|
396
|
+
|
|
397
|
+
CMS 라벨을 관리하기 위한 Admin API를 제공합니다. 섹션별 테이블 뷰로 라벨을 조회/수정/발행할 수 있습니다.
|
|
398
|
+
|
|
399
|
+
### Admin Routes
|
|
400
|
+
|
|
401
|
+
`cmsAppRouter`에 포함된 Admin 라우트들:
|
|
402
|
+
|
|
403
|
+
| Route | Method | Description |
|
|
404
|
+
|-------|--------|-------------|
|
|
405
|
+
| `getSectionLabels` | GET | 섹션의 모든 라벨 조회 (Draft/Published 상태 포함) |
|
|
406
|
+
| `saveSectionDraft` | PUT | 섹션 라벨 일괄 Draft 저장 |
|
|
407
|
+
| `publishSection` | POST | 섹션 전체 발행 (Draft → Published) |
|
|
408
|
+
| `resetSectionDraft` | DELETE | 섹션 Draft 초기화 |
|
|
409
|
+
|
|
410
|
+
### Admin UI 구현 예제
|
|
411
|
+
|
|
412
|
+
**1. API Client 설정:**
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
// labels.ts
|
|
416
|
+
import { createCmsClient } from '@spfn/cms';
|
|
417
|
+
|
|
418
|
+
export const { api: cmsApi } = createCmsClient(labelsDefinition, {
|
|
419
|
+
defaultLocale: labelConfig.defaultLocale,
|
|
420
|
+
fallbackLocale: labelConfig.fallbackLocale,
|
|
421
|
+
getLocale: () => getLocale(labelConfig.defaultLocale),
|
|
422
|
+
});
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
**2. 섹션 라벨 조회:**
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// 섹션의 모든 라벨을 Draft/Published 상태와 함께 조회
|
|
429
|
+
const data = await cmsApi.getSectionLabels.call({
|
|
430
|
+
params: { section: 'home' },
|
|
431
|
+
query: { locales: 'en,ko' }, // 콤마로 구분
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// 반환값
|
|
435
|
+
{
|
|
436
|
+
section: 'home',
|
|
437
|
+
locales: ['en', 'ko'],
|
|
438
|
+
labels: [
|
|
439
|
+
{
|
|
440
|
+
id: 1,
|
|
441
|
+
key: 'home.hero.title',
|
|
442
|
+
defaultValue: { en: 'Welcome', ko: '환영합니다' },
|
|
443
|
+
draft: { en: 'Welcome!', ko: '환영합니다!' } | null,
|
|
444
|
+
published: { en: 'Welcome', ko: '환영합니다' } | null,
|
|
445
|
+
hasDraft: true
|
|
446
|
+
},
|
|
447
|
+
// ...
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
**3. Draft 저장:**
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
// 수정된 라벨들을 Draft로 저장
|
|
456
|
+
await cmsApi.saveSectionDraft.call({
|
|
457
|
+
params: { section: 'home' },
|
|
458
|
+
body: {
|
|
459
|
+
labels: [
|
|
460
|
+
{ id: 1, values: { en: 'Welcome!', ko: '환영합니다!' } },
|
|
461
|
+
{ id: 2, values: { ko: '새로운 부제목' } }, // 특정 locale만 수정 가능
|
|
462
|
+
]
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**4. 섹션 발행:**
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
// Draft가 있는 모든 라벨을 Published로 발행
|
|
471
|
+
const result = await cmsApi.publishSection.call({
|
|
472
|
+
params: { section: 'home' },
|
|
473
|
+
body: { locales: ['en', 'ko'] },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// 반환값
|
|
477
|
+
{
|
|
478
|
+
published: 2, // 발행된 라벨 수
|
|
479
|
+
version: 3, // 최대 버전 번호
|
|
480
|
+
labels: ['home.hero.title', 'home.hero.subtitle'] // 발행된 라벨 키
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**5. Draft 초기화:**
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
// 섹션의 모든 Draft 삭제 (Published 값으로 복원)
|
|
488
|
+
await cmsApi.resetSectionDraft.call({
|
|
489
|
+
params: { section: 'home' },
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Workflow
|
|
494
|
+
|
|
495
|
+
```
|
|
496
|
+
┌─────────────┐ saveSectionDraft ┌─────────────┐
|
|
497
|
+
│ Default │ ──────────────────────► │ Draft │
|
|
498
|
+
│ (코드 정의) │ │ (version:null)│
|
|
499
|
+
└─────────────┘ └──────┬──────┘
|
|
500
|
+
│
|
|
501
|
+
publishSection │
|
|
502
|
+
◄───────────────────────────────┘
|
|
503
|
+
│
|
|
504
|
+
▼
|
|
505
|
+
┌─────────────┐
|
|
506
|
+
│ Published │
|
|
507
|
+
│ (version:N) │
|
|
508
|
+
└─────────────┘
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**상태 우선순위:** Draft > Published > Default
|
|
512
|
+
|
|
513
|
+
### 상태 표시
|
|
514
|
+
|
|
515
|
+
| 상태 | 의미 | UI 표시 예 |
|
|
516
|
+
|------|------|-----------|
|
|
517
|
+
| Default | 코드에 정의된 기본값만 존재 | 회색 |
|
|
518
|
+
| Draft | 저장되었으나 미발행 | 노란색 |
|
|
519
|
+
| Published | 발행되어 실제 서비스에 반영 | 초록색 |
|
|
520
|
+
| Edited | UI에서 수정 중 (미저장) | 파란색 |
|
|
521
|
+
|
|
386
522
|
## Architecture
|
|
387
523
|
|
|
388
524
|
### Database Schema
|
|
@@ -399,9 +535,6 @@ cms_label_values (actual content)
|
|
|
399
535
|
cms_published_cache (performance)
|
|
400
536
|
├─ section, locale, content (JSONB)
|
|
401
537
|
└─ version (for cache invalidation)
|
|
402
|
-
|
|
403
|
-
cms_audit_logs (tracking)
|
|
404
|
-
└─ action, userId, changes, metadata
|
|
405
538
|
```
|
|
406
539
|
|
|
407
540
|
### Query Flow
|
package/dist/actions.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/actions.ts"],"sourcesContent":["
|
|
1
|
+
{"version":3,"sources":["../src/actions.ts"],"sourcesContent":["'use server';\n\nimport { cookies } from 'next/headers';\n\nconst LOCALE_COOKIE_NAME = 'cms-locale';\nconst LOCALE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year\n\n/**\n * Set user's preferred locale in cookie\n *\n * @param locale - Language code (e.g., 'ko', 'en', 'ja')\n */\nexport async function setLocale(locale: string): Promise<void>\n{\n const cookieStore = await cookies();\n\n cookieStore.set(LOCALE_COOKIE_NAME, locale, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: LOCALE_MAX_AGE,\n path: '/',\n });\n}\n\n/**\n * Get user's preferred locale from cookie\n *\n * @param defaultLocale - Default locale from labelConfig.defaultLocale\n * @returns Language code (from cookie, or defaultLocale, or 'en')\n */\nexport async function getLocale(defaultLocale?: string): Promise<string>\n{\n const cookieStore = await cookies();\n const localeCookie = cookieStore.get(LOCALE_COOKIE_NAME);\n\n return localeCookie?.value ?? defaultLocale ?? 'en';\n}\n"],"mappings":";;;AAEA,SAAS,eAAe;AAExB,IAAM,qBAAqB;AAC3B,IAAM,iBAAiB,MAAM,KAAK,KAAK;AAOvC,eAAsB,UAAU,QAChC;AACI,QAAM,cAAc,MAAM,QAAQ;AAElC,cAAY,IAAI,oBAAoB,QAAQ;AAAA,IACxC,UAAU;AAAA,IACV,QAAQ,QAAQ,IAAI,aAAa;AAAA,IACjC,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,MAAM;AAAA,EACV,CAAC;AACL;AAQA,eAAsB,UAAU,eAChC;AACI,QAAM,cAAc,MAAM,QAAQ;AAClC,QAAM,eAAe,YAAY,IAAI,kBAAkB;AAEvD,SAAO,cAAc,SAAS,iBAAiB;AACnD;","names":[]}
|
package/dist/config.d.ts
CHANGED
package/dist/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/config/index.ts","../src/config/schema.ts"],"sourcesContent":["/**\n * Core Package Configuration\n *\n * @example\n * ```typescript\n * import { registry } from '@spfn/core/config';\n *\n * const env = registry.validate();\n * console.log(env.DB_POOL_MAX);\n * ```\n *\n * @module config\n */\n\nimport { createEnvRegistry } from '@spfn/core/env';\nimport { cmsEnvSchema } from './schema';\n\n/**\n * Environment registry\n */\nconst registry = createEnvRegistry(cmsEnvSchema);\nexport const env = registry.validate()
|
|
1
|
+
{"version":3,"sources":["../src/config/index.ts","../src/config/schema.ts"],"sourcesContent":["/**\n * Core Package Configuration\n *\n * @example\n * ```typescript\n * import { registry } from '@spfn/core/config';\n *\n * const env = registry.validate();\n * console.log(env.DB_POOL_MAX);\n * ```\n *\n * @module config\n */\n\nimport { createEnvRegistry } from '@spfn/core/env';\nimport { cmsEnvSchema } from './schema';\n\n/**\n * Environment registry\n */\nconst registry = createEnvRegistry(cmsEnvSchema);\nexport const env = registry.validate();\n","/**\n * CMS Environment Variable Schema\n *\n * Centralized schema definition for all environment variables used in @spfn/cms.\n * This provides type safety, validation, and documentation for CMS configuration.\n *\n * @module config/schema\n */\n\nimport {\n defineEnvSchema,\n envString,\n envBoolean,\n} from '@spfn/core/env';\n\n/**\n * CMS environment variable schema\n *\n * Defines all CMS environment variables with:\n * - Type information\n * - Default values\n * - Validation rules\n * - Documentation\n *\n * @example\n * ```typescript\n * import { cmsEnvSchema } from '@spfn/cms/config';\n *\n * // Access schema information\n * console.log(cmsEnvSchema.SPFN_CMS_DEFAULT_LOCALE.description);\n * console.log(cmsEnvSchema.SPFN_CMS_DEFAULT_LOCALE.default);\n * ```\n */\nexport const cmsEnvSchema = defineEnvSchema({\n // ============================================================================\n // Locale Configuration\n // ============================================================================\n SPFN_CMS_DEFAULT_LOCALE: {\n ...envString({\n description: '[DEPRECATED] Use labelConfig.defaultLocale instead. Default language for CMS content (ISO 639-1 language code)',\n default: 'en',\n required: false,\n category: 'cms',\n examples: ['en', 'ko', 'ja', 'zh', 'es', 'fr', 'de'],\n }),\n },\n\n SPFN_CMS_DETECT_BROWSER_LANGUAGE: {\n ...envBoolean({\n description: 'Automatically detect and use browser language for content localization',\n default: true,\n required: false,\n category: 'cms',\n }),\n },\n});\n"],"mappings":";AAcA,SAAS,yBAAyB;;;ACLlC;AAAA,EACI;AAAA,EACA;AAAA,EACA;AAAA,OACG;AAoBA,IAAM,eAAe,gBAAgB;AAAA;AAAA;AAAA;AAAA,EAIxC,yBAAyB;AAAA,IACrB,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU;AAAA,MACV,UAAU,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,IACvD,CAAC;AAAA,EACL;AAAA,EAEA,kCAAkC;AAAA,IAC9B,GAAG,WAAW;AAAA,MACV,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU;AAAA,IACd,CAAC;AAAA,EACL;AACJ,CAAC;;;ADnCD,IAAM,WAAW,kBAAkB,YAAY;AACxC,IAAM,MAAM,SAAS,SAAS;","names":[]}
|
package/dist/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/errors/cms-errors.ts"],"sourcesContent":["/**\n * CMS Error Classes\n *\n * Custom error classes for CMS-specific scenarios\n */\n\nimport {\n ValidationError,\n NotFoundError,\n ConflictError,\n ForbiddenError,\n InternalServerError
|
|
1
|
+
{"version":3,"sources":["../src/errors/cms-errors.ts"],"sourcesContent":["/**\n * CMS Error Classes\n *\n * Custom error classes for CMS-specific scenarios\n */\n\nimport {\n ValidationError,\n NotFoundError,\n ConflictError,\n ForbiddenError,\n InternalServerError,\n} from '@spfn/core/errors';\n\n/**\n * Label Not Found Error (404)\n *\n * Thrown when a CMS label does not exist\n */\nexport class LabelNotFoundError extends NotFoundError\n{\n constructor(data: { labelKey?: string; labelId?: number; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Label not found',\n details: {\n labelKey: data.labelKey,\n labelId: data.labelId,\n ...data.details,\n },\n });\n this.name = 'LabelNotFoundError';\n }\n}\n\n/**\n * Label Value Not Found Error (404)\n *\n * Thrown when a CMS label value does not exist\n */\nexport class LabelValueNotFoundError extends NotFoundError\n{\n constructor(data: { labelKey?: string; locale?: string; valueId?: number; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Label value not found',\n details: {\n labelKey: data.labelKey,\n locale: data.locale,\n valueId: data.valueId,\n ...data.details,\n },\n });\n this.name = 'LabelValueNotFoundError';\n }\n}\n\n/**\n * Locale Not Found Error (404)\n *\n * Thrown when requested locale does not exist in the system\n */\nexport class LocaleNotFoundError extends NotFoundError\n{\n constructor(data: { locale?: string; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Locale not found',\n details: {\n locale: data.locale,\n ...data.details,\n },\n });\n this.name = 'LocaleNotFoundError';\n }\n}\n\n/**\n * Published Cache Not Found Error (404)\n *\n * Thrown when published cache does not exist\n */\nexport class PublishedCacheNotFoundError extends NotFoundError\n{\n constructor(data: { locale?: string; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Published cache not found',\n details: {\n locale: data.locale,\n ...data.details,\n },\n });\n this.name = 'PublishedCacheNotFoundError';\n }\n}\n\n/**\n * Duplicate Label Error (409)\n *\n * Thrown when trying to create a label with existing key\n */\nexport class DuplicateLabelError extends ConflictError\n{\n constructor(data: { labelKey?: string; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Label already exists',\n details: {\n labelKey: data.labelKey,\n ...data.details,\n },\n });\n this.name = 'DuplicateLabelError';\n }\n}\n\n/**\n * Duplicate Label Value Error (409)\n *\n * Thrown when trying to create a label value that already exists\n */\nexport class DuplicateLabelValueError extends ConflictError\n{\n constructor(data: { labelKey?: string; locale?: string; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Label value already exists for this locale',\n details: {\n labelKey: data.labelKey,\n locale: data.locale,\n ...data.details,\n },\n });\n this.name = 'DuplicateLabelValueError';\n }\n}\n\n/**\n * Invalid Locale Error (400)\n *\n * Thrown when locale format is invalid or not supported\n */\nexport class InvalidLocaleError extends ValidationError\n{\n constructor(data: { locale?: string; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Invalid locale',\n details: {\n locale: data.locale,\n ...data.details,\n },\n });\n this.name = 'InvalidLocaleError';\n }\n}\n\n/**\n * Invalid Label Key Error (400)\n *\n * Thrown when label key format is invalid\n */\nexport class InvalidLabelKeyError extends ValidationError\n{\n constructor(data: { labelKey?: string; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Invalid label key format',\n details: {\n labelKey: data.labelKey,\n ...data.details,\n },\n });\n this.name = 'InvalidLabelKeyError';\n }\n}\n\n/**\n * Invalid Published Cache Error (400)\n *\n * Thrown when published cache data is malformed or corrupted\n */\nexport class InvalidPublishedCacheError extends ValidationError\n{\n constructor(data: { locale?: string; reason?: string; message?: string; details?: Record<string, any> } = {})\n {\n super({\n message: data.message || 'Invalid published cache data',\n details: {\n locale: data.locale,\n reason: data.reason,\n ...data.details,\n },\n });\n this.name = 'InvalidPublishedCacheError';\n }\n}\n\n/**\n * CMS Operation Failed Error (500)\n *\n * Thrown when CMS operation fails unexpectedly\n */\nexport class CMSOperationFailedError extends InternalServerError\n{\n constructor(data: { operation?: string; resource?: string; message?: string; details?: Record<string, any> } = {})\n {\n const operation = data.operation || 'operation';\n const resource = data.resource || 'resource';\n super({\n message: data.message || `Failed to ${operation} ${resource}`,\n details: {\n operation,\n resource,\n ...data.details,\n },\n });\n this.name = 'CMSOperationFailedError';\n }\n}\n\n/**\n * Insufficient CMS Permissions Error (403)\n *\n * Thrown when user lacks required CMS permissions\n */\nexport class InsufficientCMSPermissionsError extends ForbiddenError\n{\n constructor(data: { requiredPermissions?: string[]; resource?: string; message?: string; details?: Record<string, any> } = {})\n {\n const requiredPermissions = data.requiredPermissions || [];\n super({\n message: data.message || `Missing required CMS permissions: ${requiredPermissions.join(', ')}`,\n details: {\n requiredPermissions,\n resource: data.resource,\n ...data.details,\n },\n });\n this.name = 'InsufficientCMSPermissionsError';\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -102,7 +102,12 @@ declare function format(template: string, vars: Record<string, string | number>)
|
|
|
102
102
|
* @example
|
|
103
103
|
* ```typescript
|
|
104
104
|
* // labels.ts - Setup once
|
|
105
|
-
*
|
|
105
|
+
* import { getLocale } from '@spfn/cms/actions';
|
|
106
|
+
*
|
|
107
|
+
* export const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, {
|
|
108
|
+
* defaultLocale: 'ko',
|
|
109
|
+
* getLocale: () => getLocale('ko'),
|
|
110
|
+
* });
|
|
106
111
|
*
|
|
107
112
|
* // Single section - direct access
|
|
108
113
|
* const label = await getLabel('home');
|
|
@@ -121,6 +126,7 @@ declare function format(template: string, vars: Record<string, string | number>)
|
|
|
121
126
|
declare function createCmsClient<T>(labelsDefinition: T, config: {
|
|
122
127
|
defaultLocale: string;
|
|
123
128
|
fallbackLocale?: string;
|
|
129
|
+
getLocale: () => Promise<string>;
|
|
124
130
|
}): {
|
|
125
131
|
api: _spfn_core_nextjs.Client<_spfn_core_route.Router<{
|
|
126
132
|
getLabelCache: _spfn_core_route.RouteDef<{
|
|
@@ -129,6 +135,61 @@ declare function createCmsClient<T>(labelsDefinition: T, config: {
|
|
|
129
135
|
locale: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
130
136
|
}>;
|
|
131
137
|
}, {}, Record<string, any>>;
|
|
138
|
+
getSectionLabels: _spfn_core_route.RouteDef<{
|
|
139
|
+
params: _sinclair_typebox.TObject<{
|
|
140
|
+
section: _sinclair_typebox.TString;
|
|
141
|
+
}>;
|
|
142
|
+
query: _sinclair_typebox.TObject<{
|
|
143
|
+
locales: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
144
|
+
}>;
|
|
145
|
+
}, {}, {
|
|
146
|
+
section: string;
|
|
147
|
+
locales: string[];
|
|
148
|
+
labels: Array<{
|
|
149
|
+
id: number;
|
|
150
|
+
key: string;
|
|
151
|
+
description: string | null;
|
|
152
|
+
defaultValue: Record<string, string>;
|
|
153
|
+
draft: Record<string, string> | null;
|
|
154
|
+
published: Record<string, string> | null;
|
|
155
|
+
hasDraft: boolean;
|
|
156
|
+
}>;
|
|
157
|
+
}>;
|
|
158
|
+
saveSectionDraft: _spfn_core_route.RouteDef<{
|
|
159
|
+
params: _sinclair_typebox.TObject<{
|
|
160
|
+
section: _sinclair_typebox.TString;
|
|
161
|
+
}>;
|
|
162
|
+
body: _sinclair_typebox.TObject<{
|
|
163
|
+
labels: _sinclair_typebox.TArray<_sinclair_typebox.TObject<{
|
|
164
|
+
id: _sinclair_typebox.TNumber;
|
|
165
|
+
values: _sinclair_typebox.TRecord<_sinclair_typebox.TString, _sinclair_typebox.TString>;
|
|
166
|
+
}>>;
|
|
167
|
+
}>;
|
|
168
|
+
}, {}, {
|
|
169
|
+
updated: number;
|
|
170
|
+
success: boolean;
|
|
171
|
+
}>;
|
|
172
|
+
publishSection: _spfn_core_route.RouteDef<{
|
|
173
|
+
params: _sinclair_typebox.TObject<{
|
|
174
|
+
section: _sinclair_typebox.TString;
|
|
175
|
+
}>;
|
|
176
|
+
body: _sinclair_typebox.TObject<{
|
|
177
|
+
locales: _sinclair_typebox.TArray<_sinclair_typebox.TString>;
|
|
178
|
+
}>;
|
|
179
|
+
}, {}, {
|
|
180
|
+
published: number;
|
|
181
|
+
version: number;
|
|
182
|
+
labels: string[];
|
|
183
|
+
success: boolean;
|
|
184
|
+
}>;
|
|
185
|
+
resetSectionDraft: _spfn_core_route.RouteDef<{
|
|
186
|
+
params: _sinclair_typebox.TObject<{
|
|
187
|
+
section: _sinclair_typebox.TString;
|
|
188
|
+
}>;
|
|
189
|
+
}, {}, {
|
|
190
|
+
reset: number;
|
|
191
|
+
success: boolean;
|
|
192
|
+
}>;
|
|
132
193
|
}>>;
|
|
133
194
|
getLabel: <K extends SectionKeys<T>>(section: K) => Promise<BoundLabelSection<T, K>>;
|
|
134
195
|
getLabels: <K extends SectionKeys<T>>(sections: readonly K[]) => Promise<BoundLabelsSections<T, K>>;
|
package/dist/index.js
CHANGED
|
@@ -41,16 +41,6 @@ function createProxy(obj, locale, fallbackLocale) {
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// src/actions.ts
|
|
45
|
-
import { cookies } from "next/headers";
|
|
46
|
-
var LOCALE_COOKIE_NAME = "cms-locale";
|
|
47
|
-
var LOCALE_MAX_AGE = 365 * 24 * 60 * 60;
|
|
48
|
-
async function getLocale(defaultLocale) {
|
|
49
|
-
const cookieStore = await cookies();
|
|
50
|
-
const localeCookie = cookieStore.get(LOCALE_COOKIE_NAME);
|
|
51
|
-
return localeCookie?.value ?? defaultLocale ?? "en";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
44
|
// src/lib/helpers.ts
|
|
55
45
|
function setNestedValue(target, path, value) {
|
|
56
46
|
const parts = path.split(".");
|
|
@@ -85,7 +75,7 @@ var cmsLogger = logger.child("@spfn/cms");
|
|
|
85
75
|
var api = createApi();
|
|
86
76
|
function createCmsClient(labelsDefinition, config) {
|
|
87
77
|
async function getLabel(section) {
|
|
88
|
-
const locale = await getLocale(
|
|
78
|
+
const locale = await config.getLocale();
|
|
89
79
|
cmsLogger.debug("getLabel called", {
|
|
90
80
|
section,
|
|
91
81
|
locale,
|
|
@@ -107,7 +97,7 @@ function createCmsClient(labelsDefinition, config) {
|
|
|
107
97
|
return merged[section];
|
|
108
98
|
}
|
|
109
99
|
async function getLabels(sections) {
|
|
110
|
-
const locale = await getLocale(
|
|
100
|
+
const locale = await config.getLocale();
|
|
111
101
|
cmsLogger.debug("getLabels called", {
|
|
112
102
|
sections,
|
|
113
103
|
locale,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/lib/bind-locale.ts","../src/actions.ts","../src/lib/helpers.ts","../src/lib/define-labels.ts"],"sourcesContent":["import { createApi } from \"@spfn/core/nextjs\";\nimport { logger } from \"@spfn/core/logger\";\nimport { type AppRouter } from './server/routes/index';\nimport { bindLocale, type SectionKeys, type BoundLabelSection, type BoundLabelsSections } from './lib/bind-locale';\nimport { getLocale } from './actions';\nimport { setNestedValue } from './lib/helpers';\nimport { format, defineLabelConfig, defineLabels } from './lib/define-labels';\n\nconst cmsLogger = logger.child('@spfn/cms');\n\n/**\n * Default API client (for backward compatibility or when not using labels)\n */\nconst api = createApi<AppRouter>();\n\n/**\n * Create CMS client with API, label getters, and format utility\n *\n * @param labelsDefinition - Labels defined using defineLabels()\n * @param config - Label config from defineLabelConfig()\n * @returns API client, getLabel (single), getLabels (multiple), and format utility\n *\n * @example\n * ```typescript\n * // labels.ts - Setup once\n * export const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, labelConfig);\n *\n * // Single section - direct access\n * const label = await getLabel('home');\n * label.hero.title // \"Hello\" (no section name!)\n *\n * // Multiple sections - with section names\n * const labels = await getLabels(['home', 'about']);\n * labels.home.hero.title // \"Hello\"\n * labels.about.title // \"About Us\"\n *\n * // With template variables\n * const greeting = label.hero.greeting; // \"Hello {name}\"\n * format(greeting, { name: \"John\" }); // \"Hello John\"\n * ```\n */\nexport function createCmsClient<T>(\n labelsDefinition: T,\n config: { defaultLocale: string; fallbackLocale?: string }\n)\n{\n /**\n * Get a single section's labels (without section name wrapper)\n *\n * @param section - Section name to fetch\n * @returns Labels for the section, directly accessible\n *\n * @example\n * ```typescript\n * const label = await getLabel('signup');\n * label.title // Direct access\n * label.userName\n * ```\n */\n async function getLabel<K extends SectionKeys<T>>(section: K): Promise<BoundLabelSection<T, K>>\n {\n // Auto-detect locale from cookie, fallback to config.defaultLocale\n const locale = await getLocale(config.defaultLocale);\n\n cmsLogger.debug('getLabel called', {\n section,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [section as string],\n locale\n }\n });\n\n // 2. Filter only requested section\n const filteredLabels: any = {};\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n\n // 3. Generate defaults with locale binding\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n // 5. Return only the section content (without section name)\n return merged[section] as BoundLabelSection<T, K>;\n }\n\n /**\n * Get multiple sections' labels (with section names as keys)\n *\n * @param sections - Array of section names to fetch\n * @returns Object with section names as keys\n *\n * @example\n * ```typescript\n * const labels = await getLabels(['home', 'about']);\n * labels.home.title\n * labels.about.description\n * ```\n */\n async function getLabels<K extends SectionKeys<T>>(sections: readonly K[]): Promise<BoundLabelsSections<T, K>>\n {\n // Auto-detect locale from cookie, fallback to config.defaultLocale\n const locale = await getLocale(config.defaultLocale);\n\n cmsLogger.debug('getLabels called', {\n sections,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n availableDefinitionKeys: Object.keys(labelsDefinition as any),\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [...sections] as unknown as string[],\n locale\n }\n });\n\n cmsLogger.debug('Fetched from cache', {\n cacheKeys: Object.keys(cache),\n cacheEntryCount: Object.keys(cache).length,\n cacheStructure: Object.entries(cache).map(([key, value]) => ({\n section: key,\n isObject: typeof value === 'object',\n isNull: value === null,\n contentKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 2. Filter only requested sections (performance optimization)\n const filteredLabels: any = {};\n for (const section of sections)\n {\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n }\n\n cmsLogger.debug('Filtered sections', {\n requestedSections: sections,\n filteredSections: Object.keys(filteredLabels),\n filteredLabelsStructure: Object.entries(filteredLabels).map(([key, value]) => ({\n section: key,\n hasValue: !!value,\n isObject: typeof value === 'object',\n nestedKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 3. Generate defaults with locale binding (only for requested sections)\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n cmsLogger.debug('Generated defaults with locale binding', {\n defaultsKeys: Object.keys(defaults),\n });\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n cmsLogger.debug('Merged cache and defaults', {\n mergedKeys: Object.keys(merged),\n });\n\n return merged as BoundLabelsSections<T, K>;\n }\n\n return { api, getLabel, getLabels, format };\n}\n\n/**\n * Deep merge cache into defaults\n */\nfunction deepMergeCache(defaults: any, cache: Record<string, any>, locale: string): any\n{\n const result = { ...defaults };\n\n cmsLogger.debug('deepMergeCache: Starting merge', {\n cacheEntries: Object.keys(cache).length,\n locale,\n });\n\n for (const [section, content] of Object.entries(cache))\n {\n if (!content || typeof content !== 'object')\n {\n cmsLogger.debug('deepMergeCache: Skipping invalid content', { section });\n continue;\n }\n\n const contentKeys = Object.keys(content);\n cmsLogger.debug('deepMergeCache: Processing section', {\n section,\n labelCount: contentKeys.length,\n });\n\n for (const [flatKey, value] of Object.entries(content))\n {\n // Extract locale-specific value from LabelValue format\n let extractedValue: any;\n\n if (value && typeof value === 'object' && 'content' in value)\n {\n extractedValue = (value as any).content;\n cmsLogger.debug('deepMergeCache: Extracted from content field', {\n flatKey,\n hasContent: true,\n });\n }\n else if (value && typeof value === 'object' && locale in value)\n {\n extractedValue = (value as any)[locale];\n cmsLogger.debug('deepMergeCache: Extracted from locale field', {\n flatKey,\n locale,\n });\n }\n else\n {\n extractedValue = value;\n cmsLogger.debug('deepMergeCache: Using raw value', {\n flatKey,\n valueType: typeof value,\n });\n }\n\n // Set value using helper function\n setNestedValue(result, flatKey, extractedValue);\n }\n }\n\n cmsLogger.debug('deepMergeCache: Merge completed', {\n resultKeys: Object.keys(result),\n });\n\n return result;\n}\n\n/**\n * Re-export format utility for standalone use\n *\n * @example\n * ```typescript\n * import { format } from '@spfn/cms/api-client';\n *\n * const text = \"Hello {name}, you have {count} messages\";\n * format(text, { name: \"John\", count: 5 });\n * // \"Hello John, you have 5 messages\"\n * ```\n */\nexport { format, defineLabelConfig, defineLabels };\n\n/**\n * Re-export types for external use\n */\nexport type { BoundLabels, SectionKeys, BoundLabelSection, BoundLabelsSections } from './lib/bind-locale';","/**\n * Bind locale to labels, returning locale-specific values\n *\n * @example\n * ```ts\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * ```\n */\n\n/**\n * Type that converts locale records to strings\n */\nexport type BoundLabels<T> = {\n [K in keyof T]: T[K] extends Record<string, any>\n ? IsLocaleRecord<T[K]> extends true\n ? string\n : BoundLabels<T[K]>\n : T[K];\n};\n\n/**\n * Extract section keys from label definition\n */\nexport type SectionKeys<T> = Extract<keyof T, string>;\n\n/**\n * Get content of a single section (without section name wrapper)\n */\nexport type BoundLabelSection<T, K extends SectionKeys<T>> = BoundLabels<T>[K];\n\n/**\n * Pick specific sections from bound labels (for multiple sections)\n */\nexport type BoundLabelsSections<T, K extends SectionKeys<T>> = Pick<BoundLabels<T>, K>;\n\n/**\n * Check if object is a locale record (has string values only)\n */\ntype IsLocaleRecord<T> = T extends Record<string, string> ? true : false;\n\n/**\n * Check if an object is a locale record at runtime\n */\nfunction isLocaleRecord(obj: any): boolean\n{\n if (!obj || typeof obj !== 'object')\n {\n return false;\n }\n\n const values = Object.values(obj);\n\n // Empty object is not a locale record\n if (values.length === 0)\n {\n return false;\n }\n\n // All values must be strings\n return values.every(v => typeof v === 'string');\n}\n\n/**\n * Bind a locale to label definitions, returning locale-specific values\n *\n * @param labels - Label definitions with locale records\n * @param locale - Locale to bind (e.g., 'en', 'ko')\n * @param fallbackLocale - Optional fallback locale if value not found\n * @returns Labels with locale-specific string values\n *\n * @example\n * ```typescript\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" },\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * labels.home.hero.title // \"환영합니다\"\n *\n * // With fallback\n * const labelsEn = bindLocale(labelsDefinition, 'en', 'ko');\n * ```\n */\nexport function bindLocale<T>(\n labels: T,\n locale: string,\n fallbackLocale?: string\n): BoundLabels<T>\n{\n return createProxy(labels, locale, fallbackLocale) as BoundLabels<T>;\n}\n\n/**\n * Create a proxy that intercepts property access and returns locale-specific values\n */\nfunction createProxy(obj: any, locale: string, fallbackLocale?: string): any\n{\n return new Proxy(obj, {\n get(target, prop)\n {\n const value = target[prop];\n\n // If value doesn't exist, return undefined\n if (value === undefined)\n {\n return undefined;\n }\n\n // If this is a locale record, return the locale value\n if (isLocaleRecord(value))\n {\n // Try to get the requested locale\n if (value[locale] !== undefined)\n {\n return value[locale];\n }\n\n // Fallback to fallbackLocale if specified\n if (fallbackLocale && value[fallbackLocale] !== undefined)\n {\n return value[fallbackLocale];\n }\n\n // If locale not found, return first available locale\n const firstLocale = Object.keys(value)[0];\n return value[firstLocale];\n }\n\n // If this is a nested object, wrap it in a proxy\n if (typeof value === 'object' && value !== null)\n {\n return createProxy(value, locale, fallbackLocale);\n }\n\n // Otherwise return as-is\n return value;\n },\n });\n}","\"use server\"\n\nimport { cookies } from 'next/headers';\n\nconst LOCALE_COOKIE_NAME = 'cms-locale';\nconst LOCALE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year\n\n/**\n * Set user's preferred locale in cookie\n *\n * @param locale - Language code (e.g., 'ko', 'en', 'ja')\n */\nexport async function setLocale(locale: string): Promise<void>\n{\n const cookieStore = await cookies();\n\n cookieStore.set(LOCALE_COOKIE_NAME, locale, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: LOCALE_MAX_AGE,\n path: '/',\n });\n}\n\n/**\n * Get user's preferred locale from cookie\n *\n * @param defaultLocale - Default locale from labelConfig.defaultLocale\n * @returns Language code (from cookie, or defaultLocale, or 'en')\n */\nexport async function getLocale(defaultLocale?: string): Promise<string>\n{\n const cookieStore = await cookies();\n const localeCookie = cookieStore.get(LOCALE_COOKIE_NAME);\n\n return localeCookie?.value ?? defaultLocale ?? 'en';\n}","/**\n * CMS Helper Functions\n */\n\nexport type FlatLabel = Record<string, Record<string, string>>;\n\n/**\n * Flatten nested label structure into dot notation\n *\n * @param labels - Nested label object\n * @param prefix - Key prefix for recursion\n * @returns Flattened label structure\n *\n * @example\n * ```typescript\n * const nested = {\n * home: {\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * };\n *\n * const flat = flattenLabels(nested);\n * // { \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" } }\n * ```\n */\nexport function flattenLabels<T extends Record<string, any>>(labels: T, prefix = ''): FlatLabel\n{\n const result: FlatLabel = {};\n\n if (!labels || typeof labels !== 'object')\n {\n return result;\n }\n\n const obj = labels as Record<string, unknown>;\n\n for (const [key, value] of Object.entries(obj))\n {\n const newKey = prefix ? `${prefix}.${key}` : key;\n\n if (!value || typeof value !== 'object')\n {\n continue;\n }\n\n const valueObj = value as Record<string, unknown>;\n\n // Check if this is a leaf node (locale values: { en: \"...\", ko: \"...\" })\n const isLeaf = Object.values(valueObj).every(v => typeof v === 'string');\n\n if (isLeaf)\n {\n result[newKey] = valueObj as Record<string, string>;\n }\n else\n {\n // Recursively flatten nested structure\n Object.assign(result, flattenLabels(value, newKey));\n }\n }\n\n return result;\n}\n\n/**\n * Set a value in nested object using dot notation path\n *\n * @param target - Target object to modify\n * @param path - Dot notation path (e.g., \"home.hero.title\")\n * @param value - Value to set\n *\n * @example\n * ```typescript\n * const obj = {};\n * setNestedValue(obj, \"home.hero.title\", \"Welcome\");\n * // obj = { home: { hero: { title: \"Welcome\" } } }\n * ```\n */\nexport function setNestedValue(target: any, path: string, value: any): void\n{\n const parts = path.split('.');\n let current = target;\n\n for (let i = 0; i < parts.length - 1; i++)\n {\n const part = parts[i];\n if (!current[part])\n {\n current[part] = {};\n }\n current = current[part];\n }\n\n // Set the leaf value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Unflatten dot notation keys back to nested structure\n *\n * @param flat - Flattened label object\n * @returns Nested label structure\n *\n * @example\n * ```typescript\n * const flat = {\n * \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" },\n * \"home.hero.subtitle\": { en: \"Subtitle\", ko: \"부제목\" }\n * };\n *\n * const nested = unflattenLabels(flat);\n * // {\n * // home: {\n * // hero: {\n * // title: { en: \"Welcome\", ko: \"환영합니다\" },\n * // subtitle: { en: \"Subtitle\", ko: \"부제목\" }\n * // }\n * // }\n * // }\n * ```\n */\nexport function unflattenLabels(flat: FlatLabel): Record<string, any>\n{\n const result: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(flat))\n {\n setNestedValue(result, key, value);\n }\n\n return result;\n}","/**\n * Defines a type-safe label configuration.\n *\n * @example\n * ```ts\n * export const labelConfig = defineLabelConfig({\n * locales: ['en', 'ar'] as const,\n * defaultLocale: 'en',\n * fallbackLocale: 'en', // Optional\n * });\n *\n * export type LabelConfig = typeof labelConfig;\n * export type AppLocale = typeof labelConfig.locales[number]; // 'en' | 'ar'\n * ```\n */\nexport function defineLabelConfig<const TLocales extends readonly string[]>(config: {\n locales: TLocales;\n defaultLocale: TLocales[number];\n fallbackLocale?: TLocales[number];\n useBrowserLanguage?: boolean;\n})\n{\n return config;\n}\n\n/**\n * Define nested label structure (tRPC-style)\n *\n * @example\n * ```ts\n * export const labels = defineLabels({\n * home: {\n * slogan: { en: \"Welcome\", ko: \"환영합니다\" },\n * hero: {\n * title: { en: \"Hello\", ko: \"안녕하세요\" }\n * }\n * },\n * about: {\n * title: { en: \"About Us\", ko: \"회사 소개\" }\n * }\n * });\n *\n * // Usage\n * labels.home.slogan;\n * labels.home.hero.title;\n * labels.about.title;\n * ```\n */\nexport function defineLabels<const T>(labels: T)\n{\n return labels;\n}\n\nexport function format(template: string, vars: Record<string, string | number>): string\n{\n return template.replace(/\\{(\\w+)}/g, (match, key) =>\n {\n const value = vars[key];\n return value !== undefined ? String(value) : match;\n });\n}"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/lib/bind-locale.ts","../src/lib/helpers.ts","../src/lib/define-labels.ts"],"sourcesContent":["import { createApi } from '@spfn/core/nextjs';\nimport { logger } from '@spfn/core/logger';\nimport { type AppRouter } from './server/routes/index';\nimport { bindLocale, type SectionKeys, type BoundLabelSection, type BoundLabelsSections } from './lib/bind-locale';\nimport { setNestedValue } from './lib/helpers';\nimport { format, defineLabelConfig, defineLabels } from './lib/define-labels';\n\nconst cmsLogger = logger.child('@spfn/cms');\n\n/**\n * Default API client (for backward compatibility or when not using labels)\n */\nconst api = createApi<AppRouter>();\n\n/**\n * Create CMS client with API, label getters, and format utility\n *\n * @param labelsDefinition - Labels defined using defineLabels()\n * @param config - Label config from defineLabelConfig()\n * @returns API client, getLabel (single), getLabels (multiple), and format utility\n *\n * @example\n * ```typescript\n * // labels.ts - Setup once\n * import { getLocale } from '@spfn/cms/actions';\n *\n * export const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, {\n * defaultLocale: 'ko',\n * getLocale: () => getLocale('ko'),\n * });\n *\n * // Single section - direct access\n * const label = await getLabel('home');\n * label.hero.title // \"Hello\" (no section name!)\n *\n * // Multiple sections - with section names\n * const labels = await getLabels(['home', 'about']);\n * labels.home.hero.title // \"Hello\"\n * labels.about.title // \"About Us\"\n *\n * // With template variables\n * const greeting = label.hero.greeting; // \"Hello {name}\"\n * format(greeting, { name: \"John\" }); // \"Hello John\"\n * ```\n */\nexport function createCmsClient<T>(\n labelsDefinition: T,\n config: {\n defaultLocale: string;\n fallbackLocale?: string;\n getLocale: () => Promise<string>;\n },\n)\n{\n /**\n * Get a single section's labels (without section name wrapper)\n *\n * @param section - Section name to fetch\n * @returns Labels for the section, directly accessible\n *\n * @example\n * ```typescript\n * const label = await getLabel('signup');\n * label.title // Direct access\n * label.userName\n * ```\n */\n async function getLabel<K extends SectionKeys<T>>(section: K): Promise<BoundLabelSection<T, K>>\n {\n // Auto-detect locale from cookie via injected getLocale\n const locale = await config.getLocale();\n\n cmsLogger.debug('getLabel called', {\n section,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [section as string],\n locale,\n },\n });\n\n // 2. Filter only requested section\n const filteredLabels: any = {};\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n\n // 3. Generate defaults with locale binding\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n // 5. Return only the section content (without section name)\n return merged[section] as BoundLabelSection<T, K>;\n }\n\n /**\n * Get multiple sections' labels (with section names as keys)\n *\n * @param sections - Array of section names to fetch\n * @returns Object with section names as keys\n *\n * @example\n * ```typescript\n * const labels = await getLabels(['home', 'about']);\n * labels.home.title\n * labels.about.description\n * ```\n */\n async function getLabels<K extends SectionKeys<T>>(sections: readonly K[]): Promise<BoundLabelsSections<T, K>>\n {\n // Auto-detect locale from cookie via injected getLocale\n const locale = await config.getLocale();\n\n cmsLogger.debug('getLabels called', {\n sections,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n availableDefinitionKeys: Object.keys(labelsDefinition as any),\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [...sections] as unknown as string[],\n locale,\n },\n });\n\n cmsLogger.debug('Fetched from cache', {\n cacheKeys: Object.keys(cache),\n cacheEntryCount: Object.keys(cache).length,\n cacheStructure: Object.entries(cache).map(([key, value]) => ({\n section: key,\n isObject: typeof value === 'object',\n isNull: value === null,\n contentKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 2. Filter only requested sections (performance optimization)\n const filteredLabels: any = {};\n for (const section of sections)\n {\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n }\n\n cmsLogger.debug('Filtered sections', {\n requestedSections: sections,\n filteredSections: Object.keys(filteredLabels),\n filteredLabelsStructure: Object.entries(filteredLabels).map(([key, value]) => ({\n section: key,\n hasValue: !!value,\n isObject: typeof value === 'object',\n nestedKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 3. Generate defaults with locale binding (only for requested sections)\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n cmsLogger.debug('Generated defaults with locale binding', {\n defaultsKeys: Object.keys(defaults),\n });\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n cmsLogger.debug('Merged cache and defaults', {\n mergedKeys: Object.keys(merged),\n });\n\n return merged as BoundLabelsSections<T, K>;\n }\n\n return { api, getLabel, getLabels, format };\n}\n\n/**\n * Deep merge cache into defaults\n */\nfunction deepMergeCache(defaults: any, cache: Record<string, any>, locale: string): any\n{\n const result = { ...defaults };\n\n cmsLogger.debug('deepMergeCache: Starting merge', {\n cacheEntries: Object.keys(cache).length,\n locale,\n });\n\n for (const [section, content] of Object.entries(cache))\n {\n if (!content || typeof content !== 'object')\n {\n cmsLogger.debug('deepMergeCache: Skipping invalid content', { section });\n continue;\n }\n\n const contentKeys = Object.keys(content);\n cmsLogger.debug('deepMergeCache: Processing section', {\n section,\n labelCount: contentKeys.length,\n });\n\n for (const [flatKey, value] of Object.entries(content))\n {\n // Extract locale-specific value from LabelValue format\n let extractedValue: any;\n\n if (value && typeof value === 'object' && 'content' in value)\n {\n extractedValue = (value as any).content;\n cmsLogger.debug('deepMergeCache: Extracted from content field', {\n flatKey,\n hasContent: true,\n });\n }\n else if (value && typeof value === 'object' && locale in value)\n {\n extractedValue = (value as any)[locale];\n cmsLogger.debug('deepMergeCache: Extracted from locale field', {\n flatKey,\n locale,\n });\n }\n else\n {\n extractedValue = value;\n cmsLogger.debug('deepMergeCache: Using raw value', {\n flatKey,\n valueType: typeof value,\n });\n }\n\n // Set value using helper function\n setNestedValue(result, flatKey, extractedValue);\n }\n }\n\n cmsLogger.debug('deepMergeCache: Merge completed', {\n resultKeys: Object.keys(result),\n });\n\n return result;\n}\n\n/**\n * Re-export format utility for standalone use\n *\n * @example\n * ```typescript\n * import { format } from '@spfn/cms/api-client';\n *\n * const text = \"Hello {name}, you have {count} messages\";\n * format(text, { name: \"John\", count: 5 });\n * // \"Hello John, you have 5 messages\"\n * ```\n */\nexport { format, defineLabelConfig, defineLabels };\n\n/**\n * Re-export types for external use\n */\nexport type { BoundLabels, SectionKeys, BoundLabelSection, BoundLabelsSections } from './lib/bind-locale';\n","/**\n * Bind locale to labels, returning locale-specific values\n *\n * @example\n * ```ts\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * ```\n */\n\n/**\n * Type that converts locale records to strings\n */\nexport type BoundLabels<T> = {\n [K in keyof T]: T[K] extends Record<string, any>\n ? IsLocaleRecord<T[K]> extends true\n ? string\n : BoundLabels<T[K]>\n : T[K];\n};\n\n/**\n * Extract section keys from label definition\n */\nexport type SectionKeys<T> = Extract<keyof T, string>;\n\n/**\n * Get content of a single section (without section name wrapper)\n */\nexport type BoundLabelSection<T, K extends SectionKeys<T>> = BoundLabels<T>[K];\n\n/**\n * Pick specific sections from bound labels (for multiple sections)\n */\nexport type BoundLabelsSections<T, K extends SectionKeys<T>> = Pick<BoundLabels<T>, K>;\n\n/**\n * Check if object is a locale record (has string values only)\n */\ntype IsLocaleRecord<T> = T extends Record<string, string> ? true : false;\n\n/**\n * Check if an object is a locale record at runtime\n */\nfunction isLocaleRecord(obj: any): boolean\n{\n if (!obj || typeof obj !== 'object')\n {\n return false;\n }\n\n const values = Object.values(obj);\n\n // Empty object is not a locale record\n if (values.length === 0)\n {\n return false;\n }\n\n // All values must be strings\n return values.every(v => typeof v === 'string');\n}\n\n/**\n * Bind a locale to label definitions, returning locale-specific values\n *\n * @param labels - Label definitions with locale records\n * @param locale - Locale to bind (e.g., 'en', 'ko')\n * @param fallbackLocale - Optional fallback locale if value not found\n * @returns Labels with locale-specific string values\n *\n * @example\n * ```typescript\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" },\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * labels.home.hero.title // \"환영합니다\"\n *\n * // With fallback\n * const labelsEn = bindLocale(labelsDefinition, 'en', 'ko');\n * ```\n */\nexport function bindLocale<T>(\n labels: T,\n locale: string,\n fallbackLocale?: string,\n): BoundLabels<T>\n{\n return createProxy(labels, locale, fallbackLocale) as BoundLabels<T>;\n}\n\n/**\n * Create a proxy that intercepts property access and returns locale-specific values\n */\nfunction createProxy(obj: any, locale: string, fallbackLocale?: string): any\n{\n return new Proxy(obj, {\n get(target, prop)\n {\n const value = target[prop];\n\n // If value doesn't exist, return undefined\n if (value === undefined)\n {\n return undefined;\n }\n\n // If this is a locale record, return the locale value\n if (isLocaleRecord(value))\n {\n // Try to get the requested locale\n if (value[locale] !== undefined)\n {\n return value[locale];\n }\n\n // Fallback to fallbackLocale if specified\n if (fallbackLocale && value[fallbackLocale] !== undefined)\n {\n return value[fallbackLocale];\n }\n\n // If locale not found, return first available locale\n const firstLocale = Object.keys(value)[0];\n\n return value[firstLocale];\n }\n\n // If this is a nested object, wrap it in a proxy\n if (typeof value === 'object' && value !== null)\n {\n return createProxy(value, locale, fallbackLocale);\n }\n\n // Otherwise return as-is\n return value;\n },\n });\n}\n","/**\n * CMS Helper Functions\n */\n\nexport type FlatLabel = Record<string, Record<string, string>>;\n\nexport type FlatLabelItem = {\n values: Record<string, string>;\n description?: string;\n};\n\nexport type FlatLabelWithDescription = Record<string, FlatLabelItem>;\n\n/**\n * Flatten nested label structure into dot notation\n *\n * @param labels - Nested label object\n * @param prefix - Key prefix for recursion\n * @returns Flattened label structure with description\n *\n * @example\n * ```typescript\n * const nested = {\n * home: {\n * hero: {\n * title: {\n * en: \"Welcome\",\n * ko: \"환영합니다\",\n * $description: \"Main hero title\"\n * }\n * }\n * }\n * };\n *\n * const flat = flattenLabels(nested);\n * // {\n * // \"home.hero.title\": {\n * // values: { en: \"Welcome\", ko: \"환영합니다\" },\n * // description: \"Main hero title\"\n * // }\n * // }\n * ```\n */\nexport function flattenLabels<T extends Record<string, any>>(\n labels: T,\n prefix = '',\n): FlatLabelWithDescription\n{\n const result: FlatLabelWithDescription = {};\n\n if (!labels || typeof labels !== 'object')\n {\n return result;\n }\n\n const obj = labels as Record<string, unknown>;\n\n for (const [key, value] of Object.entries(obj))\n {\n const newKey = prefix ? `${prefix}.${key}` : key;\n\n if (!value || typeof value !== 'object')\n {\n continue;\n }\n\n const valueObj = value as Record<string, unknown>;\n\n // Check if this is a leaf node (locale values: { en: \"...\", ko: \"...\", $description?: \"...\" })\n // All values must be strings (including $description)\n const isLeaf = Object.values(valueObj).every(v => typeof v === 'string');\n\n if (isLeaf)\n {\n const { $description, ...localeValues } = valueObj as Record<string, string>;\n\n result[newKey] = {\n values: localeValues,\n description: $description,\n };\n }\n else\n {\n // Recursively flatten nested structure\n Object.assign(result, flattenLabels(value, newKey));\n }\n }\n\n return result;\n}\n\n/**\n * Set a value in nested object using dot notation path\n *\n * @param target - Target object to modify\n * @param path - Dot notation path (e.g., \"home.hero.title\")\n * @param value - Value to set\n *\n * @example\n * ```typescript\n * const obj = {};\n * setNestedValue(obj, \"home.hero.title\", \"Welcome\");\n * // obj = { home: { hero: { title: \"Welcome\" } } }\n * ```\n */\nexport function setNestedValue(target: any, path: string, value: any): void\n{\n const parts = path.split('.');\n let current = target;\n\n for (let i = 0; i < parts.length - 1; i++)\n {\n const part = parts[i];\n if (!current[part])\n {\n current[part] = {};\n }\n current = current[part];\n }\n\n // Set the leaf value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Unflatten dot notation keys back to nested structure\n *\n * @param flat - Flattened label object\n * @returns Nested label structure\n *\n * @example\n * ```typescript\n * const flat = {\n * \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" },\n * \"home.hero.subtitle\": { en: \"Subtitle\", ko: \"부제목\" }\n * };\n *\n * const nested = unflattenLabels(flat);\n * // {\n * // home: {\n * // hero: {\n * // title: { en: \"Welcome\", ko: \"환영합니다\" },\n * // subtitle: { en: \"Subtitle\", ko: \"부제목\" }\n * // }\n * // }\n * // }\n * ```\n */\nexport function unflattenLabels(flat: FlatLabel): Record<string, any>\n{\n const result: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(flat))\n {\n setNestedValue(result, key, value);\n }\n\n return result;\n}\n","/**\n * Defines a type-safe label configuration.\n *\n * @example\n * ```ts\n * export const labelConfig = defineLabelConfig({\n * locales: ['en', 'ar'] as const,\n * defaultLocale: 'en',\n * fallbackLocale: 'en', // Optional\n * });\n *\n * export type LabelConfig = typeof labelConfig;\n * export type AppLocale = typeof labelConfig.locales[number]; // 'en' | 'ar'\n * ```\n */\nexport function defineLabelConfig<const TLocales extends readonly string[]>(config: {\n locales: TLocales;\n defaultLocale: TLocales[number];\n fallbackLocale?: TLocales[number];\n useBrowserLanguage?: boolean;\n})\n{\n return config;\n}\n\n/**\n * Define nested label structure (tRPC-style)\n *\n * @example\n * ```ts\n * export const labels = defineLabels({\n * home: {\n * slogan: { en: \"Welcome\", ko: \"환영합니다\" },\n * hero: {\n * title: { en: \"Hello\", ko: \"안녕하세요\" }\n * }\n * },\n * about: {\n * title: { en: \"About Us\", ko: \"회사 소개\" }\n * }\n * });\n *\n * // Usage\n * labels.home.slogan;\n * labels.home.hero.title;\n * labels.about.title;\n * ```\n */\nexport function defineLabels<const T>(labels: T)\n{\n return labels;\n}\n\nexport function format(template: string, vars: Record<string, string | number>): string\n{\n return template.replace(/\\{(\\w+)}/g, (match, key) =>\n {\n const value = vars[key];\n\n return value !== undefined ? String(value) : match;\n });\n}\n"],"mappings":"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","names":[]}
|
package/dist/server.d.ts
CHANGED
|
@@ -8,6 +8,61 @@ declare const cmsAppRouter: _spfn_core_route.Router<{
|
|
|
8
8
|
locale: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
9
9
|
}>;
|
|
10
10
|
}, {}, Record<string, any>>;
|
|
11
|
+
getSectionLabels: _spfn_core_route.RouteDef<{
|
|
12
|
+
params: _sinclair_typebox.TObject<{
|
|
13
|
+
section: _sinclair_typebox.TString;
|
|
14
|
+
}>;
|
|
15
|
+
query: _sinclair_typebox.TObject<{
|
|
16
|
+
locales: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
17
|
+
}>;
|
|
18
|
+
}, {}, {
|
|
19
|
+
section: string;
|
|
20
|
+
locales: string[];
|
|
21
|
+
labels: Array<{
|
|
22
|
+
id: number;
|
|
23
|
+
key: string;
|
|
24
|
+
description: string | null;
|
|
25
|
+
defaultValue: Record<string, string>;
|
|
26
|
+
draft: Record<string, string> | null;
|
|
27
|
+
published: Record<string, string> | null;
|
|
28
|
+
hasDraft: boolean;
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
saveSectionDraft: _spfn_core_route.RouteDef<{
|
|
32
|
+
params: _sinclair_typebox.TObject<{
|
|
33
|
+
section: _sinclair_typebox.TString;
|
|
34
|
+
}>;
|
|
35
|
+
body: _sinclair_typebox.TObject<{
|
|
36
|
+
labels: _sinclair_typebox.TArray<_sinclair_typebox.TObject<{
|
|
37
|
+
id: _sinclair_typebox.TNumber;
|
|
38
|
+
values: _sinclair_typebox.TRecord<_sinclair_typebox.TString, _sinclair_typebox.TString>;
|
|
39
|
+
}>>;
|
|
40
|
+
}>;
|
|
41
|
+
}, {}, {
|
|
42
|
+
updated: number;
|
|
43
|
+
success: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
publishSection: _spfn_core_route.RouteDef<{
|
|
46
|
+
params: _sinclair_typebox.TObject<{
|
|
47
|
+
section: _sinclair_typebox.TString;
|
|
48
|
+
}>;
|
|
49
|
+
body: _sinclair_typebox.TObject<{
|
|
50
|
+
locales: _sinclair_typebox.TArray<_sinclair_typebox.TString>;
|
|
51
|
+
}>;
|
|
52
|
+
}, {}, {
|
|
53
|
+
published: number;
|
|
54
|
+
version: number;
|
|
55
|
+
labels: string[];
|
|
56
|
+
success: boolean;
|
|
57
|
+
}>;
|
|
58
|
+
resetSectionDraft: _spfn_core_route.RouteDef<{
|
|
59
|
+
params: _sinclair_typebox.TObject<{
|
|
60
|
+
section: _sinclair_typebox.TString;
|
|
61
|
+
}>;
|
|
62
|
+
}, {}, {
|
|
63
|
+
reset: number;
|
|
64
|
+
success: boolean;
|
|
65
|
+
}>;
|
|
11
66
|
}>;
|
|
12
67
|
|
|
13
68
|
/**
|