@mandujs/core 0.11.0 → 0.12.1

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/README.ko.md CHANGED
@@ -25,11 +25,16 @@ bun add @mandujs/core
25
25
 
26
26
  ```
27
27
  @mandujs/core
28
- ├── spec/ # Spec 스키마 로딩
29
- ├── generator/ # 코드 생성
28
+ ├── router/ # 파일 시스템 기반 라우팅
30
29
  ├── guard/ # 아키텍처 검사 및 자동 수정
31
- ├── runtime/ # 서버 라우터
32
- └── report/ # Guard 리포트 생성
30
+ ├── runtime/ # 서버, SSR, 스트리밍
31
+ ├── filling/ # 핸들러 체인 API
32
+ ├── contract/ # 타입 안전 API 계약
33
+ ├── content/ # Content Layer - 빌드 타임 콘텐츠 로딩 🆕
34
+ ├── bundler/ # 클라이언트 번들링, HMR
35
+ ├── client/ # Island 하이드레이션, 클라이언트 라우터
36
+ ├── brain/ # Doctor, Watcher, 아키텍처 분석
37
+ └── change/ # 트랜잭션 & 히스토리
33
38
  ```
34
39
 
35
40
  ## Spec 모듈
@@ -133,6 +138,73 @@ if (!result.passed) {
133
138
  | `COMPONENT_NOT_FOUND` | 컴포넌트 파일 없음 | ❌ |
134
139
  | `SLOT_NOT_FOUND` | slot 파일 없음 | ✅ |
135
140
 
141
+ ## Content Layer 🆕
142
+
143
+ Astro에서 영감받은 빌드 타임 콘텐츠 로딩 시스템.
144
+
145
+ ```typescript
146
+ // content.config.ts
147
+ import { defineContentConfig, glob, file, api } from "@mandujs/core/content";
148
+ import { z } from "zod";
149
+
150
+ const postSchema = z.object({
151
+ title: z.string(),
152
+ date: z.coerce.date(),
153
+ tags: z.array(z.string()).default([]),
154
+ });
155
+
156
+ export default defineContentConfig({
157
+ collections: {
158
+ // Markdown 파일 (프론트매터 지원)
159
+ posts: {
160
+ loader: glob({ pattern: "content/posts/**/*.md" }),
161
+ schema: postSchema,
162
+ },
163
+ // 단일 JSON/YAML 파일
164
+ settings: {
165
+ loader: file({ path: "data/settings.json" }),
166
+ },
167
+ // 외부 API
168
+ products: {
169
+ loader: api({
170
+ url: "https://api.example.com/products",
171
+ cacheTTL: 3600,
172
+ }),
173
+ },
174
+ },
175
+ });
176
+ ```
177
+
178
+ ### 콘텐츠 조회
179
+
180
+ ```typescript
181
+ import { getCollection, getEntry } from "@mandujs/core/content";
182
+
183
+ // 전체 컬렉션 조회
184
+ const posts = await getCollection("posts");
185
+
186
+ // 단일 엔트리 조회
187
+ const post = await getEntry("posts", "hello-world");
188
+ console.log(post?.data.title, post?.body);
189
+ ```
190
+
191
+ ### 내장 로더
192
+
193
+ | 로더 | 설명 | 예시 |
194
+ |------|------|------|
195
+ | `file()` | 단일 파일 (JSON, YAML, TOML) | `file({ path: "data/config.json" })` |
196
+ | `glob()` | 패턴 매칭 (Markdown, JSON) | `glob({ pattern: "content/**/*.md" })` |
197
+ | `api()` | HTTP API (캐싱 지원) | `api({ url: "https://...", cacheTTL: 3600 })` |
198
+
199
+ ### 주요 기능
200
+
201
+ - **Digest 기반 캐싱**: 변경된 파일만 재파싱
202
+ - **Zod 검증**: 스키마 기반 타입 안전 콘텐츠
203
+ - **프론트매터 지원**: Markdown YAML 프론트매터
204
+ - **Dev 모드 감시**: 콘텐츠 변경 시 자동 리로드
205
+
206
+ ---
207
+
136
208
  ## Contract 모듈
137
209
 
138
210
  Zod 기반 계약(Contract) 정의 및 타입 안전 클라이언트 생성.
@@ -209,6 +281,11 @@ import type {
209
281
  GuardViolation,
210
282
  GenerateResult,
211
283
  AutoCorrectResult,
284
+ // Content Layer
285
+ DataEntry,
286
+ ContentConfig,
287
+ CollectionConfig,
288
+ Loader,
212
289
  } from "@mandujs/core";
213
290
  ```
214
291
 
package/README.md CHANGED
@@ -81,6 +81,8 @@ const handlers = Mandu.handler(userContract, {
81
81
  ├── runtime/ # Server, SSR, streaming
82
82
  ├── filling/ # Handler chain API (Mandu.filling())
83
83
  ├── contract/ # Type-safe API contracts
84
+ ├── content/ # Content Layer - build-time content loading 🆕
85
+ │ └── loaders # file(), glob(), api() loaders
84
86
  ├── bundler/ # Client bundling, HMR
85
87
  ├── client/ # Island hydration, client router
86
88
  ├── brain/ # Doctor, Watcher, Architecture analyzer
@@ -453,6 +455,78 @@ function Navigation() {
453
455
 
454
456
  ---
455
457
 
458
+ ## Content Layer 🆕
459
+
460
+ Astro-inspired build-time content loading system.
461
+
462
+ ```typescript
463
+ // content.config.ts
464
+ import { defineContentConfig, glob, file, api } from "@mandujs/core/content";
465
+ import { z } from "zod";
466
+
467
+ const postSchema = z.object({
468
+ title: z.string(),
469
+ date: z.coerce.date(),
470
+ tags: z.array(z.string()).default([]),
471
+ });
472
+
473
+ export default defineContentConfig({
474
+ collections: {
475
+ // Markdown files with frontmatter
476
+ posts: {
477
+ loader: glob({ pattern: "content/posts/**/*.md" }),
478
+ schema: postSchema,
479
+ },
480
+ // Single JSON/YAML file
481
+ settings: {
482
+ loader: file({ path: "data/settings.json" }),
483
+ },
484
+ // External API
485
+ products: {
486
+ loader: api({
487
+ url: "https://api.example.com/products",
488
+ headers: () => ({ Authorization: `Bearer ${process.env.API_KEY}` }),
489
+ cacheTTL: 3600,
490
+ }),
491
+ },
492
+ },
493
+ });
494
+ ```
495
+
496
+ ### Querying Content
497
+
498
+ ```typescript
499
+ import { getCollection, getEntry } from "@mandujs/core/content";
500
+
501
+ // Get all entries
502
+ const posts = await getCollection("posts");
503
+ posts.forEach(post => {
504
+ console.log(post.id, post.data.title);
505
+ });
506
+
507
+ // Get single entry
508
+ const post = await getEntry("posts", "hello-world");
509
+ console.log(post?.data.title, post?.body);
510
+ ```
511
+
512
+ ### Built-in Loaders
513
+
514
+ | Loader | Description | Example |
515
+ |--------|-------------|---------|
516
+ | `file()` | Single file (JSON, YAML, TOML) | `file({ path: "data/config.json" })` |
517
+ | `glob()` | Pattern matching (Markdown, JSON) | `glob({ pattern: "content/**/*.md" })` |
518
+ | `api()` | HTTP API with caching | `api({ url: "https://...", cacheTTL: 3600 })` |
519
+
520
+ ### Features
521
+
522
+ - **Digest-based caching**: Only re-parse changed files
523
+ - **Zod validation**: Type-safe content with schema validation
524
+ - **Frontmatter support**: YAML frontmatter in Markdown files
525
+ - **Dev mode watching**: Auto-reload on content changes
526
+ - **Incremental updates**: Efficient builds with change detection
527
+
528
+ ---
529
+
456
530
  ## Brain (AI Assistant)
457
531
 
458
532
  Doctor and architecture analyzer.
@@ -551,6 +625,13 @@ import type {
551
625
 
552
626
  // Filling
553
627
  ManduContext,
628
+
629
+ // Content Layer
630
+ DataEntry,
631
+ ContentConfig,
632
+ CollectionConfig,
633
+ Loader,
634
+ LoaderContext,
554
635
  } from "@mandujs/core";
555
636
  ```
556
637
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -13,12 +13,6 @@
13
13
  "files": [
14
14
  "src/**/*"
15
15
  ],
16
- "scripts": {
17
- "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
18
- "test:hydration": "bun test tests/hydration",
19
- "test:streaming": "bun test tests/streaming-ssr",
20
- "test:watch": "bun test --watch"
21
- },
22
16
  "devDependencies": {
23
17
  "@happy-dom/global-registrator": "^15.0.0"
24
18
  },
@@ -35,7 +29,7 @@
35
29
  "directory": "packages/core"
36
30
  },
