dexin-content 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/main.ts CHANGED
@@ -1,207 +1,197 @@
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
- }
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 { flowBuild } from './build'
18
+ import { flowPackage } from './package'
19
+
20
+ type CmdArgs = Record<string, string | boolean>
21
+
22
+ function parseArgs(argv: string[]): { command: string; args: CmdArgs; positionals: string[] } {
23
+ const command = argv[0] ?? ''
24
+ const positionals: string[] = []
25
+ const args: CmdArgs = {}
26
+ for (let i = 1; i < argv.length; i++) {
27
+ const a = argv[i]
28
+ if (a.startsWith('--')) {
29
+ const key = a.slice(2)
30
+ const next = argv[i + 1]
31
+ if (next && !next.startsWith('--')) {
32
+ args[key] = next; i++
33
+ } else {
34
+ args[key] = true
35
+ }
36
+ } else {
37
+ positionals.push(a)
38
+ }
39
+ }
40
+ return { command, args, positionals }
41
+ }
42
+
43
+ function printHelp() {
44
+ console.log(`dexin-content CLI — Independent content compilation tool
45
+
46
+ Usage:
47
+ dexin-content <command> [options] (npm bin; or locally: npx tsx cli/main.ts)
48
+
49
+ Commands:
50
+ build Compile markdown to artifacts
51
+ help Show this help
52
+
53
+ Build modes:
54
+ Single-file:
55
+ dexin-content build <file.md> --domain lesson --out <path>
56
+
57
+ Directory tree:
58
+ dexin-content build <dir> --out <dir> [--manifest <path>] [--lessons <dir>]
59
+
60
+ Options:
61
+ --domain <name> Domain parser (lesson). Required for single-file mode.
62
+ --out <path> Output path (file for single, directory for batch).
63
+ --manifest <path> Content manifest JSON for packaging.
64
+ --lessons <dir> Relative path to lessons directory (default: lessons).
65
+ `)
66
+ }
67
+
68
+ async function runSingleFile(filePath: string, domain: string, outPath?: string) {
69
+ const absPath = isAbsolute(filePath) ? filePath : resolve(filePath)
70
+ if (!existsSync(absPath)) {
71
+ console.error(`Error: file not found: ${absPath}`)
72
+ process.exit(1)
73
+ }
74
+
75
+ const source = await readFile(absPath, 'utf-8')
76
+ const relPath = basename(absPath)
77
+
78
+ const registry = new DomainParserRegistry()
79
+ registry.register(lessonParser)
80
+
81
+ let identity: any
82
+ let fixture: string
83
+
84
+ if (domain === 'lesson') {
85
+ // Try to parse lesson identity from parent directories
86
+ const parent = dirname(absPath)
87
+ const grandParent = dirname(parent)
88
+ const topicSlug = basename(grandParent)
89
+ const chapterSlug = basename(parent)
90
+ const lessonSlug = basename(absPath).replace(/\.md$/, '')
91
+ fixture = `${topicSlug}/${chapterSlug}/${lessonSlug}`
92
+ identity = buildLessonIdentity(lessonSlug, topicSlug, chapterSlug, relPath)
93
+ } else {
94
+ console.error(`Error: unknown domain '${domain}'`)
95
+ process.exit(1)
96
+ }
97
+
98
+ const input: CompileInput = {
99
+ fixture,
100
+ domain,
101
+ identity,
102
+ source,
103
+ file: relPath
104
+ }
105
+
106
+ const result = compile(input, registry)
107
+
108
+ if (result.kind === 'error') {
109
+ const err = result.error
110
+ console.error(`Compile error: ${err?.code ?? 'UNKNOWN'} — ${err?.message ?? err}`)
111
+ process.exit(1)
112
+ }
113
+
114
+ const output = JSON.stringify(result.artifact, null, 2) + '\n'
115
+
116
+ if (outPath) {
117
+ const absOut = isAbsolute(outPath) ? outPath : resolve(outPath)
118
+ await mkdir(dirname(absOut), { recursive: true })
119
+ await writeFile(absOut, output, 'utf-8')
120
+ console.log(`Compiled ${fixture} → ${absOut}`)
121
+ } else {
122
+ process.stdout.write(output)
123
+ }
124
+ }
125
+
126
+ async function runDirectory(rootDir: string, args: CmdArgs) {
127
+ const root = isAbsolute(rootDir) ? rootDir : resolve(rootDir)
128
+ if (!existsSync(root)) {
129
+ console.error(`Error: directory not found: ${root}`)
130
+ process.exit(1)
131
+ }
132
+
133
+ const outDirRaw = args.out as string | undefined
134
+ const outDir = outDirRaw
135
+ ? (isAbsolute(outDirRaw) ? outDirRaw : resolve(outDirRaw))
136
+ : resolve(root, 'output', 'flow')
137
+
138
+ const registry = new DomainParserRegistry()
139
+ registry.register(lessonParser)
140
+
141
+ const lessonResult = await flowBuild({
142
+ root,
143
+ lessonsDir: args.lessons as string | undefined,
144
+ outDir,
145
+ domain: {
146
+ lesson: lessonParser,
147
+ buildLessonIdentity
148
+ }
149
+ })
150
+
151
+ console.log(`Build complete: ${lessonResult.compiled} artifacts in ${lessonResult.durationMs}ms`)
152
+
153
+ if (args.manifest) {
154
+ const manifestPath = isAbsolute(args.manifest as string)
155
+ ? args.manifest as string
156
+ : resolve(args.manifest as string)
157
+ const pkg = await flowPackage({
158
+ storeDir: outDir,
159
+ manifestPath,
160
+ outFile: resolve(outDir, 'manifest.json')
161
+ })
162
+ console.log(`Packaged manifest.json: ${pkg.lessons.length} lessons, ${pkg.pages.length} pages`)
163
+ }
164
+ }
165
+
166
+ // ── Main ──────────────────────────────────────────────────
167
+
168
+ const { command, args, positionals } = parseArgs(process.argv.slice(2))
169
+
170
+ if (command === 'help' || !command) {
171
+ printHelp()
172
+ process.exit(0)
173
+ }
174
+
175
+ if (command === 'build') {
176
+ const target = positionals[0]
177
+ if (!target) {
178
+ console.error('Error: missing target. Usage: build <file.md | directory>')
179
+ process.exit(1)
180
+ }
181
+
182
+ const isFile = target.endsWith('.md')
183
+ if (isFile) {
184
+ const domain = args.domain as string | undefined
185
+ if (!domain) {
186
+ console.error('Error: --domain required for single-file mode')
187
+ process.exit(1)
188
+ }
189
+ await runSingleFile(target, domain, args.out as string | undefined)
190
+ } else {
191
+ await runDirectory(target, args)
192
+ }
193
+ } else {
194
+ console.error(`Unknown command: ${command}`)
195
+ printHelp()
196
+ process.exit(1)
197
+ }
@@ -73,7 +73,7 @@ const defaultFsOps: FsOps = {
73
73
 
74
74
  /**
75
75
  * File-system Artifact Store.
76
- * Layout: <baseDir>/index.json + <baseDir>/lessons/<id>.json
76
+ * Layout: <baseDir>/index.json + <baseDir>/content/<id>.json
77
77
  * Docs are serialised via toCanonicalJSON (2-space, key-lexicographic,
78
78
  * LF, trailing newline) to match golden candidate form.
79
79
  */
@@ -82,7 +82,7 @@ export function createFsArtifactStore (
82
82
  fsOps: FsOps = defaultFsOps
83
83
  ): ArtifactStore {
84
84
  const idxPath = () => `${baseDir}/index.json`
85
- const docPath = (id: string) => `${baseDir}/lessons/${id}.json`
85
+ const docPath = (id: string) => `${baseDir}/content/${id}.json`
86
86
 
87
87
  return {
88
88
  async writeIndex (index) {
@@ -0,0 +1,191 @@
1
+ # 课程内容格式规范(Lesson Markdown)
2
+
3
+ 本仓 `example/` 下有一节按本规范写成的示例课,可直接对照(见 [example/README.md](../example/README.md))。
4
+
5
+ 适用对象:为 dexinlabs 生态编写数学思维课的作者。课程正文是带 front-matter 的 Markdown,经 `dexin-content` 编译为 LessonAST。本规范以工具链代码实际行为为准(dexin-content v0.3 时代);文中「代码」路径均相对本仓仓根。
6
+
7
+ 总原则:**白名单之外即报错**。编译器对块级结构 fail-fast——写了不允许的语法会编译失败而不是静默降级,这是为了让作者当场发现问题。
8
+
9
+ ---
10
+
11
+ ## 1. 目录与文件
12
+
13
+ ```text
14
+ <内容仓根>/
15
+ ├── content-manifest.json # taxonomy 骨架(courses/topics/chapters),无逐课条目
16
+ └── lessons/
17
+ └── <topic>/<chapter>/<slug>.md # 固定三层,不再递归
18
+ ```
19
+
20
+ - 一课 = 一个 `.md` 文件;`<topic>/<chapter>` 目录名即二者 slug;文件名去 `.md` 即课 slug。
21
+ - 课身份:`id = <topic>/<chapter>/<slug>`,URL path = `/` + id(`domains/lesson/identity.ts`)。
22
+ - 新增一课 = 直接建 md 文件;只有增删 topic/chapter 才改 `content-manifest.json`。
23
+ - `index.md` 被扫描器跳过;`lessons/` 或 topic 目录下直接放的 md 不会被扫到。
24
+ - 文件系统排序是字母序,**不等于展示顺序**——展示顺序由 front-matter `order` 决定(§2)。
25
+ - 编码要求:**UTF-8 无 BOM、LF 行尾**。BOM 会使 front-matter 识别失败(整文件被当成无 front-matter);含 `\r` 的源在通用 collection 路径直接报 `LINE_ENDING_CONTAMINATION`。
26
+
27
+ ## 2. Front-matter
28
+
29
+ 以第一行精确的 `---` 开始、以单独一行 `---` 结束,中间为合法 YAML(`core/parser/frontmatter.ts`)。
30
+
31
+ 字段模型是 **SCHEMA-FREE**:
32
+
33
+ - 所有**标量**字段(string / number / boolean)原样进入产物 `meta`;未知字段不报错。
34
+ - **非标高量一律静默丢弃**:数组、对象、null、多行字符串等不会报错,但也拿不到——需要结构化信息时请改用正文容器(§5)。
35
+
36
+ 约定字段(消费方依赖,务必写):
37
+
38
+ | 字段 | 类型 | 说明 | 缺失后果 |
39
+ | ------- | ------ | --------------------------------------------------- | --------------------------------------------- |
40
+ | `title` | string | 课标题 | 打包时回退为 slug;包校验(flowValidate)报错 |
41
+ | `order` | number | 章内展示顺序(小→大) | 按 0 处理,排序失效 |
42
+
43
+ ## 3. 标题与分节
44
+
45
+ | Markdown | 行为 |
46
+ | ------------ | ----------------------------------------------------------- |
47
+ | `# h1` | **吸收丢弃**(标题已由 front-matter `title` 提供),不报错、不出现在产物中——因此不要写 |
48
+ | `## h2` | 映射为 heading level 1,并**新开一个 section**,其后内容归入该节 |
49
+ | `### h3` | level 2,留在当前 section 内 |
50
+ | `#### h4` | level 3 |
51
+ | `##### h5` | level 4 |
52
+ | `###### h6`+ | 编译失败 `LESSON_HEADING_DEPTH_UNDEFINED` |
53
+
54
+ 第一个 h2 之前的块平铺在顶层(不包 section)。一节课建议以 h2 组织小节。规则见 `domains/lesson/parser.ts`。
55
+
56
+ ## 4. 块级语法
57
+
58
+ 产物 block 类型共 14 类(其中 `image` 暂无可用写法,见下);作者可用的书写方式如下(`section` 由 h2 自动产生,无书写语法):
59
+
60
+ | 写法 | 产物 block | 注意 |
61
+ | ---------------- | ------------------------------------------ | ----------------------------------------------------------- |
62
+ | 普通段落 | `paragraph` | — |
63
+ | `> 引用` | `quote` | 内部可放段落/列表等子块 |
64
+ | 独占一行 `---` | `divider` | 与上文之间必须留空行,否则会被解析为 setext 标题下划线 |
65
+ | `- 项` / `1. 项` | `list` | **列表项内只允许一个段落**:嵌套列表、代码块等编译失败 |
66
+ | GFM 管道表格 | `table`(headers + rows) | 单元格走内联规则(§7) |
67
+ | ` ```lang ` 围栏 | `code`(`lang` 可为空串) | — |
68
+ | 块级数学 | `formula`(`display: true`) | 见 §6 |
69
+ | `:::hint` 等 | `hint` / `definition` / `example` / `question` | 见 §5 |
70
+
71
+ **图片暂不可用**:独立图片行与行内图片都会被降级为纯文本(只剩 alt 文字),请不要写 `![...]`,等待渲染侧支持后再开放。
72
+
73
+ ## 5. 容器指令(教学结构的核心)
74
+
75
+ 开栏 `:::名称{key="value"}`,闭栏独占一行 `:::`。容器内部可嵌套任意块(含数学、列表、嵌套段落)。**合法容器只有以下 4 个**(`domains/lesson/blocks.ts`):
76
+
77
+ ```markdown
78
+ :::hint{level="tip"}
79
+ 推荐写法。level 取值:`info` | `tip` | `warning` | `danger` | `reflect`。
80
+ 缺失或非法值编译失败(`LESSON_INVALID_HINT_LEVEL`)。
81
+ :::
82
+
83
+ :::definition{term="一元一次方程"}
84
+ 含有一个未知数、且未知数次数为一的整式方程。
85
+ :::
86
+
87
+ :::example{title="解方程三步法"}
88
+ `title` 可选。
89
+ :::
90
+
91
+ :::question{hint="先移项,再系数化为 1"}
92
+ 题干与作答要求写在容器体内(体即 prompt,可为任意块)。
93
+ 除 `hint` 外的属性会被静默丢弃——**不要写 `{title=…}`**。
94
+ :::
95
+ ```
96
+
97
+ `:::note`、`:::tip` 之类一律编译失败(`LESSON_UNKNOWN_CONTAINER`)。
98
+
99
+ ## 6. 数学
100
+
101
+ - **行内**:`$x + 1 = 2$` → inline `math{latex}`。
102
+ - **块级、两种等价写法**(均产出 `formula{display:true}`):
103
+
104
+ ```markdown
105
+ 单行写法:独占一段、前后空行、段内无其它文字。
106
+
107
+ $$ax + b = 0 \quad (a \neq 0)$$
108
+
109
+ 多行写法:
110
+
111
+ $$
112
+ \frac{9}{5}C + 32 = F
113
+ $$
114
+ ```
115
+
116
+ 单行 `$$…$$` 若与文字同段则回落为行内数学;单个 `$` 永远不会变块级。
117
+
118
+ ## 7. 内联
119
+
120
+ 可用:`**粗体**`、`*斜体*`、`` `代码` ``、`[文字](https://url)`、行内数学、软/硬换行。
121
+
122
+ 避免(不报错但产出坏味道):
123
+
124
+ - 行内 HTML(如 `<b>`):降级为字面文本;
125
+ - 引用式链接 `[ref][id]`:会变成一个 url 为 `id` 的坏链接——请始终用行内 `[文字](url)`;
126
+ - 图片(§4)。
127
+
128
+ ## 8. 明确禁止(编译失败)
129
+
130
+ 块级原始 HTML(`<div>`…)、脚注(`[^1]` 及定义块)、链接引用定义(`[id]: url`)、列表项内的嵌套块、h6、未知容器、非法 hint level。错误码全表见 §10。
131
+
132
+ ## 9. 产物结构(选读)
133
+
134
+ 每课编译为 Artifact:`{ fixture, domain:'lesson', identity, meta, content:{version:1, blocks} }`。`content.blocks` 即 LessonAST,节点契约见 `core/types/lessonAST.ts`(类型即文档:`Block` 联合 15 成员 = 14 类产物 block + 中性层临时节点 `container`)。
135
+
136
+ ## 10. 校验错误码
137
+
138
+ | 错误码 | 触发 |
139
+ | --- | --- |
140
+ | `SCHEMA_VALIDATION_FAILED` | front-matter YAML 解析失败 |
141
+ | `LINE_ENDING_CONTAMINATION` | 源文件含 `\r`(collection 路径) |
142
+ | `MDAST_UNSUPPORTED_NODE` | 块级语法越出 §4/§8 白名单;列表项内嵌套块 |
143
+ | `LESSON_HEADING_DEPTH_UNDEFINED` | h6 及更深 |
144
+ | `LESSON_UNKNOWN_CONTAINER` | `:::` 容器名不在 4 类之内 |
145
+ | `LESSON_INVALID_HINT_LEVEL` | hint level 非法 |
146
+ | `COMPILER_NO_DOMAIN_PARSER` | 宿主未注册 lesson 域(工具链集成问题,与作者无关) |
147
+ | `UNKNOWN_ERROR` | 兜底 |
148
+
149
+ ## 11. 自检
150
+
151
+ 写完后在内容仓根逐文件自检(失败 → stderr + **非零退出码**,适合 CI):
152
+
153
+ ```bash
154
+ npx dexin-content build lessons/<topic>/<chapter>/<slug>.md --domain lesson
155
+ ```
156
+
157
+ 省略 `--out` 时产物 AST 打印到 stdout,可肉眼核对结构;`--out <file.json>` 则落盘。
158
+
159
+ 目录整体编译(发布链路,非作者门禁):
160
+
161
+ ```bash
162
+ npx dexin-content build <内容仓根> --manifest content-manifest.json --out <输出目录>
163
+ ```
164
+
165
+ > ⚠ 已知行为:目录模式对编译失败的文件只打印 `✗ <路径>: <错误码>` 后继续,整体退出码仍为 0。**作者/CI 门禁请以单文件模式为准**。
166
+
167
+ ## 12. 与宿主的关系
168
+
169
+ 本规范只约束「Markdown → LessonAST」这一段。产物如何打包为 ContentPackage、渲染成什么样式、进度数据如何存储,由闭源宿主(dexinlabs)决定,不在本规范范围。
170
+
171
+ ## 13. 已知工具链不一致(待修,不改变本规范效力)
172
+
173
+ - `core/types/lessonAST.ts` 的常量 `HINT_LEVELS` 缺 `reflect`;实际校验枚举以 `domains/lesson/blocks.ts` 的 `LESSON_HINT_LEVELS`(5 级)为准。
174
+ - 目录模式对失败文件仍 exit 0(§11)计划在后续版本提供失败计数退出。
175
+
176
+ ## 14. 投稿与迁入 SOP
177
+
178
+ 本工具链开源共建的对象是「怎么写课」(规范/示例/校验器),成品课程内容保存在**私有成品仓**,不公开。两条投稿路径:
179
+
180
+ **A. 社区投稿(proposals/)**
181
+
182
+ 1. **撰写**:按本规范写课,放入本仓 [`proposals/<topic>/<chapter>/<slug>.md`](../proposals/README.md),本地自检(§11)。
183
+ 2. **提 PR**:gitee 网页端编辑 Markdown 亦可;填写 PR 模板的自查与声明项。
184
+ 3. **门禁校验**:合并前必须格式全绿。投稿人本地跑 `npm run validate:proposals`(或 `npx dexin-content@latest` 逐文件,见 §11),维护者合并前复跑同一脚本。校验器 = 单文件模式编译,任一失败即非零退出。
185
+ 4. **人审**:门禁全绿后由维护者 PR review,审教学正确性与结构合理性(格式问题门禁已拦截)。
186
+ 5. **迁入(单向)**:通过后由维护者将稿件迁入私有成品仓——正式 topic/chapter/slug 以成品仓目录与 `content-manifest.json` 骨架为准(可能与投稿时不同,会在 review 中说明)——随后删除本仓 `proposals/` 中的对应文件。下次内容构建时上线。
187
+ 6. **授权**:投稿即视为原创并同意被收录评估;内容版权归作者,收录成品仓的具体授权条款在迁入前另行确认(本仓 Apache-2.0 许可证覆盖的是代码与文档,不自动覆盖课程文字内容)。
188
+
189
+ **B. 签约/受雇作者**
190
+
191
+ 不进公共投稿区:直接对私有成品仓有写权限,以内部 PR 提交与评审。格式校验同样适用(`npx dexin-content@latest build <文件> --domain lesson`)。
@@ -0,0 +1,37 @@
1
+ # example/ — 示例课
2
+
3
+ 这是按 [格式规范](../docs/content-format.md) 写成的最小内容仓样例:
4
+
5
+ ```text
6
+ example/
7
+ ├── content-manifest.json # taxonomy 骨架(courses/topics/chapters)
8
+ └── lessons/
9
+ └── getting-started/hello-lesson/
10
+ ├── minimal.md # 最小的一节课(order 1)
11
+ └── syntax-tour.md # 全部合法语法速览(order 2)
12
+ ```
13
+
14
+ ## 自检
15
+
16
+ 在本仓仓根(或任何安装了 `dexin-content` 的内容仓根)执行:
17
+
18
+ ```bash
19
+ # 整树编译 + 打包(产物写到临时目录,不要写进 example/)
20
+ npx dexin-content build example \
21
+ --manifest example/content-manifest.json \
22
+ --out /tmp/dexin-example-out
23
+
24
+ # 单文件自检(AST 打印到 stdout;失败时非零退出码)
25
+ npx dexin-content build example/lessons/getting-started/hello-lesson/syntax-tour.md --domain lesson
26
+ ```
27
+
28
+ 预期输出:
29
+
30
+ ```text
31
+ Build complete: 2 artifacts in ...ms
32
+ Packaged manifest.json: 2 lessons, 0 pages
33
+ ```
34
+
35
+ 任何 `✗ <路径>: <错误码>` 行都表示该文件编译失败,含义见 [格式规范 §10](../docs/content-format.md#10-校验错误码)。
36
+
37
+ > 提示:`--out` 缺省时目录模式会把产物写进 `example/output/`。该目录被 gitignore 与打包排除,但请尽量显式指定临时输出目录,保持示例目录干净。