dexin-content 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/collection.ts +289 -0
- package/core/compiler.ts +174 -0
- package/core/discovery.ts +110 -0
- package/core/frontmatter.ts +144 -0
- package/core/markdown.ts +295 -0
- package/core/types.ts +162 -0
- package/diff.ts +124 -0
- package/index.ts +12 -0
- package/package.json +68 -0
- package/query.ts +56 -0
- package/store.ts +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 得心实验室 (Dexin Labs — https://gitee.com/cuizhn)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# dexin-content
|
|
2
|
+
|
|
3
|
+
得心实验室的**结构化内容编译与运行时 package**。
|
|
4
|
+
|
|
5
|
+
负责与具体业务领域无关的通用内容管线:
|
|
6
|
+
|
|
7
|
+
- **Compile**:将带 frontmatter 的 Markdown 编译成中性 Content AST,再经宿主注入的 `DomainParser` 产出稳定的 PositiveArtifact
|
|
8
|
+
- **Store**:Artifact 与索引(ContentIndex)的 FS/Memory 抽象
|
|
9
|
+
- **Query**:按 id / URL path / collection / 条件过滤对 Artifact 做只读查询
|
|
10
|
+
- **Collection**:集合声明、发现、批量编译与增量变化检测
|
|
11
|
+
- **Diff**:Canonical JSON 序列化与对象级差异原语(Golden/Regress 管线使用)
|
|
12
|
+
|
|
13
|
+
## 目录范围
|
|
14
|
+
|
|
15
|
+
dexin-content 是**通用层**:
|
|
16
|
+
- `core/` 编译核心(types / compiler / markdown / frontmatter / discovery)
|
|
17
|
+
- 根层 `collection.ts`、`store.ts`、`query.ts`、`diff.ts` 是稳定能力
|
|
18
|
+
- `scripts/` 仓内脚本(不在发布包 files 内,仅供本地开发/测试;也不作为公开 API)
|
|
19
|
+
|
|
20
|
+
宿主应用负责域扩展(DomainParser)、编排脚本与内容文件所有权,通过 `exports` 里的子路径消费本 package。
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
> 需要 Node.js ≥ 20。Package 以 TypeScript 源码形式分发(`*.ts`),宿主应通过 `tsx`、Nuxt/Vite 等支持 TS 解析的工具链使用。
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Gitee 源(当前发布前)
|
|
28
|
+
npm install git+https://gitee.com/cuizhn/dexin-content.git
|
|
29
|
+
|
|
30
|
+
# 发布到 npm 后
|
|
31
|
+
npm install dexin-content
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 公开 API 边界
|
|
35
|
+
|
|
36
|
+
唯一公共 API 边界是 `package.json` 中 `exports` 字段。**只使用下列路径**:
|
|
37
|
+
|
|
38
|
+
| 子路径 | 暴露内容 |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `.` | 门面:核心类型 + `DomainParserRegistry` / `compile` + `ArtifactStore` / `ContentQuery` + `defineCollection / resolveCollections / discover / compileCollections` |
|
|
41
|
+
| `./core/types` | 类型:`Inline` / `DocumentBlock` / `DocumentContent` / `DocumentIdentity` / `PositiveArtifact` / `Artifact` / `Meta` / `ParseError` / `DomainParser` / `Schema` / `ParseContext` |
|
|
42
|
+
| `./core/compiler` | `compile`、`CompileInput`、`CompileResult`、`DomainParserRegistry` |
|
|
43
|
+
| `./core/discovery` | `buildDocumentIdentity` / `normaliseRel` / `readSourceFile` / `sanitizeSourceText` / `mkLineEndingError` |
|
|
44
|
+
| `./core/frontmatter` | `splitFrontmatter` / `parseFrontmatter` / `validateSchema` / `projectMeta` |
|
|
45
|
+
| `./store` | `ArtifactStore` 接口 + `createFsArtifactStore` / `createMemoryArtifactStore` + `ContentIndex` / `IndexEntry` |
|
|
46
|
+
| `./collection` | `defineCollection` / `resolveCollections` / `compileCollections` / `recompileChanged` / `createLocalSource` / `createMemorySource` / `SourceAdapter` / `ResolvedCollection` / `CollectionDefinition` |
|
|
47
|
+
| `./query` | `ContentQuery` (byId / byPath / list / collection) + `QueryOptions` |
|
|
48
|
+
| `./diff` | `toCanonicalJSON` / `sortKeysDeep` / `stripUnderscoreKeysGolden` / `underscorePrefixedPaths` / `firstDiff` / `shortStr` |
|
|
49
|
+
|
|
50
|
+
任何未列入上表的内部路径均不保证稳定。
|
|
51
|
+
|
|
52
|
+
## 快速示例
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import {
|
|
56
|
+
defineCollection,
|
|
57
|
+
resolveCollections,
|
|
58
|
+
createLocalSource,
|
|
59
|
+
compileCollections,
|
|
60
|
+
} from 'dexin-content/collection'
|
|
61
|
+
import { createFsArtifactStore } from 'dexin-content/store'
|
|
62
|
+
import { ContentQuery } from 'dexin-content/query'
|
|
63
|
+
import { DomainParserRegistry } from 'dexin-content/core/compiler'
|
|
64
|
+
import type {
|
|
65
|
+
DomainParser,
|
|
66
|
+
DocumentContent,
|
|
67
|
+
ParseContext,
|
|
68
|
+
} from 'dexin-content/core/types'
|
|
69
|
+
|
|
70
|
+
// 1) 注入你的域 parser;parser 决定中性 AST 如何映射到目标 Artifact 内容结构
|
|
71
|
+
const documentParser: DomainParser = {
|
|
72
|
+
domain: 'document',
|
|
73
|
+
parse(content: DocumentContent, _ctx: ParseContext) {
|
|
74
|
+
return { version: 1, blocks: content.blocks as unknown[] }
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
const registry = new DomainParserRegistry()
|
|
78
|
+
registry.register(documentParser)
|
|
79
|
+
|
|
80
|
+
// 2) 声明集合 + 编译
|
|
81
|
+
const collections = resolveCollections(
|
|
82
|
+
[defineCollection({ name: 'legal', source: 'legal', domain: 'document' })],
|
|
83
|
+
'/path/to/content/root',
|
|
84
|
+
)
|
|
85
|
+
const source = createLocalSource('/path/to/content/root')
|
|
86
|
+
const store = createFsArtifactStore('/path/to/output/dexin-content')
|
|
87
|
+
await compileCollections(source, collections, registry, store)
|
|
88
|
+
|
|
89
|
+
// 3) 运行时只读查询(不解析任何 Markdown)
|
|
90
|
+
const query = new ContentQuery(store)
|
|
91
|
+
const entry = await query.byPath('/legal/privacy')
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 开发
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# 依赖
|
|
98
|
+
npm install
|
|
99
|
+
|
|
100
|
+
# 类型检查(唯一静态门禁)
|
|
101
|
+
npm run typecheck
|
|
102
|
+
|
|
103
|
+
# 仓内自检测(需先 build 输出 output/dexin-content/)
|
|
104
|
+
npm run runtime:check
|
|
105
|
+
|
|
106
|
+
# 预发布 tarball 内容检查
|
|
107
|
+
npm run pack
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT © 得心实验室。详见 [LICENSE](./LICENSE)。
|
package/collection.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/collection.ts
|
|
3
|
+
//
|
|
4
|
+
// Collection declaration + source adaptation + discovery +
|
|
5
|
+
// batch compile orchestration.
|
|
6
|
+
//
|
|
7
|
+
// The batch compileCollections() wraps the formal per-file
|
|
8
|
+
// core/compiler.ts compile() — it discovers files, builds
|
|
9
|
+
// CompileInput per file, calls compile, and writes PositiveArtifact
|
|
10
|
+
// docs + ContentIndex to the store.
|
|
11
|
+
//
|
|
12
|
+
// grep-zero: no business vocabulary appears anywhere in this
|
|
13
|
+
// module — including error messages.
|
|
14
|
+
// ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
import type {
|
|
20
|
+
PositiveArtifact,
|
|
21
|
+
DomainName
|
|
22
|
+
} from './core/types'
|
|
23
|
+
import type { DomainParserRegistry, CompileInput } from './core/compiler'
|
|
24
|
+
import { compile } from './core/compiler'
|
|
25
|
+
import { buildDocumentIdentity, sanitizeSourceText } from './core/discovery'
|
|
26
|
+
import { splitFrontmatter } from './core/frontmatter'
|
|
27
|
+
import type { ArtifactStore, ContentIndex, IndexEntry } from './store'
|
|
28
|
+
|
|
29
|
+
// ── Collection types ──
|
|
30
|
+
|
|
31
|
+
export interface CollectionDefinition {
|
|
32
|
+
/** Collection name (appears in identity.collection + IndexEntry.collection). */
|
|
33
|
+
name: string
|
|
34
|
+
/** Directory under content root; .md files collected recursively. */
|
|
35
|
+
source: string
|
|
36
|
+
/** Domain tag; defaults to 'document' when omitted. */
|
|
37
|
+
domain?: DomainName | string
|
|
38
|
+
/** Frontmatter schema (reuses core Schema; validator-only, no meta projection). */
|
|
39
|
+
schema?: { required?: string[]; types?: Record<string, 'string' | 'number' | 'boolean'> }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResolvedCollection extends CollectionDefinition {
|
|
43
|
+
domain: string
|
|
44
|
+
sourceRoot: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Declaration passthrough (declarative API semantic); resolution in resolveCollections. */
|
|
48
|
+
export function defineCollection (def: CollectionDefinition): CollectionDefinition {
|
|
49
|
+
return def
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve definitions: fill domain default + bind absolute sourceRoot. */
|
|
53
|
+
export function resolveCollections (
|
|
54
|
+
defs: CollectionDefinition[],
|
|
55
|
+
contentRoot: string
|
|
56
|
+
): ResolvedCollection[] {
|
|
57
|
+
return defs.map(d => ({
|
|
58
|
+
...d,
|
|
59
|
+
domain: d.domain ?? 'document',
|
|
60
|
+
sourceRoot: join(contentRoot, d.source)
|
|
61
|
+
}))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── SourceAdapter ──
|
|
65
|
+
|
|
66
|
+
export interface SourceAdapter {
|
|
67
|
+
list(): Promise<string[]>
|
|
68
|
+
read(relPath: string): Promise<string>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Local filesystem source. Reads .md files recursively from root.
|
|
73
|
+
* CRLF protection (R-d): read() throws LINE_ENDING_CONTAMINATION
|
|
74
|
+
* on CRLF so the batch path enjoys the same guard as the synchronous
|
|
75
|
+
* single-file read path.
|
|
76
|
+
*
|
|
77
|
+
* Sanitisation (BOM strip + CRLF guard) is delegated to the shared
|
|
78
|
+
* `sanitizeSourceText` pure helper in core/discovery — byte-identical
|
|
79
|
+
* to the synchronous readSourceFile() path.
|
|
80
|
+
*/
|
|
81
|
+
export function createLocalSource (root: string): SourceAdapter {
|
|
82
|
+
async function walk (dir: string, base: string): Promise<string[]> {
|
|
83
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
84
|
+
const out: string[] = []
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
const rel = base ? `${base}/${e.name}` : e.name
|
|
87
|
+
if (e.isDirectory()) out.push(...await walk(join(dir, e.name), rel))
|
|
88
|
+
else if (e.isFile() && e.name.endsWith('.md')) out.push(rel)
|
|
89
|
+
}
|
|
90
|
+
return out.sort()
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
list: () => walk(root, ''),
|
|
94
|
+
read: async (rel) => {
|
|
95
|
+
const raw = await readFile(join(root, rel), 'utf8')
|
|
96
|
+
return sanitizeSourceText(raw, rel)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Virtual in-memory source (test use). */
|
|
102
|
+
export function createMemorySource (files: Record<string, string>): SourceAdapter {
|
|
103
|
+
return {
|
|
104
|
+
list: async () => Object.keys(files).sort(),
|
|
105
|
+
read: async (rel) => {
|
|
106
|
+
if (!(rel in files)) throw new Error(`[MemorySource] Not found: ${rel}`)
|
|
107
|
+
return files[rel]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Discovery ──
|
|
113
|
+
|
|
114
|
+
export interface ContentFile {
|
|
115
|
+
id: string
|
|
116
|
+
path: string
|
|
117
|
+
collection: string
|
|
118
|
+
file: string
|
|
119
|
+
/**
|
|
120
|
+
* Raw source (YAML frontmatter + Markdown body), already BOM/CRLF sanitised.
|
|
121
|
+
* Cached at discovery phase so compileCollections() avoids a second IO read
|
|
122
|
+
* per file; the split into frontmatter/body happens only when needed below.
|
|
123
|
+
*/
|
|
124
|
+
raw: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scan all collections via source. Files not belonging to any
|
|
129
|
+
* collection → throw (fail-fast, no silent drop).
|
|
130
|
+
*
|
|
131
|
+
* Identity derivation: document path convention via
|
|
132
|
+
* buildDocumentIdentity (id = rel minus .md minus trailing /index,
|
|
133
|
+
* path = '/' + id, file = rel, collection = name). Non-document
|
|
134
|
+
* domains are not supported in the batch path; hosts requiring
|
|
135
|
+
* custom identity shapes must construct CompileInput per file
|
|
136
|
+
* themselves — a thrown error makes the gap explicit.
|
|
137
|
+
*/
|
|
138
|
+
export async function discover (
|
|
139
|
+
source: SourceAdapter,
|
|
140
|
+
collections: ResolvedCollection[]
|
|
141
|
+
): Promise<ContentFile[]> {
|
|
142
|
+
const byDir = new Map(collections.map(c => [c.source, c]))
|
|
143
|
+
const all = await source.list()
|
|
144
|
+
const files: ContentFile[] = []
|
|
145
|
+
for (const rel of all) {
|
|
146
|
+
const top = rel.split('/')[0]
|
|
147
|
+
const col = byDir.get(top)
|
|
148
|
+
if (!col) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[discover] File '${rel}' belongs to no collection (top dir '${top}' undefined).`
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (col.domain !== 'document') {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`[discover] Domain '${col.domain}' discovery not yet implemented in batch path; ` +
|
|
156
|
+
`only 'document' domain is supported here. Non-document domains require ` +
|
|
157
|
+
`explicit identity construction.`
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
const identity = buildDocumentIdentity(rel, col.name)
|
|
161
|
+
const raw = await source.read(rel)
|
|
162
|
+
files.push({
|
|
163
|
+
id: identity.id,
|
|
164
|
+
path: identity.path,
|
|
165
|
+
collection: col.name,
|
|
166
|
+
file: identity.file,
|
|
167
|
+
raw
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
return files
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Batch compile orchestration ──
|
|
174
|
+
|
|
175
|
+
function checksum (s: string): string {
|
|
176
|
+
return createHash('md5').update(s, 'utf-8').digest('hex').slice(0, 12)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Batch compile: discover → per-file compile → write docs + index to store.
|
|
181
|
+
*
|
|
182
|
+
* For each ContentFile:
|
|
183
|
+
* 1. Build CompileInput (id = file.id, identity = buildDocumentIdentity,
|
|
184
|
+
* source = raw, schema = col.schema)
|
|
185
|
+
* 2. Call formal compile(input, registry)
|
|
186
|
+
* 3. On positive → collect PositiveArtifact + IndexEntry
|
|
187
|
+
* On error → collect error message (fail-fast: throw after all files processed)
|
|
188
|
+
*
|
|
189
|
+
* Store receives only positive artifacts. compile failures are aggregated
|
|
190
|
+
* and thrown as a single Error.
|
|
191
|
+
*/
|
|
192
|
+
export async function compileCollections (
|
|
193
|
+
source: SourceAdapter,
|
|
194
|
+
collections: ResolvedCollection[],
|
|
195
|
+
registry: DomainParserRegistry,
|
|
196
|
+
store: ArtifactStore
|
|
197
|
+
): Promise<{ docs: PositiveArtifact[]; index: ContentIndex }> {
|
|
198
|
+
const files = await discover(source, collections)
|
|
199
|
+
const docs: PositiveArtifact[] = []
|
|
200
|
+
const entries: IndexEntry[] = []
|
|
201
|
+
const errors: string[] = []
|
|
202
|
+
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
const col = collections.find(c => c.name === f.collection)!
|
|
205
|
+
// Reuse discovery-cached source; no second per-file IO read.
|
|
206
|
+
// `body` (post-frontmatter) is derived once solely for the
|
|
207
|
+
// change-detection checksum — compile() takes the full raw
|
|
208
|
+
// source per its stable CompileInput contract.
|
|
209
|
+
const { body } = splitFrontmatter(f.raw)
|
|
210
|
+
const input: CompileInput = {
|
|
211
|
+
id: f.id,
|
|
212
|
+
domain: col.domain,
|
|
213
|
+
identity: buildDocumentIdentity(f.file, col.name),
|
|
214
|
+
source: f.raw,
|
|
215
|
+
file: f.file,
|
|
216
|
+
schema: col.schema
|
|
217
|
+
}
|
|
218
|
+
const result = compile(input, registry)
|
|
219
|
+
if (result.kind !== 'positive') {
|
|
220
|
+
errors.push(` ✗ ${f.file}: ${result.error?.message ?? 'unknown'}`)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
const artifact = result.artifact as PositiveArtifact
|
|
224
|
+
docs.push(artifact)
|
|
225
|
+
entries.push({
|
|
226
|
+
id: f.id,
|
|
227
|
+
path: f.path,
|
|
228
|
+
collection: f.collection,
|
|
229
|
+
domain: col.domain,
|
|
230
|
+
file: f.file,
|
|
231
|
+
meta: result.meta,
|
|
232
|
+
checksum: checksum(body)
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (errors.length > 0) {
|
|
237
|
+
throw new Error(`Compile failed (${errors.length} error(s)):\n${errors.join('\n')}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const index: ContentIndex = {
|
|
241
|
+
generator: 'dexin-content',
|
|
242
|
+
builtAt: new Date().toISOString(),
|
|
243
|
+
entries
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await store.writeIndex(index)
|
|
247
|
+
for (const doc of docs) {
|
|
248
|
+
await store.writeDoc(doc)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { docs, index }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Incremental recompile ──
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Detect changed/removed docs by checksum diff against the previous index.
|
|
258
|
+
*
|
|
259
|
+
* Wraps compileCollections (which writes index + all docs to store), then
|
|
260
|
+
* reports which ids changed (checksum differs from prev) or were removed
|
|
261
|
+
* (present in prev, absent in next). Used by Dev flows (memory store)
|
|
262
|
+
* to drive incremental re-render signals. Writing all docs is acceptable
|
|
263
|
+
* for ephemeral in-memory Dev stores. Watcher / preview server
|
|
264
|
+
* integrations are out of scope.
|
|
265
|
+
*/
|
|
266
|
+
export async function recompileChanged (
|
|
267
|
+
source: SourceAdapter,
|
|
268
|
+
collections: ResolvedCollection[],
|
|
269
|
+
registry: DomainParserRegistry,
|
|
270
|
+
store: ArtifactStore
|
|
271
|
+
): Promise<{ changed: string[]; removed: string[] }> {
|
|
272
|
+
const prev = await store.readIndex()
|
|
273
|
+
const prevMap = new Map((prev?.entries ?? []).map(e => [e.id, e]))
|
|
274
|
+
|
|
275
|
+
const { index } = await compileCollections(source, collections, registry, store)
|
|
276
|
+
|
|
277
|
+
const nextIds = new Set(index.entries.map(e => e.id))
|
|
278
|
+
const changed: string[] = []
|
|
279
|
+
const removed: string[] = []
|
|
280
|
+
|
|
281
|
+
for (const e of index.entries) {
|
|
282
|
+
if (prevMap.get(e.id)?.checksum !== e.checksum) changed.push(e.id)
|
|
283
|
+
}
|
|
284
|
+
for (const id of prevMap.keys()) {
|
|
285
|
+
if (!nextIds.has(id)) removed.push(id)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { changed, removed }
|
|
289
|
+
}
|
package/core/compiler.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/core/compiler.ts
|
|
3
|
+
//
|
|
4
|
+
// Pipeline:
|
|
5
|
+
// read source → split frontmatter → project meta (SCHEMA-FREE) →
|
|
6
|
+
// validate schema (if any) → parse Markdown to DocumentAST →
|
|
7
|
+
// dispatch to domain parser → assemble PositiveArtifact (5 fields).
|
|
8
|
+
//
|
|
9
|
+
// The compiler does NOT hard-code domain parsers internally. Instead they
|
|
10
|
+
// are injected via `DomainParserRegistry` by the host runner, satisfying
|
|
11
|
+
// the dependency inversion principle.
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DomainParser,
|
|
16
|
+
DocumentContent,
|
|
17
|
+
Artifact,
|
|
18
|
+
ParseContext,
|
|
19
|
+
DocumentIdentity,
|
|
20
|
+
Meta,
|
|
21
|
+
ParseError,
|
|
22
|
+
Schema
|
|
23
|
+
} from './types'
|
|
24
|
+
import {
|
|
25
|
+
parseFrontmatter,
|
|
26
|
+
splitFrontmatter,
|
|
27
|
+
validateSchema
|
|
28
|
+
} from './frontmatter'
|
|
29
|
+
import { parseDocument } from './markdown'
|
|
30
|
+
|
|
31
|
+
export type ArtifactKind = 'positive' | 'error'
|
|
32
|
+
|
|
33
|
+
export class DomainParserRegistry {
|
|
34
|
+
private parsers = new Map<string, DomainParser>()
|
|
35
|
+
register (parser: DomainParser): void {
|
|
36
|
+
if (this.parsers.has(parser.domain)) {
|
|
37
|
+
throw new Error(`[compiler] Domain parser already registered for '${parser.domain}'`)
|
|
38
|
+
}
|
|
39
|
+
this.parsers.set(parser.domain, parser)
|
|
40
|
+
}
|
|
41
|
+
get (domain: string): DomainParser | undefined {
|
|
42
|
+
return this.parsers.get(domain)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Inputs required by `compile`. This is deliberately a plain object so the
|
|
48
|
+
* host runner can mix-and-match per-collection identity derivation rules.
|
|
49
|
+
*/
|
|
50
|
+
export interface CompileInput {
|
|
51
|
+
/** Stable document id — used in the output Artifact.id field */
|
|
52
|
+
id: string
|
|
53
|
+
/** The domain tag to compile under. Determines parser lookup. */
|
|
54
|
+
domain: string
|
|
55
|
+
/** Identity shape computed by host/discovery layer (document path convention). */
|
|
56
|
+
identity: DocumentIdentity
|
|
57
|
+
/** Raw Markdown source WITH frontmatter. */
|
|
58
|
+
source: string
|
|
59
|
+
/** File path used in error messages (relative, short form). */
|
|
60
|
+
file: string
|
|
61
|
+
/** Optional collection schema for frontmatter validation. */
|
|
62
|
+
schema?: Schema
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CompileResult {
|
|
66
|
+
kind: ArtifactKind
|
|
67
|
+
artifact: Artifact
|
|
68
|
+
/** Parsed DocumentAST; undefined if parsing failed before this stage. */
|
|
69
|
+
docAST?: DocumentContent
|
|
70
|
+
/** Parsed meta (SCHEMA-FREE scalar projection). Always present. */
|
|
71
|
+
meta: Meta
|
|
72
|
+
error?: Error & ParseError
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run the compile pipeline once. Fail-fast errors are returned wrapped as
|
|
77
|
+
* CompileResult with kind = 'error' — no throw out of this function, so the
|
|
78
|
+
* runner can log + diff against L5 descriptor.
|
|
79
|
+
*/
|
|
80
|
+
export function compile (
|
|
81
|
+
input: CompileInput,
|
|
82
|
+
registry: DomainParserRegistry
|
|
83
|
+
): CompileResult {
|
|
84
|
+
let meta: Meta = {}
|
|
85
|
+
try {
|
|
86
|
+
// 1. Frontmatter split + parse + SCHEMA-FREE scalar projection.
|
|
87
|
+
const { frontmatter, body } = splitFrontmatter(input.source)
|
|
88
|
+
meta = parseFrontmatter(frontmatter, input.file)
|
|
89
|
+
|
|
90
|
+
// 2. Schema validation (schema is validator-only — doesn't change meta).
|
|
91
|
+
validateSchema(meta, input.schema, input.file)
|
|
92
|
+
|
|
93
|
+
// 3. Markdown → DocumentAST (neutral).
|
|
94
|
+
// Note: the GENERIC layer does NOT throw for md h5/h6 — it produces a
|
|
95
|
+
// structural HeadingBlock with synthetic level=4 and attaches the
|
|
96
|
+
// original md depth to the explicit HeadingBlock.mdDepth optional
|
|
97
|
+
// field. Fail-fast for out-of-range heading depths is the ACTIVE
|
|
98
|
+
// DOMAIN PARSER's responsibility.
|
|
99
|
+
const docAST = parseDocument(body, input.file)
|
|
100
|
+
|
|
101
|
+
// 4. Domain parser dispatch.
|
|
102
|
+
const parser = registry.get(input.domain)
|
|
103
|
+
if (!parser) {
|
|
104
|
+
throw mkError(
|
|
105
|
+
`COMPILER_NO_DOMAIN_PARSER`,
|
|
106
|
+
`[compiler] No domain parser registered for '${input.domain}' — file: ${input.file}`,
|
|
107
|
+
input.file
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const ctx: ParseContext = {
|
|
111
|
+
file: input.file,
|
|
112
|
+
schema: input.schema,
|
|
113
|
+
meta
|
|
114
|
+
}
|
|
115
|
+
const content = parser.parse(docAST, ctx)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
kind: 'positive',
|
|
119
|
+
meta,
|
|
120
|
+
docAST,
|
|
121
|
+
artifact: {
|
|
122
|
+
id: input.id,
|
|
123
|
+
domain: input.domain,
|
|
124
|
+
identity: input.identity,
|
|
125
|
+
meta,
|
|
126
|
+
content: { version: 1, blocks: content.blocks }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const err = normaliseError(e, input.file)
|
|
131
|
+
return {
|
|
132
|
+
kind: 'error',
|
|
133
|
+
meta,
|
|
134
|
+
error: err,
|
|
135
|
+
artifact: {
|
|
136
|
+
id: input.id,
|
|
137
|
+
domain: input.domain,
|
|
138
|
+
identity: input.identity,
|
|
139
|
+
meta,
|
|
140
|
+
kind: 'COMPILE_ERROR',
|
|
141
|
+
error: { code: err.code, message: err.message }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Error helpers ────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function mkError (code: string, message: string, file: string): ParseError & Error {
|
|
150
|
+
const e = new Error(message) as ParseError & Error
|
|
151
|
+
e.code = code
|
|
152
|
+
e.file = file
|
|
153
|
+
return e
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normaliseError (e: unknown, file: string): ParseError & Error {
|
|
157
|
+
if (e && typeof e === 'object') {
|
|
158
|
+
const obj = e as Record<string, unknown>
|
|
159
|
+
if ('code' in obj && 'message' in obj) {
|
|
160
|
+
// already a ParseError — ensure .file set
|
|
161
|
+
const err = e as ParseError & Error
|
|
162
|
+
if (!err.file) err.file = file
|
|
163
|
+
return err
|
|
164
|
+
}
|
|
165
|
+
if (e instanceof Error) {
|
|
166
|
+
const err = e as ParseError & Error
|
|
167
|
+
const obj = e as unknown as Record<string, unknown>
|
|
168
|
+
err.code = (obj.code as string) ?? 'UNKNOWN_ERROR'
|
|
169
|
+
err.file = file
|
|
170
|
+
return err
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return mkError('UNKNOWN_ERROR', String(e), file)
|
|
174
|
+
}
|