37
31
  "author": "konamgil",
38
- "license": "MIT",
32
+ "license": "MPL-2.0",
39
33
  "publishConfig": {
40
34
  "access": "public"
41
35
  },
@@ -49,8 +43,15 @@
49
43
  },
50
44
  "dependencies": {
51
45
  "chokidar": "^5.0.0",
46
+ "fast-glob": "^3.3.2",
52
47
  "glob": "^13.0.0",
53
48
  "minimatch": "^10.1.1",
54
49
  "ollama": "^0.6.3"
50
+ },
51
+ "scripts": {
52
+ "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
53
+ "test:hydration": "bun test tests/hydration",
54
+ "test:streaming": "bun test tests/streaming-ssr",
55
+ "test:watch": "bun test --watch"
55
56
  }
56
- }
57
+ }
package/src/constants.ts CHANGED
@@ -23,3 +23,18 @@ export const LIMITS = {
23
23
  ROUTER_PATTERN_CACHE: 200,
24
24
  ROUTER_PREFETCH_CACHE: 500,
25
25
  } as const;
26
+
27
+ export const CONTENT = {
28
+ /** 로더 기본 타임아웃 (ms) */
29
+ LOADER_TIMEOUT: 10000,
30
+ /** 데이터 스토어 파일 경로 */
31
+ STORE_FILE: ".mandu/content-store.json",
32
+ /** 메타 스토어 파일 경로 */
33
+ META_FILE: ".mandu/content-meta.json",
34
+ /** 저장 디바운스 (ms) */
35
+ DEBOUNCE_SAVE: 500,
36
+ /** 기본 콘텐츠 디렉토리 */
37
+ DEFAULT_DIR: "content",
38
+ /** API 로더 기본 캐시 TTL (초) */
39
+ API_CACHE_TTL: 3600,
40
+ } as const;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * ContentLayer - 콘텐츠 레이어 메인 클래스
3
+ *
4
+ * 빌드 타임에 모든 컬렉션을 로드하고 관리
5
+ * getCollection(), getEntry() API 제공
6
+ */
7
+
8
+ import type {
9
+ ContentConfig,
10
+ CollectionConfig,
11
+ DataEntry,
12
+ ManduContentConfig,
13
+ ContentWatcher,
14
+ RenderedContent,
15
+ } from "./types";
16
+ import { LoaderError } from "./types";
17
+ import { ContentDataStore, createDataStore } from "./data-store";
18
+ import { ContentMetaStore, createMetaStore } from "./meta-store";
19
+ import { createLoaderContext, createSimpleMarkdownRenderer } from "./loader-context";
20
+ import { CONTENT } from "../constants";
21
+ import type { ZodSchema } from "zod";
22
+ import * as path from "path";
23
+
24
+ /**
25
+ * ContentLayer 옵션
26
+ */
27
+ export interface ContentLayerOptions {
28
+ /** 콘텐츠 설정 */
29
+ contentConfig: ContentConfig;
30
+ /** Mandu 설정 */
31
+ manduConfig: ManduContentConfig;
32
+ /** Markdown 렌더러 (선택) */
33
+ markdownRenderer?: (content: string) => Promise<RenderedContent>;
34
+ /** 파일 감시자 (dev 모드) */
35
+ watcher?: ContentWatcher;
36
+ }
37
+
38
+ /**
39
+ * ContentLayer 클래스
40
+ *
41
+ * 빌드 타임 콘텐츠 로딩 및 관리
42
+ */
43
+ export class ContentLayer {
44
+ private contentConfig: ContentConfig;
45
+ private manduConfig: ManduContentConfig;
46
+ private dataStore: ContentDataStore;
47
+ private metaStore: ContentMetaStore;
48
+ private markdownRenderer: (content: string) => Promise<RenderedContent>;
49
+ private watcher?: ContentWatcher;
50
+ private loaded: boolean = false;
51
+ private loading: Promise<void> | null = null;
52
+
53
+ constructor(options: ContentLayerOptions) {
54
+ const { contentConfig, manduConfig, markdownRenderer, watcher } = options;
55
+
56
+ this.contentConfig = contentConfig;
57
+ this.manduConfig = manduConfig;
58
+ this.watcher = watcher;
59
+ this.markdownRenderer = markdownRenderer ?? createSimpleMarkdownRenderer();
60
+
61
+ // 스토어 초기화
62
+ const storeFile = path.join(manduConfig.root, CONTENT.STORE_FILE);
63
+ const metaFile = path.join(manduConfig.root, CONTENT.META_FILE);
64
+
65
+ this.dataStore = createDataStore({
66
+ filePath: storeFile,
67
+ autoSave: true,
68
+ saveDebounce: CONTENT.DEBOUNCE_SAVE,
69
+ });
70
+
71
+ this.metaStore = createMetaStore({
72
+ filePath: metaFile,
73
+ autoSave: true,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * 모든 컬렉션 로드
79
+ */
80
+ async load(): Promise<void> {
81
+ // 이미 로딩 중이면 기다림
82
+ if (this.loading) {
83
+ return this.loading;
84
+ }
85
+
86
+ // 이미 로드됨
87
+ if (this.loaded) {
88
+ return;
89
+ }
90
+
91
+ this.loading = this.doLoad();
92
+ await this.loading;
93
+ this.loading = null;
94
+ this.loaded = true;
95
+ }
96
+
97
+ private async doLoad(): Promise<void> {
98
+ // 캐시에서 로드
99
+ await Promise.all([this.dataStore.load(), this.metaStore.load()]);
100
+
101
+ const collections = Object.entries(this.contentConfig.collections);
102
+
103
+ // 병렬로 모든 컬렉션 로드
104
+ await Promise.all(
105
+ collections.map(([name, config]) => this.loadCollection(name, config))
106
+ );
107
+
108
+ // 저장
109
+ await Promise.all([this.dataStore.save(), this.metaStore.save()]);
110
+ }
111
+
112
+ /**
113
+ * 단일 컬렉션 로드
114
+ */
115
+ private async loadCollection(name: string, config: CollectionConfig): Promise<void> {
116
+ const { loader, schema } = config;
117
+
118
+ // 로더 스키마와 컬렉션 스키마 병합 (컬렉션 우선)
119
+ let finalSchema: ZodSchema | undefined = schema;
120
+ if (!finalSchema && loader.schema) {
121
+ finalSchema =
122
+ typeof loader.schema === "function" ? await loader.schema() : loader.schema;
123
+ }
124
+
125
+ // LoaderContext 생성
126
+ const context = createLoaderContext({
127
+ collection: name,
128
+ store: this.dataStore.getStore(name),
129
+ meta: this.metaStore.getStore(name),
130
+ config: this.manduConfig,
131
+ schema: finalSchema,
132
+ markdownRenderer: this.markdownRenderer,
133
+ watcher: this.watcher,
134
+ });
135
+
136
+ try {
137
+ // 타임아웃 처리
138
+ const timeoutPromise = new Promise<never>((_, reject) => {
139
+ setTimeout(
140
+ () => reject(new LoaderError(`Loader "${loader.name}" timed out`, name)),
141
+ CONTENT.LOADER_TIMEOUT
142
+ );
143
+ });
144
+
145
+ await Promise.race([loader.load(context), timeoutPromise]);
146
+
147
+ context.logger.info(`Loaded ${context.store.size()} entries`);
148
+ } catch (error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ context.logger.error(`Failed to load: ${message}`);
151
+
152
+ if (error instanceof LoaderError) {
153
+ throw error;
154
+ }
155
+
156
+ throw new LoaderError(`Failed to load collection "${name}": ${message}`, name);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * 단일 컬렉션 다시 로드
162
+ */
163
+ async reloadCollection(name: string): Promise<void> {
164
+ const config = this.contentConfig.collections[name];
165
+ if (!config) {
166
+ throw new Error(`Collection "${name}" not found`);
167
+ }
168
+
169
+ await this.loadCollection(name, config);
170
+ await this.dataStore.save();
171
+ await this.metaStore.save();
172
+ }
173
+
174
+ /**
175
+ * 컬렉션의 모든 엔트리 조회
176
+ */
177
+ getCollection<T = Record<string, unknown>>(
178
+ collection: string
179
+ ): Array<DataEntry<T>> {
180
+ this.ensureLoaded();
181
+
182
+ const store = this.dataStore.getStore(collection);
183
+ return store.values<T>();
184
+ }
185
+
186
+ /**
187
+ * 컬렉션에서 단일 엔트리 조회
188
+ */
189
+ getEntry<T = Record<string, unknown>>(
190
+ collection: string,
191
+ id: string
192
+ ): DataEntry<T> | undefined {
193
+ this.ensureLoaded();
194
+
195
+ const store = this.dataStore.getStore(collection);
196
+ return store.get<T>(id);
197
+ }
198
+
199
+ /**
200
+ * 컬렉션 존재 여부 확인
201
+ */
202
+ hasCollection(collection: string): boolean {
203
+ return collection in this.contentConfig.collections;
204
+ }
205
+
206
+ /**
207
+ * 모든 컬렉션 이름 조회
208
+ */
209
+ getCollectionNames(): string[] {
210
+ return Object.keys(this.contentConfig.collections);
211
+ }
212
+
213
+ /**
214
+ * 컬렉션 통계
215
+ */
216
+ getStats(): {
217
+ collections: number;
218
+ totalEntries: number;
219
+ byCollection: Record<string, number>;
220
+ } {
221
+ this.ensureLoaded();
222
+
223
+ const byCollection: Record<string, number> = {};
224
+ let totalEntries = 0;
225
+
226
+ for (const name of this.getCollectionNames()) {
227
+ const store = this.dataStore.getStore(name);
228
+ const count = store.size();
229
+ byCollection[name] = count;
230
+ totalEntries += count;
231
+ }
232
+
233
+ return {
234
+ collections: Object.keys(byCollection).length,
235
+ totalEntries,
236
+ byCollection,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * 로드 확인
242
+ */
243
+ private ensureLoaded(): void {
244
+ if (!this.loaded) {
245
+ throw new Error(
246
+ "ContentLayer not loaded. Call await contentLayer.load() first."
247
+ );
248
+ }
249
+ }
250
+
251
+ /**
252
+ * 리소스 정리
253
+ */
254
+ dispose(): void {
255
+ this.dataStore.dispose();
256
+ this.watcher?.close();
257
+ }
258
+ }
259
+
260
+ /**
261
+ * ContentLayer 팩토리
262
+ */
263
+ export function createContentLayer(options: ContentLayerOptions): ContentLayer {
264
+ return new ContentLayer(options);
265
+ }
266
+
267
+ // ============================================================================
268
+ // 전역 ContentLayer 인스턴스 (싱글톤)
269
+ // ============================================================================
270
+
271
+ let globalContentLayer: ContentLayer | null = null;
272
+
273
+ /**
274
+ * 전역 ContentLayer 설정
275
+ */
276
+ export function setGlobalContentLayer(layer: ContentLayer): void {
277
+ globalContentLayer = layer;
278
+ }
279
+
280
+ /**
281
+ * 전역 ContentLayer 조회
282
+ */
283
+ export function getGlobalContentLayer(): ContentLayer | null {
284
+ return globalContentLayer;
285
+ }
286
+
287
+ /**
288
+ * 컬렉션 조회 (전역 ContentLayer 사용)
289
+ */
290
+ export async function getCollection<T = Record<string, unknown>>(
291
+ collection: string
292
+ ): Promise<Array<DataEntry<T>>> {
293
+ if (!globalContentLayer) {
294
+ throw new Error("ContentLayer not initialized. Call setGlobalContentLayer() first.");
295
+ }
296
+
297
+ await globalContentLayer.load();
298
+ return globalContentLayer.getCollection<T>(collection);
299
+ }
300
+
301
+ /**
302
+ * 단일 엔트리 조회 (전역 ContentLayer 사용)
303
+ */
304
+ export async function getEntry<T = Record<string, unknown>>(
305
+ collection: string,
306
+ id: string
307
+ ): Promise<DataEntry<T> | undefined> {
308
+ if (!globalContentLayer) {
309
+ throw new Error("ContentLayer not initialized. Call setGlobalContentLayer() first.");
310
+ }
311
+
312
+ await globalContentLayer.load();
313
+ return globalContentLayer.getEntry<T>(collection, id);
314
+ }