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/LICENSE +202 -21
- package/bin/dexin-content.mjs +6 -0
- package/cli/build.ts +188 -0
- package/cli/check.ts +124 -0
- package/cli/main.ts +207 -0
- package/cli/package.ts +137 -0
- package/cli/serve.ts +135 -0
- package/cli/validate.ts +136 -0
- package/{collection.ts → core/artifact/collection.ts} +297 -289
- package/{diff.ts → core/artifact/diff.ts} +125 -124
- package/{query.ts → core/artifact/query.ts} +56 -56
- package/{store.ts → core/artifact/store.ts} +128 -128
- package/core/{compiler.ts → compiler/compiler.ts} +24 -21
- package/core/compiler/discovery.ts +116 -0
- package/core/{frontmatter.ts → parser/frontmatter.ts} +145 -144
- package/core/{markdown.ts → parser/markdown.ts} +68 -36
- package/core/types/index.ts +131 -0
- package/core/types/lessonAST.ts +264 -0
- package/core/validator/integrity.ts +199 -0
- package/domains/document/identity.ts +27 -0
- package/domains/document/index.ts +5 -0
- package/domains/document/parser.ts +105 -0
- package/domains/lesson/blocks.ts +205 -0
- package/domains/lesson/identity.ts +40 -0
- package/domains/lesson/index.ts +7 -0
- package/domains/lesson/parser.ts +200 -0
- package/index.ts +57 -8
- package/package.json +82 -68
- package/core/discovery.ts +0 -110
- package/core/types.ts +0 -162
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|
package/cli/validate.ts
ADDED
|
@@ -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
|
+
}
|