dexin-content 0.1.1 → 0.2.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/cli/main.ts ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env tsx
2
+ // ─────────────────────────────────────────────────────────────
3
+ // dexin-content CLI main entry
4
+ //
5
+ // Modes:
6
+ // Single-file: tsx cli/main.ts build <file.md> --domain lesson --out <path>
7
+ // Directory: tsx cli/main.ts build <dir> --out <dir> [--manifest <path>]
8
+ // ─────────────────────────────────────────────────────────────
9
+
10
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
11
+ import { existsSync } from 'node:fs'
12
+ import { resolve, isAbsolute, dirname, basename } from 'node:path'
13
+
14
+ import { DomainParserRegistry, compile } from '../core/compiler/compiler'
15
+ import type { CompileInput } from '../core/compiler/compiler'
16
+ import { lessonParser, buildLessonIdentity } from '../domains/lesson/index'
17
+ import { documentParser, buildDocumentIdentity } from '../domains/document/index'
18
+ import { flowBuild } from './build'
19
+ import { flowPackage } from './package'
20
+
21
+ type CmdArgs = Record<string, string | boolean>
22
+
23
+ function parseArgs(argv: string[]): { command: string; args: CmdArgs; positionals: string[] } {
24
+ const command = argv[0] ?? ''
25
+ const positionals: string[] = []
26
+ const args: CmdArgs = {}
27
+ for (let i = 1; i < argv.length; i++) {
28
+ const a = argv[i]
29
+ if (a.startsWith('--')) {
30
+ const key = a.slice(2)
31
+ const next = argv[i + 1]
32
+ if (next && !next.startsWith('--')) {
33
+ args[key] = next; i++
34
+ } else {
35
+ args[key] = true
36
+ }
37
+ } else {
38
+ positionals.push(a)
39
+ }
40
+ }
41
+ return { command, args, positionals }
42
+ }
43
+
44
+ function printHelp() {
45
+ console.log(`dexin-content CLI — Independent content compilation tool
46
+
47
+ Usage:
48
+ dexin-content <command> [options] (npm bin; or locally: npx tsx cli/main.ts)
49
+
50
+ Commands:
51
+ build Compile markdown to artifacts
52
+ help Show this help
53
+
54
+ Build modes:
55
+ Single-file:
56
+ dexin-content build <file.md> --domain <lesson|document> --out <path>
57
+
58
+ Directory tree:
59
+ dexin-content build <dir> --out <dir> [--manifest <path>] [--lessons <dir>] [--documents <dir>]
60
+
61
+ Options:
62
+ --domain <name> Domain parser (lesson | document). Required for single-file mode.
63
+ --out <path> Output path (file for single, directory for batch).
64
+ --manifest <path> Content manifest JSON for packaging.
65
+ --lessons <dir> Relative path to lessons directory (default: lessons).
66
+ --documents <dir> Relative path to documents directory.
67
+ `)
68
+ }
69
+
70
+ async function runSingleFile(filePath: string, domain: string, outPath?: string) {
71
+ const absPath = isAbsolute(filePath) ? filePath : resolve(filePath)
72
+ if (!existsSync(absPath)) {
73
+ console.error(`Error: file not found: ${absPath}`)
74
+ process.exit(1)
75
+ }
76
+
77
+ const source = await readFile(absPath, 'utf-8')
78
+ const relPath = basename(absPath)
79
+
80
+ const registry = new DomainParserRegistry()
81
+ registry.register(lessonParser)
82
+ registry.register(documentParser)
83
+
84
+ let identity: any
85
+ let fixture: string
86
+
87
+ if (domain === 'lesson') {
88
+ // Try to parse lesson identity from parent directories
89
+ const parent = dirname(absPath)
90
+ const grandParent = dirname(parent)
91
+ const topicSlug = basename(grandParent)
92
+ const chapterSlug = basename(parent)
93
+ const lessonSlug = basename(absPath).replace(/\.md$/, '')
94
+ fixture = `${topicSlug}/${chapterSlug}/${lessonSlug}`
95
+ identity = buildLessonIdentity(lessonSlug, topicSlug, chapterSlug, relPath)
96
+ } else if (domain === 'document') {
97
+ const slug = basename(absPath).replace(/\.md$/, '')
98
+ fixture = slug
99
+ identity = buildDocumentIdentity(slug, relPath)
100
+ } else {
101
+ console.error(`Error: unknown domain '${domain}'`)
102
+ process.exit(1)
103
+ }
104
+
105
+ const input: CompileInput = {
106
+ fixture,
107
+ domain,
108
+ identity,
109
+ source,
110
+ file: relPath
111
+ }
112
+
113
+ const result = compile(input, registry)
114
+
115
+ if (result.kind === 'error') {
116
+ const err = result.error
117
+ console.error(`Compile error: ${err?.code ?? 'UNKNOWN'} — ${err?.message ?? err}`)
118
+ process.exit(1)
119
+ }
120
+
121
+ const output = JSON.stringify(result.artifact, null, 2) + '\n'
122
+
123
+ if (outPath) {
124
+ const absOut = isAbsolute(outPath) ? outPath : resolve(outPath)
125
+ await mkdir(dirname(absOut), { recursive: true })
126
+ await writeFile(absOut, output, 'utf-8')
127
+ console.log(`Compiled ${fixture} → ${absOut}`)
128
+ } else {
129
+ process.stdout.write(output)
130
+ }
131
+ }
132
+
133
+ async function runDirectory(rootDir: string, args: CmdArgs) {
134
+ const root = isAbsolute(rootDir) ? rootDir : resolve(rootDir)
135
+ if (!existsSync(root)) {
136
+ console.error(`Error: directory not found: ${root}`)
137
+ process.exit(1)
138
+ }
139
+
140
+ const outDirRaw = args.out as string | undefined
141
+ const outDir = outDirRaw
142
+ ? (isAbsolute(outDirRaw) ? outDirRaw : resolve(outDirRaw))
143
+ : resolve(root, 'output', 'flow')
144
+
145
+ const registry = new DomainParserRegistry()
146
+ registry.register(lessonParser)
147
+ registry.register(documentParser)
148
+
149
+ const lessonResult = await flowBuild({
150
+ root,
151
+ lessonsDir: args.lessons as string | undefined,
152
+ documentDir: args.documents as string | undefined,
153
+ outDir,
154
+ domain: {
155
+ lesson: lessonParser,
156
+ document: documentParser,
157
+ buildLessonIdentity
158
+ }
159
+ })
160
+
161
+ console.log(`Build complete: ${lessonResult.compiled} artifacts in ${lessonResult.durationMs}ms`)
162
+
163
+ if (args.manifest) {
164
+ const manifestPath = isAbsolute(args.manifest as string)
165
+ ? args.manifest as string
166
+ : resolve(args.manifest as string)
167
+ const pkg = await flowPackage({
168
+ storeDir: outDir,
169
+ manifestPath,
170
+ outFile: resolve(outDir, 'manifest.json')
171
+ })
172
+ console.log(`Packaged manifest.json: ${pkg.lessons.length} lessons, ${pkg.pages.length} pages`)
173
+ }
174
+ }
175
+
176
+ // ── Main ──────────────────────────────────────────────────
177
+
178
+ const { command, args, positionals } = parseArgs(process.argv.slice(2))
179
+
180
+ if (command === 'help' || !command) {
181
+ printHelp()
182
+ process.exit(0)
183
+ }
184
+
185
+ if (command === 'build') {
186
+ const target = positionals[0]
187
+ if (!target) {
188
+ console.error('Error: missing target. Usage: build <file.md | directory>')
189
+ process.exit(1)
190
+ }
191
+
192
+ const isFile = target.endsWith('.md')
193
+ if (isFile) {
194
+ const domain = args.domain as string | undefined
195
+ if (!domain) {
196
+ console.error('Error: --domain required for single-file mode')
197
+ process.exit(1)
198
+ }
199
+ await runSingleFile(target, domain, args.out as string | undefined)
200
+ } else {
201
+ await runDirectory(target, args)
202
+ }
203
+ } else {
204
+ console.error(`Unknown command: ${command}`)
205
+ printHelp()
206
+ process.exit(1)
207
+ }
package/cli/package.ts ADDED
@@ -0,0 +1,137 @@
1
+ // flow/cli/package.ts
2
+ //极薄:flowPackage — 从已编译 Artifact Store 读 → 按 manifest skeleton 组装 ContentPackage JSON
3
+ //
4
+ // 逻辑来源:dexinlabs-content/dexin-content/build-content-package.ts
5
+ // (读 store、过滤 lesson domain、按 manifest 组装 lessons[] + pages[])
6
+
7
+ import { mkdir, writeFile, readFile } from 'node:fs/promises'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { createFsArtifactStore } from '../core/artifact/store.js'
10
+ import { ContentQuery } from '../core/artifact/query.js'
11
+ import type { PositiveArtifact } from '../core/types/index.js'
12
+
13
+ export interface FlowManifest {
14
+ courses: { slug: string; title: string; order: number }[]
15
+ topics: { slug: string; title: string; order: number }[]
16
+ chapters: { slug: string; title: string; order: number; topic_slug: string }[]
17
+ }
18
+
19
+ export interface OutLesson {
20
+ topic_slug: string
21
+ chapter_slug: string
22
+ slug: string
23
+ title: string
24
+ order: number
25
+ content: { version: number; blocks: unknown[] }
26
+ }
27
+
28
+ export interface OutPage {
29
+ slug: string
30
+ title: string
31
+ content: { version: number; blocks: unknown[] }
32
+ }
33
+
34
+ export interface OutPackage {
35
+ protocol_version: 1
36
+ ast_version: 1
37
+ manifest: FlowManifest
38
+ lessons: OutLesson[]
39
+ pages: OutPage[]
40
+ exercises: unknown[]
41
+ }
42
+
43
+ export interface FlowPackageInput {
44
+ /** Artifact Store 目录(flowBuild 的 outDir) */
45
+ storeDir: string
46
+ /** content-manifest.json 路径 */
47
+ manifestPath: string
48
+ /** ContentPackage JSON 输出路径 */
49
+ outFile: string
50
+ }
51
+
52
+ export async function flowPackage(input: FlowPackageInput): Promise<OutPackage> {
53
+ const store = createFsArtifactStore(input.storeDir)
54
+ const query = new ContentQuery(store)
55
+
56
+ // 1. 读 manifest skeleton
57
+ let manifest: FlowManifest
58
+ try {
59
+ manifest = JSON.parse(await readFile(input.manifestPath, 'utf-8'))
60
+ } catch (e) {
61
+ throw new Error(`[flowPackage] manifest 读取失败: ${(e as Error).message}`)
62
+ }
63
+
64
+ // 2. 读 artifact index
65
+ let entries
66
+ try {
67
+ entries = await query.list()
68
+ } catch (e) {
69
+ throw new Error(
70
+ `[flowPackage] artifact store 读取失败(是否先跑 flowBuild?): ${(e as Error).message}`
71
+ )
72
+ }
73
+
74
+ // 3. 按 domain 分类
75
+ const lessonEntries = entries.filter(e => e.domain === 'lesson')
76
+ const docEntries = entries.filter(e => e.domain === 'document')
77
+
78
+ // 4. 组装 lessons[]
79
+ const manifestTopicSlugs = new Set(manifest.topics.map(t => t.slug))
80
+ const manifestChapterKeys = new Set(manifest.chapters.map(c => `${c.topic_slug}/${c.slug}`))
81
+ const lessons: OutLesson[] = []
82
+ const lessonsByOrder: OutLesson[] = []
83
+ let errors = 0
84
+
85
+ for (const entry of lessonEntries) {
86
+ const doc: PositiveArtifact | null = await query.byId(entry.id)
87
+ if (!doc) { errors++; continue }
88
+ if (!doc.content || doc.content.version !== 1 || !Array.isArray(doc.content.blocks)) { errors++; continue }
89
+
90
+ const { topic_slug, chapter_slug, slug } = doc.identity as {
91
+ topic_slug: string; chapter_slug: string; slug: string
92
+ }
93
+
94
+ if (!manifestTopicSlugs.has(topic_slug)) { errors++; continue }
95
+ if (!manifestChapterKeys.has(`${topic_slug}/${chapter_slug}`)) { errors++; continue }
96
+
97
+ const title = String((doc.meta as { title?: unknown })?.title ?? slug)
98
+ const order = Number((doc.meta as { order?: unknown })?.order ?? 0)
99
+
100
+ const pkgLesson: OutLesson = {
101
+ slug, title, topic_slug, chapter_slug, order,
102
+ content: { version: doc.content.version, blocks: doc.content.blocks },
103
+ }
104
+ lessons.push(pkgLesson)
105
+ }
106
+
107
+ // 5. 组装 pages[]
108
+ const pages: OutPage[] = []
109
+ for (const entry of docEntries) {
110
+ const doc = await query.byId(entry.id)
111
+ if (!doc) continue
112
+ if (!doc.content || doc.content.version !== 1 || !Array.isArray(doc.content.blocks)) continue
113
+ const ident = doc.identity as { slug?: string; id?: string } | undefined
114
+ const slug = ident?.slug ?? (ident?.id ?? entry.id).split('/').pop()!
115
+ const title = String((doc.meta as { title?: unknown })?.title ?? slug)
116
+ pages.push({ slug, title, content: { version: doc.content.version, blocks: doc.content.blocks } })
117
+ }
118
+
119
+ if (errors > 0) {
120
+ throw new Error(`[flowPackage] FAIL — ${errors} 个错误`)
121
+ }
122
+
123
+ // 6. 组装 + 写文件
124
+ const pkg: OutPackage = {
125
+ protocol_version: 1,
126
+ ast_version: 1,
127
+ manifest,
128
+ lessons,
129
+ pages,
130
+ exercises: [],
131
+ }
132
+
133
+ await mkdir(dirname(resolve(input.outFile)), { recursive: true })
134
+ await writeFile(input.outFile, JSON.stringify(pkg, null, 2) + '\n', 'utf-8')
135
+
136
+ return pkg
137
+ }
package/cli/serve.ts ADDED
@@ -0,0 +1,135 @@
1
+ // flow/cli/serve.ts
2
+ //极薄:flowServe — Artifact Store 只读 dev preview server
3
+ //
4
+ // 设计取舍:
5
+ // Phase 2 第一版只做"读已编译 store → HTTP 预览"。不 watch、不 SSE。
6
+ // 用户编辑 md → 手动跑 flow build → browser refresh。
7
+ // watch/SSE 在后续迭代加(需要 chokidar 依赖,极简原则)。
8
+ //
9
+ // 路由:
10
+ // GET / — 索引页(所有已编译文档列表)
11
+ // GET /view/<path> — 渲染后的 HTML
12
+ // GET /api/index — JSON 索引
13
+ // GET /api/doc/:id — JSON artifact
14
+
15
+ import http from 'node:http'
16
+ import { isAbsolute, resolve } from 'node:path'
17
+ import { createFsArtifactStore } from '../core/artifact/store.js'
18
+ import { ContentQuery } from '../core/artifact/query.js'
19
+ import type { PositiveArtifact } from '../core/types/index.js'
20
+
21
+ export interface FlowServeInput {
22
+ /** Artifact Store 目录 */
23
+ storeDir: string
24
+ /** HTTP 端口(默认 3714) */
25
+ port?: number
26
+ }
27
+
28
+ export interface FlowServeResult {
29
+ port: number
30
+ server: http.Server
31
+ }
32
+
33
+ const DESIGN_TOKENS = `
34
+ :root {
35
+ --color-primary: #3b6fe0;
36
+ --color-text-primary: #18181b;
37
+ --color-text-secondary: #52525b;
38
+ --color-bg-primary: #fbfbfa;
39
+ --color-border: #e4e4e7;
40
+ --color-code-bg: #1c1c1f;
41
+ --color-code-text: #e4e4e7;
42
+ --font-content: "Inter", "Noto Sans SC", -apple-system, sans-serif;
43
+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
44
+ }
45
+ * { box-sizing: border-box; margin: 0; padding: 0; }
46
+ body { font-family: var(--font-content); color: var(--color-text-primary); background: var(--color-bg-primary); line-height: 1.7; max-width: 800px; margin: 0 auto; padding: 2rem 1.5rem; }
47
+ h1 { font-size: 1.875rem; font-weight: 700; margin: 1rem 0 0.5rem; }
48
+ h2 { font-size: 1.5rem; font-weight: 700; margin: 1.5rem 0 0.5rem; }
49
+ h3 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; }
50
+ p { margin: 0.5rem 0; }
51
+ blockquote { border-left: 4px solid var(--color-border); padding: 0.5rem 1rem; margin: 1rem 0; color: var(--color-text-secondary); background: #f4f4f5; border-radius: 0 0.5rem 0.5rem 0; }
52
+ ul, ol { padding-left: 1.5rem; margin: 0.5rem 0; }
53
+ code { font-family: var(--font-mono); font-size: 0.875em; }
54
+ p code, li code { background: #f4f4f5; padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
55
+ pre { background: var(--color-code-bg); color: var(--color-code-text); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; font-family: var(--font-mono); font-size: 0.875rem; }
56
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
57
+ th, td { border: 1px solid var(--color-border); padding: 0.5rem 0.75rem; text-align: left; }
58
+ th { background: #f4f4f5; font-weight: 600; }
59
+ .badge { display: inline-block; font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 1rem; background: #f4f4f5; color: var(--color-text-secondary); margin-left: 0.5rem; }
60
+ .nav-list { list-style: none; padding: 0; }
61
+ .nav-list li { padding: 0.25rem 0; }
62
+ .nav-list a { color: var(--color-primary); }
63
+ `
64
+
65
+ export async function flowServe(input: FlowServeInput): Promise<FlowServeResult> {
66
+ const storeDir = isAbsolute(input.storeDir) ? input.storeDir : resolve(input.storeDir)
67
+ const port = input.port ?? 3714
68
+ const store = createFsArtifactStore(storeDir)
69
+ const query = new ContentQuery(store)
70
+
71
+ const server = http.createServer(async (req, res) => {
72
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`)
73
+ const pathname = url.pathname
74
+
75
+ try {
76
+ if (pathname === '/api/index') {
77
+ const entries = await query.list()
78
+ res.writeHead(200, { 'Content-Type': 'application/json' })
79
+ res.end(JSON.stringify({ entries }, null, 2))
80
+ return
81
+ }
82
+
83
+ if (pathname.startsWith('/api/doc/')) {
84
+ const id = decodeURIComponent(pathname.slice('/api/doc/'.length))
85
+ const doc = await query.byId(id)
86
+ res.writeHead(200, { 'Content-Type': 'application/json' })
87
+ res.end(JSON.stringify(doc, null, 2))
88
+ return
89
+ }
90
+
91
+ if (pathname === '/' || pathname === '') {
92
+ const entries = await query.list()
93
+ const items = entries
94
+ .map(e => `<li><a href="/view${e.path}">${escMeta(e.meta.title)}</a> <span class="badge">${e.domain}</span></li>`)
95
+ .join('')
96
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
97
+ res.end(htmlShell('Flow Dev Preview', `<h1>Dev Preview</h1><p>${entries.length} document(s)</p><ul class="nav-list">${items}</ul>`))
98
+ return
99
+ }
100
+
101
+ if (pathname.startsWith('/view/')) {
102
+ const docPath = decodeURIComponent(pathname.slice('/view'.length))
103
+ const doc = await query.byPath(docPath)
104
+ if (!doc) {
105
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
106
+ res.end(htmlShell('Not Found', `<h1>404</h1><p>Document not found: ${docPath}</p>`))
107
+ return
108
+ }
109
+ const title = String((doc.meta as { title?: unknown })?.title ?? doc.fixture)
110
+ const blockCount = (doc.content as { blocks?: unknown[] } | undefined)?.blocks?.length ?? 0
111
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
112
+ res.end(htmlShell(title, `<h1>${title}</h1><p><span class="badge">${doc.domain}</span> ${blockCount} blocks</p><pre style="white-space:pre-wrap;">${JSON.stringify(doc.content, null, 2)}</pre>`))
113
+ return
114
+ }
115
+
116
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
117
+ res.end('Not Found')
118
+ } catch (e) {
119
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
120
+ res.end(String((e as Error).message))
121
+ }
122
+ })
123
+
124
+ await new Promise<void>((resolveFn) => server.listen(port, () => resolveFn()))
125
+
126
+ return { port, server }
127
+ }
128
+
129
+ function escMeta(s: unknown): string {
130
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
131
+ }
132
+
133
+ function htmlShell(title: string, body: string): string {
134
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title><style>${DESIGN_TOKENS}</style></head><body>${body}</body></html>`
135
+ }
@@ -0,0 +1,136 @@
1
+ // flow/cli/validate.ts
2
+ //极薄:flowValidate — ContentPackage JSON 结构校验
3
+ //
4
+ // 只做 protocol_version / ast_version / manifest shape / lessons shape 检查。
5
+ // 不做 dexinlabs Gate3 V1_EXPECTED_TOPIC_ORDER(宿主业务 gate,Flow 不管)。
6
+
7
+ import { readFile } from 'node:fs/promises'
8
+
9
+ export interface FlowValidateResult {
10
+ pass: boolean
11
+ errors: string[]
12
+ warnings: string[]
13
+ summary: {
14
+ protocol_version: number
15
+ ast_version: number
16
+ lessons: number
17
+ pages: number
18
+ topics: number
19
+ chapters: number
20
+ totalBlocks: number
21
+ }
22
+ }
23
+
24
+ export async function flowValidate(input: string | Buffer | object): Promise<FlowValidateResult> {
25
+ let pkg: any
26
+ let sourceLabel: string
27
+
28
+ if (typeof input === 'string') {
29
+ // 文件路径
30
+ try {
31
+ const text = await readFile(input, 'utf-8')
32
+ pkg = JSON.parse(text)
33
+ sourceLabel = input
34
+ } catch (e) {
35
+ return {
36
+ pass: false,
37
+ errors: [`无法读取/解析 JSON: ${(e as Error).message}`],
38
+ warnings: [],
39
+ summary: { protocol_version: 0, ast_version: 0, lessons: 0, pages: 0, topics: 0, chapters: 0, totalBlocks: 0 }
40
+ }
41
+ }
42
+ } else if (input && typeof input === 'object') {
43
+ pkg = input
44
+ sourceLabel = '(passed object)'
45
+ } else {
46
+ return {
47
+ pass: false,
48
+ errors: [`无效输入: 需要 JSON 文件路径或已解析对象`],
49
+ warnings: [],
50
+ summary: { protocol_version: 0, ast_version: 0, lessons: 0, pages: 0, topics: 0, chapters: 0, totalBlocks: 0 }
51
+ }
52
+ }
53
+
54
+ const errors: string[] = []
55
+ const warnings: string[] = []
56
+
57
+ // 1. protocol_version
58
+ if (pkg.protocol_version !== 1) {
59
+ errors.push(`protocol_version 应为 1, 实际: ${pkg.protocol_version}`)
60
+ }
61
+
62
+ // 2. ast_version
63
+ if (pkg.ast_version !== 1) {
64
+ errors.push(`ast_version 应为 1, 实际: ${pkg.ast_version}`)
65
+ }
66
+
67
+ // 3. manifest shape
68
+ if (!pkg.manifest || typeof pkg.manifest !== 'object') {
69
+ errors.push('manifest 缺失或不是对象')
70
+ } else {
71
+ if (!Array.isArray(pkg.manifest.topics)) errors.push('manifest.topics 缺失或不是数组')
72
+ if (!Array.isArray(pkg.manifest.chapters)) errors.push('manifest.chapters 缺失或不是数组')
73
+ if (!Array.isArray(pkg.manifest.courses)) warnings.push('manifest.courses 缺失(可选)')
74
+ }
75
+
76
+ // 4. lessons shape
77
+ if (!Array.isArray(pkg.lessons)) {
78
+ errors.push('lessons 缺失或不是数组')
79
+ } else {
80
+ const lessons = pkg.lessons as any[]
81
+ for (let i = 0; i < lessons.length; i++) {
82
+ const l = lessons[i]
83
+ if (!l.slug) errors.push(`lessons[${i}].slug 缺失`)
84
+ if (!l.title) errors.push(`lessons[${i}].title 缺失`)
85
+ if (!l.topic_slug) errors.push(`lessons[${i}].topic_slug 缺失`)
86
+ if (!l.chapter_slug) errors.push(`lessons[${i}].chapter_slug 缺失`)
87
+ if (typeof l.order !== 'number') errors.push(`lessons[${i}].order 缺失或非数字`)
88
+ if (!l.content || l.content.version !== 1 || !Array.isArray(l.content.blocks)) {
89
+ errors.push(`lessons[${i}].content shape 无效`)
90
+ }
91
+ }
92
+ }
93
+
94
+ // 5. pages shape (optional)
95
+ if (pkg.pages && !Array.isArray(pkg.pages)) {
96
+ errors.push('pages 不是数组(应为数组或缺失)')
97
+ } else if (pkg.pages) {
98
+ for (let i = 0; i < pkg.pages.length; i++) {
99
+ const p = pkg.pages[i]
100
+ if (!p.slug) errors.push(`pages[${i}].slug 缺失`)
101
+ if (!p.title) errors.push(`pages[${i}].title 缺失`)
102
+ if (!p.content || p.content.version !== 1 || !Array.isArray(p.content.blocks)) {
103
+ errors.push(`pages[${i}].content shape 无效`)
104
+ }
105
+ }
106
+ }
107
+
108
+ // 6. exercises
109
+ if (!pkg.exercises) {
110
+ warnings.push('exercises 缺失(MVP 预期为 [])')
111
+ }
112
+
113
+ // Summary
114
+ const lessons = Array.isArray(pkg.lessons) ? pkg.lessons : []
115
+ const pages = Array.isArray(pkg.pages) ? pkg.pages : []
116
+ const manifest = pkg.manifest ?? {}
117
+ const topics = Array.isArray(manifest.topics) ? manifest.topics : []
118
+ const chapters = Array.isArray(manifest.chapters) ? manifest.chapters : []
119
+ const totalBlocks = lessons.reduce((n: number, l: any) => n + (l.content?.blocks?.length ?? 0), 0)
120
+ + pages.reduce((n: number, p: any) => n + (p.content?.blocks?.length ?? 0), 0)
121
+
122
+ return {
123
+ pass: errors.length === 0,
124
+ errors,
125
+ warnings,
126
+ summary: {
127
+ protocol_version: pkg.protocol_version ?? 0,
128
+ ast_version: pkg.ast_version ?? 0,
129
+ lessons: lessons.length,
130
+ pages: pages.length,
131
+ topics: topics.length,
132
+ chapters: chapters.length,
133
+ totalBlocks,
134
+ }
135
+ }
136
+ }