@mandujs/core 0.41.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/content/digest.ts
CHANGED
|
@@ -1,133 +1,133 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content Digest - 변경 감지용 해시 생성
|
|
3
|
-
*
|
|
4
|
-
* 콘텐츠의 고유 다이제스트를 생성하여 변경 감지에 사용
|
|
5
|
-
* xxHash 스타일의 빠른 해시 또는 crypto 기반 SHA-256 사용
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { createHash } from "crypto";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* 다이제스트 옵션
|
|
12
|
-
*/
|
|
13
|
-
export interface DigestOptions {
|
|
14
|
-
/** 해시 알고리즘 (기본: sha256) */
|
|
15
|
-
algorithm?: "sha256" | "md5" | "sha1";
|
|
16
|
-
/** 출력 길이 (기본: 16) */
|
|
17
|
-
length?: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const DEFAULT_OPTIONS: DigestOptions = {
|
|
21
|
-
algorithm: "sha256",
|
|
22
|
-
length: 16,
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* 데이터에서 다이제스트 생성
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* ```ts
|
|
30
|
-
* const digest = generateDigest({ title: "Hello", content: "World" });
|
|
31
|
-
* // → "a1b2c3d4e5f6g7h8"
|
|
32
|
-
* ```
|
|
33
|
-
*/
|
|
34
|
-
export function generateDigest(data: unknown, options?: DigestOptions): string {
|
|
35
|
-
const { algorithm, length } = { ...DEFAULT_OPTIONS, ...options };
|
|
36
|
-
|
|
37
|
-
const serialized = stableStringify(data);
|
|
38
|
-
const hash = createHash(algorithm!);
|
|
39
|
-
hash.update(serialized);
|
|
40
|
-
|
|
41
|
-
return hash.digest("hex").slice(0, length);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 파일 내용에서 다이제스트 생성
|
|
46
|
-
*/
|
|
47
|
-
export function generateFileDigest(content: string | Buffer, options?: DigestOptions): string {
|
|
48
|
-
const { algorithm, length } = { ...DEFAULT_OPTIONS, ...options };
|
|
49
|
-
|
|
50
|
-
const hash = createHash(algorithm!);
|
|
51
|
-
hash.update(content);
|
|
52
|
-
|
|
53
|
-
return hash.digest("hex").slice(0, length);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* 여러 소스를 결합한 다이제스트 생성
|
|
58
|
-
*
|
|
59
|
-
* @example
|
|
60
|
-
* ```ts
|
|
61
|
-
* const digest = combineDigests([
|
|
62
|
-
* generateDigest(frontmatter),
|
|
63
|
-
* generateFileDigest(body),
|
|
64
|
-
* ]);
|
|
65
|
-
* ```
|
|
66
|
-
*/
|
|
67
|
-
export function combineDigests(digests: string[], options?: DigestOptions): string {
|
|
68
|
-
const { algorithm, length } = { ...DEFAULT_OPTIONS, ...options };
|
|
69
|
-
|
|
70
|
-
const combined = digests.join(":");
|
|
71
|
-
const hash = createHash(algorithm!);
|
|
72
|
-
hash.update(combined);
|
|
73
|
-
|
|
74
|
-
return hash.digest("hex").slice(0, length);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* 안정적인 JSON 문자열화 (키 순서 일관성 보장)
|
|
79
|
-
*/
|
|
80
|
-
function stableStringify(obj: unknown): string {
|
|
81
|
-
if (obj === null || obj === undefined) {
|
|
82
|
-
return String(obj);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (typeof obj !== "object") {
|
|
86
|
-
return JSON.stringify(obj);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (Array.isArray(obj)) {
|
|
90
|
-
return "[" + obj.map(stableStringify).join(",") + "]";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (obj instanceof Date) {
|
|
94
|
-
return JSON.stringify(obj.toISOString());
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (obj instanceof Map) {
|
|
98
|
-
const entries = Array.from(obj.entries()).sort(([a], [b]) =>
|
|
99
|
-
String(a).localeCompare(String(b))
|
|
100
|
-
);
|
|
101
|
-
return stableStringify(Object.fromEntries(entries));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (obj instanceof Set) {
|
|
105
|
-
return stableStringify(Array.from(obj).sort());
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// 일반 객체: 키를 정렬하여 직렬화
|
|
109
|
-
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
|
110
|
-
const pairs = keys.map(
|
|
111
|
-
(key) => `${JSON.stringify(key)}:${stableStringify((obj as Record<string, unknown>)[key])}`
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
return "{" + pairs.join(",") + "}";
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* 두 다이제스트 비교
|
|
119
|
-
*/
|
|
120
|
-
export function digestsMatch(a: string | undefined, b: string | undefined): boolean {
|
|
121
|
-
if (!a || !b) return false;
|
|
122
|
-
return a === b;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* 다이제스트 기반 변경 감지
|
|
127
|
-
*/
|
|
128
|
-
export function hasChanged(
|
|
129
|
-
newDigest: string,
|
|
130
|
-
oldDigest: string | undefined
|
|
131
|
-
): boolean {
|
|
132
|
-
return !digestsMatch(newDigest, oldDigest);
|
|
133
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Content Digest - 변경 감지용 해시 생성
|
|
3
|
+
*
|
|
4
|
+
* 콘텐츠의 고유 다이제스트를 생성하여 변경 감지에 사용
|
|
5
|
+
* xxHash 스타일의 빠른 해시 또는 crypto 기반 SHA-256 사용
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 다이제스트 옵션
|
|
12
|
+
*/
|
|
13
|
+
export interface DigestOptions {
|
|
14
|
+
/** 해시 알고리즘 (기본: sha256) */
|
|
15
|
+
algorithm?: "sha256" | "md5" | "sha1";
|
|
16
|
+
/** 출력 길이 (기본: 16) */
|
|
17
|
+
length?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_OPTIONS: DigestOptions = {
|
|
21
|
+
algorithm: "sha256",
|
|
22
|
+
length: 16,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 데이터에서 다이제스트 생성
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* const digest = generateDigest({ title: "Hello", content: "World" });
|
|
31
|
+
* // → "a1b2c3d4e5f6g7h8"
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function generateDigest(data: unknown, options?: DigestOptions): string {
|
|
35
|
+
const { algorithm, length } = { ...DEFAULT_OPTIONS, ...options };
|
|
36
|
+
|
|
37
|
+
const serialized = stableStringify(data);
|
|
38
|
+
const hash = createHash(algorithm!);
|
|
39
|
+
hash.update(serialized);
|
|
40
|
+
|
|
41
|
+
return hash.digest("hex").slice(0, length);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 파일 내용에서 다이제스트 생성
|
|
46
|
+
*/
|
|
47
|
+
export function generateFileDigest(content: string | Buffer, options?: DigestOptions): string {
|
|
48
|
+
const { algorithm, length } = { ...DEFAULT_OPTIONS, ...options };
|
|
49
|
+
|
|
50
|
+
const hash = createHash(algorithm!);
|
|
51
|
+
hash.update(content);
|
|
52
|
+
|
|
53
|
+
return hash.digest("hex").slice(0, length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 여러 소스를 결합한 다이제스트 생성
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const digest = combineDigests([
|
|
62
|
+
* generateDigest(frontmatter),
|
|
63
|
+
* generateFileDigest(body),
|
|
64
|
+
* ]);
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function combineDigests(digests: string[], options?: DigestOptions): string {
|
|
68
|
+
const { algorithm, length } = { ...DEFAULT_OPTIONS, ...options };
|
|
69
|
+
|
|
70
|
+
const combined = digests.join(":");
|
|
71
|
+
const hash = createHash(algorithm!);
|
|
72
|
+
hash.update(combined);
|
|
73
|
+
|
|
74
|
+
return hash.digest("hex").slice(0, length);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 안정적인 JSON 문자열화 (키 순서 일관성 보장)
|
|
79
|
+
*/
|
|
80
|
+
function stableStringify(obj: unknown): string {
|
|
81
|
+
if (obj === null || obj === undefined) {
|
|
82
|
+
return String(obj);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof obj !== "object") {
|
|
86
|
+
return JSON.stringify(obj);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(obj)) {
|
|
90
|
+
return "[" + obj.map(stableStringify).join(",") + "]";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (obj instanceof Date) {
|
|
94
|
+
return JSON.stringify(obj.toISOString());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (obj instanceof Map) {
|
|
98
|
+
const entries = Array.from(obj.entries()).sort(([a], [b]) =>
|
|
99
|
+
String(a).localeCompare(String(b))
|
|
100
|
+
);
|
|
101
|
+
return stableStringify(Object.fromEntries(entries));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (obj instanceof Set) {
|
|
105
|
+
return stableStringify(Array.from(obj).sort());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 일반 객체: 키를 정렬하여 직렬화
|
|
109
|
+
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
|
110
|
+
const pairs = keys.map(
|
|
111
|
+
(key) => `${JSON.stringify(key)}:${stableStringify((obj as Record<string, unknown>)[key])}`
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return "{" + pairs.join(",") + "}";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 두 다이제스트 비교
|
|
119
|
+
*/
|
|
120
|
+
export function digestsMatch(a: string | undefined, b: string | undefined): boolean {
|
|
121
|
+
if (!a || !b) return false;
|
|
122
|
+
return a === b;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 다이제스트 기반 변경 감지
|
|
127
|
+
*/
|
|
128
|
+
export function hasChanged(
|
|
129
|
+
newDigest: string,
|
|
130
|
+
oldDigest: string | undefined
|
|
131
|
+
): boolean {
|
|
132
|
+
return !digestsMatch(newDigest, oldDigest);
|
|
133
|
+
}
|
|
@@ -1,168 +1,168 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content Types Generator (Issue #199)
|
|
3
|
-
*
|
|
4
|
-
* Emits `.mandu/generated/content-types.d.ts` with typed aliases for
|
|
5
|
-
* every collection declared in `content.config.ts`. This gives users
|
|
6
|
-
* full autocomplete on `docs.all()`, `docs.get(slug)`, etc. without
|
|
7
|
-
* having to thread generics through every call site:
|
|
8
|
-
*
|
|
9
|
-
* ```ts
|
|
10
|
-
* // user writes
|
|
11
|
-
* import type { CollectionMap } from '.mandu/generated/content-types';
|
|
12
|
-
* const entry = (await collections.docs.all())[0];
|
|
13
|
-
* entry.data.title; // typed as string when schema has title: z.string()
|
|
14
|
-
* ```
|
|
15
|
-
*
|
|
16
|
-
* # Contract
|
|
17
|
-
*
|
|
18
|
-
* - Input: a map of collection-name → Collection instance (already
|
|
19
|
-
* returned from `defineCollection({ path, schema })`). We DO NOT
|
|
20
|
-
* re-execute `content.config.ts` here — the caller (the CLI
|
|
21
|
-
* generator in #196) is responsible for running the config in
|
|
22
|
-
* a scratch context and passing the live map to this function.
|
|
23
|
-
*
|
|
24
|
-
* - Output: a single `.d.ts` file with:
|
|
25
|
-
* 1. Re-exports of `Collection`, `CollectionEntry` types
|
|
26
|
-
* 2. A `CollectionMap` interface keyed by collection name
|
|
27
|
-
* 3. A `CollectionName` union type
|
|
28
|
-
* 4. Per-collection `Entry{Name}` aliases
|
|
29
|
-
*
|
|
30
|
-
* - Determinism: output order is stable (sorted by collection name)
|
|
31
|
-
* so regenerating without content changes produces a no-op diff.
|
|
32
|
-
*
|
|
33
|
-
* Schema-level type extraction is intentionally shallow for the MVP —
|
|
34
|
-
* we emit `CollectionEntry<Record<string, unknown>>` for collections
|
|
35
|
-
* without a schema, and `CollectionEntry<z.infer<typeof X>>` only
|
|
36
|
-
* when the user exports the schema alongside the collection. A future
|
|
37
|
-
* pass (tracked in a follow-up) will parse the Zod schema at build
|
|
38
|
-
* time to emit concrete interfaces.
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
import * as fs from "fs";
|
|
42
|
-
import * as path from "path";
|
|
43
|
-
import type { Collection } from "./collection";
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Structural view of a Collection accepted by the type emitter. The
|
|
47
|
-
* generator only reads the collection's name — it never calls methods
|
|
48
|
-
* — so the empty interface lets callers pass typed
|
|
49
|
-
* `Collection<{title: string}>` without running into TypeScript's
|
|
50
|
-
* invariance on generic classes.
|
|
51
|
-
*/
|
|
52
|
-
// biome-ignore lint/suspicious/noEmptyInterface: marker type for variance
|
|
53
|
-
interface CollectionLike {}
|
|
54
|
-
|
|
55
|
-
/** Input map: `{ docs: docsCollection, blog: blogCollection }`. */
|
|
56
|
-
export type CollectionRegistry = Record<string, CollectionLike>;
|
|
57
|
-
|
|
58
|
-
// Re-export the Collection type so users of @mandujs/core/content still
|
|
59
|
-
// see it alongside the registry.
|
|
60
|
-
export type { Collection };
|
|
61
|
-
|
|
62
|
-
/** Options for the type-emitter. */
|
|
63
|
-
export interface GenerateTypesOptions {
|
|
64
|
-
/**
|
|
65
|
-
* Destination path for the generated `.d.ts` file. Defaults to
|
|
66
|
-
* `.mandu/generated/content-types.d.ts` relative to `root`.
|
|
67
|
-
*/
|
|
68
|
-
outFile?: string;
|
|
69
|
-
/**
|
|
70
|
-
* Project root. Used to resolve `outFile` when that option is
|
|
71
|
-
* relative. Defaults to `process.cwd()`.
|
|
72
|
-
*/
|
|
73
|
-
root?: string;
|
|
74
|
-
/**
|
|
75
|
-
* Banner comment prepended to the file. Defaults to an
|
|
76
|
-
* auto-generated warning so authors know not to hand-edit.
|
|
77
|
-
*/
|
|
78
|
-
banner?: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const DEFAULT_BANNER = `/**
|
|
82
|
-
* AUTO-GENERATED by @mandujs/core/content — do not edit by hand.
|
|
83
|
-
* Regenerated on every \`mandu build\` / \`mandu dev\` bootstrap.
|
|
84
|
-
*/`;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Generate the `content-types.d.ts` source string. Exposed separately
|
|
88
|
-
* from the filesystem writer so tests can assert on the output without
|
|
89
|
-
* touching disk.
|
|
90
|
-
*/
|
|
91
|
-
export function renderContentTypes(
|
|
92
|
-
collections: CollectionRegistry
|
|
93
|
-
): string {
|
|
94
|
-
const names = Object.keys(collections).sort();
|
|
95
|
-
const lines: string[] = [];
|
|
96
|
-
lines.push(DEFAULT_BANNER);
|
|
97
|
-
lines.push("");
|
|
98
|
-
lines.push(`import type { Collection, CollectionEntry } from "@mandujs/core/content";`);
|
|
99
|
-
lines.push("");
|
|
100
|
-
lines.push("// Per-collection entry aliases");
|
|
101
|
-
for (const name of names) {
|
|
102
|
-
// We can't introspect Zod at runtime without an ICU-sized schema
|
|
103
|
-
// crawler, so we emit `Record<string, unknown>` here. Users who
|
|
104
|
-
// want full field typing can import their schema directly:
|
|
105
|
-
// type DocEntry = CollectionEntry<z.infer<typeof docsSchema>>;
|
|
106
|
-
lines.push(
|
|
107
|
-
`export type Entry${capitalize(name)} = CollectionEntry<Record<string, unknown>>;`
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
lines.push("");
|
|
111
|
-
lines.push("export interface CollectionMap {");
|
|
112
|
-
for (const name of names) {
|
|
113
|
-
lines.push(` ${JSON.stringify(name)}: Collection<Record<string, unknown>>;`);
|
|
114
|
-
}
|
|
115
|
-
lines.push("}");
|
|
116
|
-
lines.push("");
|
|
117
|
-
lines.push(
|
|
118
|
-
`export type CollectionName = ${names.length > 0 ? names.map((n) => JSON.stringify(n)).join(" | ") : "never"};`
|
|
119
|
-
);
|
|
120
|
-
lines.push("");
|
|
121
|
-
return lines.join("\n");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Write the generated types file to disk. Creates the directory
|
|
126
|
-
* tree if necessary and is a no-op if the file already contains the
|
|
127
|
-
* exact same contents (prevents unnecessary filewatcher churn during
|
|
128
|
-
* `mandu dev`).
|
|
129
|
-
*/
|
|
130
|
-
export function generateContentTypes(
|
|
131
|
-
collections: CollectionRegistry,
|
|
132
|
-
options: GenerateTypesOptions = {}
|
|
133
|
-
): { outFile: string; wrote: boolean } {
|
|
134
|
-
const root = options.root ?? process.cwd();
|
|
135
|
-
const outFile = path.isAbsolute(options.outFile ?? "")
|
|
136
|
-
? (options.outFile as string)
|
|
137
|
-
: path.resolve(
|
|
138
|
-
root,
|
|
139
|
-
options.outFile ?? ".mandu/generated/content-types.d.ts"
|
|
140
|
-
);
|
|
141
|
-
const banner = options.banner ?? DEFAULT_BANNER;
|
|
142
|
-
const body = renderContentTypes(collections);
|
|
143
|
-
const content = banner === DEFAULT_BANNER ? body : banner + "\n\n" + body.slice(body.indexOf("\n\n") + 2);
|
|
144
|
-
|
|
145
|
-
let existing: string | null = null;
|
|
146
|
-
try {
|
|
147
|
-
existing = fs.readFileSync(outFile, "utf8");
|
|
148
|
-
} catch {
|
|
149
|
-
existing = null;
|
|
150
|
-
}
|
|
151
|
-
if (existing === content) {
|
|
152
|
-
return { outFile, wrote: false };
|
|
153
|
-
}
|
|
154
|
-
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
155
|
-
fs.writeFileSync(outFile, content, "utf8");
|
|
156
|
-
return { outFile, wrote: true };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function capitalize(value: string): string {
|
|
160
|
-
// Collection names might contain dashes/underscores (`my-docs`),
|
|
161
|
-
// which are not valid identifiers. Strip non-word chars and
|
|
162
|
-
// PascalCase the remaining segments so `my-docs` becomes `MyDocs`.
|
|
163
|
-
return value
|
|
164
|
-
.split(/[^A-Za-z0-9]+/)
|
|
165
|
-
.filter((part) => part.length > 0)
|
|
166
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
167
|
-
.join("");
|
|
168
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Content Types Generator (Issue #199)
|
|
3
|
+
*
|
|
4
|
+
* Emits `.mandu/generated/content-types.d.ts` with typed aliases for
|
|
5
|
+
* every collection declared in `content.config.ts`. This gives users
|
|
6
|
+
* full autocomplete on `docs.all()`, `docs.get(slug)`, etc. without
|
|
7
|
+
* having to thread generics through every call site:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // user writes
|
|
11
|
+
* import type { CollectionMap } from '.mandu/generated/content-types';
|
|
12
|
+
* const entry = (await collections.docs.all())[0];
|
|
13
|
+
* entry.data.title; // typed as string when schema has title: z.string()
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* # Contract
|
|
17
|
+
*
|
|
18
|
+
* - Input: a map of collection-name → Collection instance (already
|
|
19
|
+
* returned from `defineCollection({ path, schema })`). We DO NOT
|
|
20
|
+
* re-execute `content.config.ts` here — the caller (the CLI
|
|
21
|
+
* generator in #196) is responsible for running the config in
|
|
22
|
+
* a scratch context and passing the live map to this function.
|
|
23
|
+
*
|
|
24
|
+
* - Output: a single `.d.ts` file with:
|
|
25
|
+
* 1. Re-exports of `Collection`, `CollectionEntry` types
|
|
26
|
+
* 2. A `CollectionMap` interface keyed by collection name
|
|
27
|
+
* 3. A `CollectionName` union type
|
|
28
|
+
* 4. Per-collection `Entry{Name}` aliases
|
|
29
|
+
*
|
|
30
|
+
* - Determinism: output order is stable (sorted by collection name)
|
|
31
|
+
* so regenerating without content changes produces a no-op diff.
|
|
32
|
+
*
|
|
33
|
+
* Schema-level type extraction is intentionally shallow for the MVP —
|
|
34
|
+
* we emit `CollectionEntry<Record<string, unknown>>` for collections
|
|
35
|
+
* without a schema, and `CollectionEntry<z.infer<typeof X>>` only
|
|
36
|
+
* when the user exports the schema alongside the collection. A future
|
|
37
|
+
* pass (tracked in a follow-up) will parse the Zod schema at build
|
|
38
|
+
* time to emit concrete interfaces.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import * as fs from "fs";
|
|
42
|
+
import * as path from "path";
|
|
43
|
+
import type { Collection } from "./collection";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Structural view of a Collection accepted by the type emitter. The
|
|
47
|
+
* generator only reads the collection's name — it never calls methods
|
|
48
|
+
* — so the empty interface lets callers pass typed
|
|
49
|
+
* `Collection<{title: string}>` without running into TypeScript's
|
|
50
|
+
* invariance on generic classes.
|
|
51
|
+
*/
|
|
52
|
+
// biome-ignore lint/suspicious/noEmptyInterface: marker type for variance
|
|
53
|
+
interface CollectionLike {}
|
|
54
|
+
|
|
55
|
+
/** Input map: `{ docs: docsCollection, blog: blogCollection }`. */
|
|
56
|
+
export type CollectionRegistry = Record<string, CollectionLike>;
|
|
57
|
+
|
|
58
|
+
// Re-export the Collection type so users of @mandujs/core/content still
|
|
59
|
+
// see it alongside the registry.
|
|
60
|
+
export type { Collection };
|
|
61
|
+
|
|
62
|
+
/** Options for the type-emitter. */
|
|
63
|
+
export interface GenerateTypesOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Destination path for the generated `.d.ts` file. Defaults to
|
|
66
|
+
* `.mandu/generated/content-types.d.ts` relative to `root`.
|
|
67
|
+
*/
|
|
68
|
+
outFile?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Project root. Used to resolve `outFile` when that option is
|
|
71
|
+
* relative. Defaults to `process.cwd()`.
|
|
72
|
+
*/
|
|
73
|
+
root?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Banner comment prepended to the file. Defaults to an
|
|
76
|
+
* auto-generated warning so authors know not to hand-edit.
|
|
77
|
+
*/
|
|
78
|
+
banner?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_BANNER = `/**
|
|
82
|
+
* AUTO-GENERATED by @mandujs/core/content — do not edit by hand.
|
|
83
|
+
* Regenerated on every \`mandu build\` / \`mandu dev\` bootstrap.
|
|
84
|
+
*/`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generate the `content-types.d.ts` source string. Exposed separately
|
|
88
|
+
* from the filesystem writer so tests can assert on the output without
|
|
89
|
+
* touching disk.
|
|
90
|
+
*/
|
|
91
|
+
export function renderContentTypes(
|
|
92
|
+
collections: CollectionRegistry
|
|
93
|
+
): string {
|
|
94
|
+
const names = Object.keys(collections).sort();
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
lines.push(DEFAULT_BANNER);
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push(`import type { Collection, CollectionEntry } from "@mandujs/core/content";`);
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push("// Per-collection entry aliases");
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
// We can't introspect Zod at runtime without an ICU-sized schema
|
|
103
|
+
// crawler, so we emit `Record<string, unknown>` here. Users who
|
|
104
|
+
// want full field typing can import their schema directly:
|
|
105
|
+
// type DocEntry = CollectionEntry<z.infer<typeof docsSchema>>;
|
|
106
|
+
lines.push(
|
|
107
|
+
`export type Entry${capitalize(name)} = CollectionEntry<Record<string, unknown>>;`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
lines.push("");
|
|
111
|
+
lines.push("export interface CollectionMap {");
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
lines.push(` ${JSON.stringify(name)}: Collection<Record<string, unknown>>;`);
|
|
114
|
+
}
|
|
115
|
+
lines.push("}");
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push(
|
|
118
|
+
`export type CollectionName = ${names.length > 0 ? names.map((n) => JSON.stringify(n)).join(" | ") : "never"};`
|
|
119
|
+
);
|
|
120
|
+
lines.push("");
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Write the generated types file to disk. Creates the directory
|
|
126
|
+
* tree if necessary and is a no-op if the file already contains the
|
|
127
|
+
* exact same contents (prevents unnecessary filewatcher churn during
|
|
128
|
+
* `mandu dev`).
|
|
129
|
+
*/
|
|
130
|
+
export function generateContentTypes(
|
|
131
|
+
collections: CollectionRegistry,
|
|
132
|
+
options: GenerateTypesOptions = {}
|
|
133
|
+
): { outFile: string; wrote: boolean } {
|
|
134
|
+
const root = options.root ?? process.cwd();
|
|
135
|
+
const outFile = path.isAbsolute(options.outFile ?? "")
|
|
136
|
+
? (options.outFile as string)
|
|
137
|
+
: path.resolve(
|
|
138
|
+
root,
|
|
139
|
+
options.outFile ?? ".mandu/generated/content-types.d.ts"
|
|
140
|
+
);
|
|
141
|
+
const banner = options.banner ?? DEFAULT_BANNER;
|
|
142
|
+
const body = renderContentTypes(collections);
|
|
143
|
+
const content = banner === DEFAULT_BANNER ? body : banner + "\n\n" + body.slice(body.indexOf("\n\n") + 2);
|
|
144
|
+
|
|
145
|
+
let existing: string | null = null;
|
|
146
|
+
try {
|
|
147
|
+
existing = fs.readFileSync(outFile, "utf8");
|
|
148
|
+
} catch {
|
|
149
|
+
existing = null;
|
|
150
|
+
}
|
|
151
|
+
if (existing === content) {
|
|
152
|
+
return { outFile, wrote: false };
|
|
153
|
+
}
|
|
154
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
155
|
+
fs.writeFileSync(outFile, content, "utf8");
|
|
156
|
+
return { outFile, wrote: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function capitalize(value: string): string {
|
|
160
|
+
// Collection names might contain dashes/underscores (`my-docs`),
|
|
161
|
+
// which are not valid identifiers. Strip non-word chars and
|
|
162
|
+
// PascalCase the remaining segments so `my-docs` becomes `MyDocs`.
|
|
163
|
+
return value
|
|
164
|
+
.split(/[^A-Za-z0-9]+/)
|
|
165
|
+
.filter((part) => part.length > 0)
|
|
166
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
167
|
+
.join("");
|
|
168
|
+
}
|
package/src/content/index.ts
CHANGED
|
@@ -179,7 +179,12 @@ export function defineContentConfig<T extends ContentConfigType>(config: T): T {
|
|
|
179
179
|
// `./collection.ts` — it detects the shape at runtime via the presence
|
|
180
180
|
// of the `loader` property.
|
|
181
181
|
|
|
182
|
-
export {
|
|
182
|
+
export {
|
|
183
|
+
defineCollection,
|
|
184
|
+
Collection,
|
|
185
|
+
getRegisteredCollections,
|
|
186
|
+
invalidateAllCollections,
|
|
187
|
+
} from "./collection";
|
|
183
188
|
export type {
|
|
184
189
|
CollectionEntry,
|
|
185
190
|
CompiledCollectionEntry,
|