dexin-content 0.2.1 → 0.3.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/README.md CHANGED
@@ -1,112 +1,121 @@
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)。
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 / parser / artifact)
17
+ - `domains/lesson/` 内置 lesson 域 parser(其余域由宿主自行注入)
18
+ - `cli/` flow 编排(build / package / validate / check / serve)
19
+ - `scripts/` 仓内脚本(不在发布包 files 内,仅供本地开发/测试;也不作为公开 API)
20
+
21
+ 宿主应用负责域扩展(DomainParser)、编排脚本与内容文件所有权,通过 `exports` 里的子路径消费本 package。
22
+
23
+ ## 安装
24
+
25
+ > 需要 Node.js ≥ 20。Package 以 TypeScript 源码形式分发(`*.ts`),宿主应通过 `tsx`、Nuxt/Vite 等支持 TS 解析的工具链使用。
26
+
27
+ ```bash
28
+ # Gitee 源(当前发布前)
29
+ npm install git+https://gitee.com/cuizhn/dexin-content.git
30
+
31
+ # 发布到 npm 后
32
+ npm install dexin-content
33
+ ```
34
+
35
+ ## 公开 API 边界
36
+
37
+ 唯一公共 API 边界是 `package.json` 中 `exports` 字段。**只使用下列路径**:
38
+
39
+ | 子路径 | 暴露内容 |
40
+ |---|---|
41
+ | `.` | 门面:核心类型 + `DomainParserRegistry` / `compile` + `ArtifactStore` / `ContentQuery` + `defineCollection / resolveCollections / discover / compileCollections` |
42
+ | `./core/types` | 类型:`Inline` / `DocumentBlock` / `DocumentContent` / `DocumentIdentity` / `PositiveArtifact` / `Artifact` / `Meta` / `ParseError` / `DomainParser` / `Schema` / `ParseContext` |
43
+ | `./core/compiler` | `compile`、`CompileInput`、`CompileResult`、`DomainParserRegistry` |
44
+ | `./core/discovery` | `buildDocumentIdentity` / `normaliseRel` / `readSourceFile` / `sanitizeSourceText` / `mkLineEndingError` |
45
+ | `./core/frontmatter` | `splitFrontmatter` / `parseFrontmatter` / `validateSchema` / `projectMeta` |
46
+ | `./store` | `ArtifactStore` 接口 + `createFsArtifactStore` / `createMemoryArtifactStore` + `ContentIndex` / `IndexEntry` |
47
+ | `./collection` | `defineCollection` / `resolveCollections` / `compileCollections` / `recompileChanged` / `createLocalSource` / `createMemorySource` / `SourceAdapter` / `ResolvedCollection` / `CollectionDefinition` |
48
+ | `./query` | `ContentQuery` (byId / byPath / list / collection) + `QueryOptions` |
49
+ | `./diff` | `toCanonicalJSON` / `sortKeysDeep` / `stripUnderscoreKeysGolden` / `underscorePrefixedPaths` / `firstDiff` / `shortStr` |
50
+ | `./core/markdown` | Markdown → 中性 AST 解析(`parseDocument` 等) |
51
+ | `./core/types/lessonAST` | LessonAST 类型与常量 |
52
+ | `./domains/lesson` | 内置 lesson 域:`lessonParser` / `buildLessonIdentity` / `mapBlocks` |
53
+ | `./cli/build` | `flowBuild`(目录发现 → compile → Artifact Store + index.json) |
54
+ | `./cli/package` | `flowPackage`(按 manifest 打包 ContentPackage) |
55
+ | `./cli/validate` | `flowValidate` |
56
+ | `./cli/check` | `flowCheck` |
57
+ | `./cli/serve` | `flowServe`(dev preview HTTP 服务) |
58
+
59
+ 任何未列入上表的内部路径均不保证稳定。
60
+
61
+ ## 快速示例
62
+
63
+ ```ts
64
+ import {
65
+ defineCollection,
66
+ resolveCollections,
67
+ createLocalSource,
68
+ compileCollections,
69
+ } from 'dexin-content/collection'
70
+ import { createFsArtifactStore } from 'dexin-content/store'
71
+ import { ContentQuery } from 'dexin-content/query'
72
+ import { DomainParserRegistry } from 'dexin-content/core/compiler'
73
+ import type {
74
+ DomainParser,
75
+ DocumentContent,
76
+ ParseContext,
77
+ } from 'dexin-content/core/types'
78
+
79
+ // 1) 注入你的域 parser;parser 决定中性 AST 如何映射到目标 Artifact 内容结构
80
+ const documentParser: DomainParser = {
81
+ domain: 'document',
82
+ parse(content: DocumentContent, _ctx: ParseContext) {
83
+ return { version: 1, blocks: content.blocks as unknown[] }
84
+ },
85
+ }
86
+ const registry = new DomainParserRegistry()
87
+ registry.register(documentParser)
88
+
89
+ // 2) 声明集合 + 编译
90
+ const collections = resolveCollections(
91
+ [defineCollection({ name: 'legal', source: 'legal', domain: 'document' })],
92
+ '/path/to/content/root',
93
+ )
94
+ const source = createLocalSource('/path/to/content/root')
95
+ const store = createFsArtifactStore('/path/to/artifact-store')
96
+ await compileCollections(source, collections, registry, store)
97
+
98
+ // 3) 运行时只读查询(不解析任何 Markdown)
99
+ const query = new ContentQuery(store)
100
+ const entry = await query.byPath('/legal/privacy')
101
+ ```
102
+
103
+ ## 开发
104
+
105
+ ```bash
106
+ # 依赖
107
+ npm install
108
+
109
+ # 类型检查(唯一静态门禁)
110
+ npm run typecheck
111
+
112
+ # 仓内自检测(脚本自建临时 fixture,无需任何本地产物)
113
+ npm run runtime:check
114
+
115
+ # 预发布 tarball 内容检查
116
+ npm run pack
117
+ ```
118
+
119
+ ## License
120
+
121
+ Apache-2.0 © 得心实验室。详见 [LICENSE](./LICENSE)。
package/cli/build.ts CHANGED
@@ -157,7 +157,7 @@ async function discoverAndCompileDocuments(
157
157
  }
158
158
 
159
159
  async function writeArtifact(outDir: string, artifact: PositiveArtifact): Promise<void> {
160
- const outFile = path.join(outDir, 'lessons', artifact.fixture + '.json')
160
+ const outFile = path.join(outDir, 'content', artifact.fixture + '.json')
161
161
  await mkdir(path.dirname(outFile), { recursive: true })
162
162
  await writeFile(outFile, JSON.stringify(artifact, null, 2) + '\n', 'utf-8')
163
163
  }
@@ -183,6 +183,6 @@ async function walkMdFiles(dir: string): Promise<string[]> {
183
183
  }
184
184
  return out.sort()
185
185
  }
186
-
187
-
188
-
186
+
187
+
188
+
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) {
package/index.ts CHANGED
@@ -1,61 +1,57 @@
1
- // dexin-content — Universal content compilation layer
2
- // Re-exports core types and interfaces for host consumption.
3
- // Domain parsers are injected by the host via DomainParserRegistry.
4
-
5
- export type {
6
- DomainName, DocumentIdentity, Schema, Artifact, ParseError,
7
- DomainParser, ParseContext,
8
- Meta, Identity, StructuredIdentity, TEX_INLINE_TYPE
9
- } from './core/types/index'
10
-
11
- // LessonAST 类型
12
- export type {
13
- LessonContent, Block, BlockType,
14
- ParagraphBlock, HeadingBlock, QuoteBlock, HintBlock,
15
- DefinitionBlock, ExampleBlock, QuestionBlock,
16
- ImageBlock, CodeBlock, FormulaBlock, ListBlock,
17
- TableBlock, TableCell, SectionBlock, DividerBlock,
18
- ContainerBlock, ExerciseContent,
19
- Inline, TextInline, BoldInline, ItalicInline,
20
- CodeInline, LinkInline, FormulaInline, MathInline,
21
- BaseBlock
22
- } from './core/types/lessonAST'
23
-
24
- export {
25
- BLOCK_TYPES, HINT_LEVELS, HEADING_LEVELS
26
- } from './core/types/lessonAST'
27
-
28
- export { DomainParserRegistry, compile } from './core/compiler/compiler'
29
- export type { CompileInput, CompileResult } from './core/compiler/compiler'
30
- export { normaliseRel, buildDocumentIdentity as buildDocPageIdentity, readSourceFile } from './core/compiler/discovery'
31
- export { splitFrontmatter, parseFrontmatter, validateSchema } from './core/parser/frontmatter'
32
- export { parseDocument } from './core/parser/markdown'
33
-
34
- export type { ArtifactStore, ContentIndex, IndexEntry } from './core/artifact/store'
35
- export { createFsArtifactStore, createMemoryArtifactStore } from './core/artifact/store'
36
-
37
- export type { CollectionDefinition, SourceAdapter, ResolvedCollection } from './core/artifact/collection'
38
- export { defineCollection, resolveCollections, discover, compileCollections } from './core/artifact/collection'
39
-
40
- export type { ContentQuery as ContentQueryType } from './core/artifact/query'
41
- export { ContentQuery } from './core/artifact/query'
42
-
43
- export { toCanonicalJSON, firstDiff, sortKeysDeep, stripUnderscoreKeysGolden } from './core/artifact/diff'
44
-
45
- // Lesson domain
46
- export { lessonParser } from './domains/lesson/parser'
47
- export { buildLessonIdentity } from './domains/lesson/identity'
48
- export { mapBlocks, lessonError, KNOWN_CONTAINERS } from './domains/lesson/blocks'
49
-
50
- // Document domain
51
- export { documentParser } from './domains/document/parser'
52
- export { buildDocumentIdentity as buildDocumentPageIdentity } from './domains/document/identity'
53
-
54
- // CLI
55
- export type { FlowBuildInput, FlowBuildResult } from './cli/build'
56
- export { flowBuild } from './cli/build'
57
- export type { FlowManifest, OutLesson, OutPage, OutPackage } from './cli/package'
58
- export { flowPackage } from './cli/package'
59
- export { flowValidate } from './cli/validate'
60
- export { flowCheck } from './cli/check'
61
- export { flowServe } from './cli/serve'
1
+ // dexin-content — Universal content compilation layer
2
+ // Re-exports core types and interfaces for host consumption.
3
+ // Domain parsers are injected by the host via DomainParserRegistry.
4
+
5
+ export type {
6
+ DomainName, DocumentIdentity, Schema, Artifact, ParseError,
7
+ DomainParser, ParseContext,
8
+ Meta, Identity, StructuredIdentity, TEX_INLINE_TYPE
9
+ } from './core/types/index'
10
+
11
+ // LessonAST 类型
12
+ export type {
13
+ LessonContent, Block, BlockType,
14
+ ParagraphBlock, HeadingBlock, QuoteBlock, HintBlock,
15
+ DefinitionBlock, ExampleBlock, QuestionBlock,
16
+ ImageBlock, CodeBlock, FormulaBlock, ListBlock,
17
+ TableBlock, TableCell, SectionBlock, DividerBlock,
18
+ ContainerBlock, ExerciseContent,
19
+ Inline, TextInline, BoldInline, ItalicInline,
20
+ CodeInline, LinkInline, FormulaInline, MathInline,
21
+ BaseBlock
22
+ } from './core/types/lessonAST'
23
+
24
+ export {
25
+ BLOCK_TYPES, HINT_LEVELS, HEADING_LEVELS
26
+ } from './core/types/lessonAST'
27
+
28
+ export { DomainParserRegistry, compile } from './core/compiler/compiler'
29
+ export type { CompileInput, CompileResult } from './core/compiler/compiler'
30
+ export { normaliseRel, buildDocumentIdentity as buildDocPageIdentity, readSourceFile } from './core/compiler/discovery'
31
+ export { splitFrontmatter, parseFrontmatter, validateSchema } from './core/parser/frontmatter'
32
+ export { parseDocument } from './core/parser/markdown'
33
+
34
+ export type { ArtifactStore, ContentIndex, IndexEntry } from './core/artifact/store'
35
+ export { createFsArtifactStore, createMemoryArtifactStore } from './core/artifact/store'
36
+
37
+ export type { CollectionDefinition, SourceAdapter, ResolvedCollection } from './core/artifact/collection'
38
+ export { defineCollection, resolveCollections, discover, compileCollections } from './core/artifact/collection'
39
+
40
+ export type { ContentQuery as ContentQueryType } from './core/artifact/query'
41
+ export { ContentQuery } from './core/artifact/query'
42
+
43
+ export { toCanonicalJSON, firstDiff, sortKeysDeep, stripUnderscoreKeysGolden } from './core/artifact/diff'
44
+
45
+ // Lesson domain
46
+ export { lessonParser } from './domains/lesson/parser'
47
+ export { buildLessonIdentity } from './domains/lesson/identity'
48
+ export { mapBlocks, lessonError, KNOWN_CONTAINERS } from './domains/lesson/blocks'
49
+
50
+ // CLI
51
+ export type { FlowBuildInput, FlowBuildResult } from './cli/build'
52
+ export { flowBuild } from './cli/build'
53
+ export type { FlowManifest, OutLesson, OutPage, OutPackage } from './cli/package'
54
+ export { flowPackage } from './cli/package'
55
+ export { flowValidate } from './cli/validate'
56
+ export { flowCheck } from './cli/check'
57
+ export { flowServe } from './cli/serve'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dexin-content",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Open content toolchain — Markdown → LessonAST/ContentPackage compiler, CLI and validator (dexinlabs ecosystem).",
5
5
  "keywords": [
6
6
  "markdown",
@@ -53,8 +53,7 @@
53
53
  "./cli/check": "./cli/check.ts",
54
54
  "./core/discovery": "./core/compiler/discovery.ts",
55
55
  "./core/types/lessonAST": "./core/types/lessonAST.ts",
56
- "./domains/lesson": "./domains/lesson/index.ts",
57
- "./domains/document": "./domains/document/index.ts"
56
+ "./domains/lesson": "./domains/lesson/index.ts"
58
57
  },
59
58
  "scripts": {
60
59
  "typecheck": "tsc --noEmit",
@@ -1,199 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // dexin-content/runtime-check.ts — Runtime flow (B1-SPEC T5.3 / ARCH-REPORT §8)
3
- //
4
- // Reads pre-compiled artifacts from output/dexin-content/ via ContentQuery.
5
- // Asserts structural correctness of the read-back artifacts.
6
- //
7
- // ZERO MARKDOWN PARSING PROOF (B1-SPEC T5.3):
8
- // This module imports ONLY from:
9
- // - ./store (fs/memory store + ContentIndex types; markdown-free)
10
- // - ./query (ContentQuery; markdown-free)
11
- // - ./core/types (type-only imports, erased at compile time)
12
- // - node:* (built-in modules)
13
- // It does NOT import: markdown, remark, unified, core/compiler,
14
- // core/frontmatter, core/markdown, domain/*, collection (which
15
- // transitively imports the compiler). The runtime path reads
16
- // pre-compiled artifacts from output/ and never reparses source.
17
- //
18
- // Transitive dependency audit:
19
- // store.ts → node:fs/promises, node:path, ./core/types (types), ./diff
20
- // query.ts → ./core/types (types), ./store
21
- // diff.ts → (zero imports — pure JSON/Object operations)
22
- // No markdown/remark/unified anywhere in the runtime import graph.
23
- //
24
- // grep-zero: no business vocabulary appears anywhere in this
25
- // module — including error messages.
26
- // ─────────────────────────────────────────────────────────────
27
-
28
- import { fileURLToPath } from 'node:url'
29
- import { join } from 'node:path'
30
-
31
- import type { PositiveArtifact } from '../types'
32
- import { createFsArtifactStore, createMemoryArtifactStore, type ArtifactStore } from '../artifact/store'
33
- import { ContentQuery } from '../artifact/query'
34
-
35
- const __filename = fileURLToPath(import.meta.url)
36
- const ROOT = join(__filename, '..', '..')
37
- const OUTPUT_DIR = join(ROOT, 'output', 'dexin-content')
38
-
39
- // ── Helpers ──────────────────────────────────────────────
40
-
41
- let pass = 0
42
- let fail = 0
43
- const errors: string[] = []
44
-
45
- function ok (label: string): void {
46
- pass++
47
- console.log(` ✅ ${label}`)
48
- }
49
-
50
- function ko (label: string, detail: string): void {
51
- fail++
52
- errors.push(`${label}: ${detail}`)
53
- console.log(` ❌ ${label}: ${detail}`)
54
- }
55
-
56
- function assertArtifact (label: string, doc: PositiveArtifact | null, expectDomain: string, expectId: string): void {
57
- if (doc === null) { ko(label, 'returned null'); return }
58
- if (doc.domain !== expectDomain) { ko(label, `domain=${doc.domain} (expected ${expectDomain})`); return }
59
- if (doc.fixture !== expectId) { ko(label, `fixture=${doc.fixture} (expected ${expectId})`); return }
60
- if (!doc.content?.blocks?.length) { ko(label, 'content.blocks empty or missing'); return }
61
- if (!doc.identity) { ko(label, 'identity missing'); return }
62
- if (!doc.meta) { ko(label, 'meta missing'); return }
63
- ok(label)
64
- }
65
-
66
- // ── R1: byPath on legal/privacy + about ───────────────────
67
-
68
- async function checkR1 (query: ContentQuery): Promise<void> {
69
- console.log('── R1: query.byPath ──')
70
- const privacy = await query.byPath('/legal/privacy')
71
- assertArtifact('R1 byPath(/legal/privacy)', privacy, 'document', 'legal/privacy')
72
-
73
- const about = await query.byPath('/about')
74
- assertArtifact('R1 byPath(/about)', about, 'document', 'about')
75
-
76
- const notFound = await query.byPath('/nonexistent')
77
- if (notFound !== null) ko('R1 byPath(/nonexistent)', 'should be null')
78
- else ok('R1 byPath(/nonexistent) → null')
79
- }
80
-
81
- // ── R2: byId ─────────────────────────────────────────────
82
-
83
- async function checkR2 (query: ContentQuery): Promise<void> {
84
- console.log('── R2: query.byId ──')
85
- const byIdPrivacy = await query.byId('legal/privacy')
86
- assertArtifact('R2 byId(legal/privacy)', byIdPrivacy, 'document', 'legal/privacy')
87
-
88
- const byIdAbout = await query.byId('about')
89
- assertArtifact('R2 byId(about)', byIdAbout, 'document', 'about')
90
-
91
- const byIdMissing = await query.byId('does/not/exist')
92
- if (byIdMissing !== null) ko('R2 byId(missing)', 'should be null')
93
- else ok('R2 byId(missing) → null')
94
- }
95
-
96
- // ── R3: collection ───────────────────────────────────────
97
-
98
- async function checkR3 (query: ContentQuery): Promise<void> {
99
- console.log('── R3: query.collection ──')
100
- const legal = await query.collection('legal')
101
- if (legal.length !== 1) ko('R3 collection(legal)', `len=${legal.length}`)
102
- else if (legal[0].id !== 'legal/privacy') ko('R3 collection(legal)', `id=${legal[0].id}`)
103
- else ok('R3 collection(legal) → 1 entry (legal/privacy)')
104
-
105
- const about = await query.collection('about')
106
- if (about.length !== 1) ko('R3 collection(about)', `len=${about.length}`)
107
- else if (about[0].id !== 'about') ko('R3 collection(about)', `id=${about[0].id}`)
108
- else ok('R3 collection(about) → 1 entry (about)')
109
- }
110
-
111
- // ── R4: list with where/sort/limit ───────────────────────
112
-
113
- async function checkR4 (query: ContentQuery): Promise<void> {
114
- console.log('── R4: query.list ──')
115
- const all = await query.list()
116
- if (all.length !== 2) ko('R4 list()', `len=${all.length}`)
117
- else ok('R4 list() → 2 entries')
118
-
119
- const legalOnly = await query.list('legal')
120
- if (legalOnly.length !== 1) ko('R4 list(legal)', `len=${legalOnly.length}`)
121
- else ok('R4 list(legal) → 1 entry')
122
-
123
- const sorted = await query.list(undefined, { sort: (a, b) => a.id.localeCompare(b.id) })
124
- if (sorted.length !== 2) ko('R4 list(sort)', `len=${sorted.length}`)
125
- else ok('R4 list(sort) → 2 sorted')
126
-
127
- const limited = await query.list(undefined, { limit: 1 })
128
- if (limited.length !== 1) ko('R4 list(limit=1)', `len=${limited.length}`)
129
- else ok('R4 list(limit=1) → 1 entry')
130
-
131
- const filtered = await query.list(undefined, { where: e => e.collection === 'legal' })
132
- if (filtered.length !== 1) ko('R4 list(where=legal)', `len=${filtered.length}`)
133
- else ok('R4 list(where=legal) → 1 entry')
134
- }
135
-
136
- // ── R5: no-index → throw (zero-parse invariant) ─────────
137
-
138
- async function checkR5 (): Promise<void> {
139
- console.log('── R5: no-index → throw (zero-parse invariant) ──')
140
- const emptyStore = createMemoryArtifactStore()
141
- const query = new ContentQuery(emptyStore)
142
- try {
143
- await query.byPath('/legal/privacy')
144
- ko('R5 byPath no-index', 'should have thrown')
145
- } catch (e) {
146
- const msg = (e as Error).message
147
- if (msg.includes('No index')) ok('R5 byPath no-index → throws')
148
- else ko('R5 byPath no-index', `wrong message: ${msg}`)
149
- }
150
- try {
151
- await query.list()
152
- ko('R5 list no-index', 'should have thrown')
153
- } catch (e) {
154
- const msg = (e as Error).message
155
- if (msg.includes('No index')) ok('R5 list no-index → throws')
156
- else ko('R5 list no-index', `wrong message: ${msg}`)
157
- }
158
- }
159
-
160
- // ── R6: deep-equal byPath vs byId (store round-trip) ────
161
-
162
- async function checkR6 (query: ContentQuery): Promise<void> {
163
- console.log('── R6: byPath vs byId deep-equal (round-trip) ──')
164
- const byPath = await query.byPath('/legal/privacy')
165
- const byId = await query.byId('legal/privacy')
166
- if (byPath === null || byId === null) { ko('R6 deep-equal', 'one or both null'); return }
167
- if (byPath.fixture !== byId.fixture) { ko('R6 deep-equal', 'fixture mismatch'); return }
168
- if (byPath.domain !== byId.domain) { ko('R6 deep-equal', 'domain mismatch'); return }
169
- if (byPath.content.blocks.length !== byId.content.blocks.length) { ko('R6 deep-equal', 'blocks length mismatch'); return }
170
- ok('R6 byPath(/legal/privacy) deep-equal byId(legal/privacy)')
171
- }
172
-
173
- // ── Main ─────────────────────────────────────────────────
174
-
175
- async function main (): Promise<void> {
176
- console.log('── runtime:check ──')
177
- console.log(` output dir: ${OUTPUT_DIR}`)
178
-
179
- const store: ArtifactStore = createFsArtifactStore(OUTPUT_DIR)
180
- const query = new ContentQuery(store)
181
-
182
- await checkR1(query)
183
- await checkR2(query)
184
- await checkR3(query)
185
- await checkR4(query)
186
- await checkR5()
187
- await checkR6(query)
188
-
189
- console.log(`\n──────── runtime:check summary ────────`)
190
- console.log(`${pass} pass, ${fail} fail`)
191
- if (fail > 0) {
192
- console.log('\nFailures:')
193
- for (const e of errors) console.log(` - ${e}`)
194
- process.exit(1)
195
- }
196
- process.exit(0)
197
- }
198
-
199
- void main()
@@ -1,27 +0,0 @@
1
- /**
2
- * DocumentIdentity — dexinlabs-specific identity builder for document content.
3
- *
4
- * Maps page slugs to DocumentIdentity shape with collection='pages'.
5
- *
6
- * This keeps dexin-content generic while allowing dexinlabs to maintain
7
- * its document/page organization.
8
- */
9
- import type { DocumentIdentity } from '../../core/types/index'
10
-
11
- /**
12
- * Build a DocumentIdentity for a page file.
13
- *
14
- * @param slug - Page identifier (e.g., 'about', 'privacy')
15
- * @param relPath - Relative path from content root (e.g., 'pages/about.md')
16
- */
17
- export function buildDocumentIdentity(
18
- slug: string,
19
- relPath: string
20
- ): DocumentIdentity {
21
- return {
22
- id: slug,
23
- path: `/${slug}`,
24
- file: relPath,
25
- collection: 'pages'
26
- }
27
- }
@@ -1,5 +0,0 @@
1
- /**
2
- * document-domain barrel
3
- */
4
- export { documentParser } from './parser'
5
- export { buildDocumentIdentity } from './identity'
@@ -1,105 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // domains/document/parser.ts
3
- //
4
- // Document domain parser. Identity behaviour defined in §6.3.15 frozen.
5
- //
6
- // Content behaviour:
7
- // * Heading = straight docAST heading (h1→1, h2→2, h3→3, h4→4; no
8
- // absorption, no downgrade).
9
- // * md h5/h6 → DOC_HEADING_DEPTH_UNDEFINED (6.3.10 frozen: ERROR)。
10
- // * No section grouping (6.3.11 frozen).
11
- // * ContainerBlock (6.3.12) stays neutral (never re-mapped to domain blocks).
12
- // * Plain blocks pass through unchanged from DocumentAST.
13
- // * `mdDepth` 属 transient 辅助注解:clone/render 路径显式剔除。
14
- // ─────────────────────────────────────────────────────────────
15
-
16
- import type {
17
- DomainParser,
18
- ParseContext,
19
- ParseError
20
- } from '../../core/types/index'
21
- import type { LessonContent, Block, HeadingBlock } from '../../core/types/lessonAST'
22
-
23
- export const documentParser: DomainParser = {
24
- domain: 'document',
25
- parse (content: LessonContent, ctx: ParseContext): LessonContent {
26
- // Pre-pass: document-domain heading depth fail-fast (6.3.10).
27
- walkAndEnforceHeadingDepth(content.blocks, ctx)
28
-
29
- // Deep-copy structural nodes so future mutations to docAST do not leak.
30
- return { version: 1, blocks: cloneBlocks(content.blocks) }
31
- }
32
- }
33
-
34
- function walkAndEnforceHeadingDepth (blocks: Block[], ctx: ParseContext): void {
35
- for (const b of blocks) {
36
- if (b.type === 'heading') {
37
- const mdDepth = (b as HeadingBlock).mdDepth ?? b.level
38
- if (mdDepth > 4) {
39
- const err = new Error(
40
- `[DOC_HEADING_DEPTH_UNDEFINED] Document domain only supports h1–h4. ` +
41
- `Encountered h${mdDepth} (heading level ${mdDepth}): no mapping defined per 6.3.10 frozen table. ` +
42
- `Allowed heading markdown depths: 1–4. Silently capping to level 4 is forbidden (information loss). ` +
43
- `file: ${ctx.file}`
44
- ) as ParseError & Error
45
- err.code = 'DOC_HEADING_DEPTH_UNDEFINED'
46
- err.file = ctx.file
47
- err.loc = { headingDepth: mdDepth, allowed: 'h1-h4' }
48
- throw err
49
- }
50
- }
51
- if (b.type === 'quote') walkAndEnforceHeadingDepth(b.children, ctx)
52
- if (b.type === 'container') walkAndEnforceHeadingDepth(b.children, ctx)
53
- }
54
- }
55
-
56
- function cloneBlocks (xs: Block[]): Block[] {
57
- return xs.map(b => {
58
- switch (b.type) {
59
- case 'paragraph': return { type:'paragraph', children: cloneInlineArr(b.children) }
60
- case 'heading': {
61
- // Drop transient mdDepth — canonical output never contains it.
62
- return { type:'heading', level: b.level, children: cloneInlineArr(b.children) }
63
- }
64
- case 'quote': return { type:'quote', children: cloneBlocks(b.children) }
65
- case 'divider': return { type:'divider' }
66
- case 'list': return {
67
- type:'list', ordered: b.ordered,
68
- items: b.items.map(row => row.map(cloneInline))
69
- }
70
- case 'table': return {
71
- type:'table',
72
- headers: b.headers.map(c => c.map(cloneInline)),
73
- rows: b.rows.map(r => r.map(c => c.map(cloneInline)))
74
- }
75
- case 'image': return { type:'image', src:b.src, alt:b.alt, caption:b.caption }
76
- case 'code': return { type:'code', language: b.language, code: b.code }
77
- case 'formula': return { type:'formula', latex: b.latex, display: b.display }
78
- case 'container': return {
79
- type:'container',
80
- name: b.name,
81
- attrs: { ...b.attrs },
82
- children: cloneBlocks(b.children)
83
- }
84
- default:
85
- // hint/definition/example/question/section — domain-specific, not expected here
86
- return b
87
- }
88
- })
89
- }
90
-
91
- function cloneInlineArr (xs: any[]): any[] {
92
- return xs.map(cloneInline)
93
- }
94
-
95
- function cloneInline (i: any): any {
96
- switch (i.type) {
97
- case 'text': return { type:'text', value: i.value }
98
- case 'code': return { type:'code', value: i.value }
99
- case 'math': return { type:'math', latex: i.latex }
100
- case 'bold': return { type:'bold', children: cloneInlineArr(i.children) }
101
- case 'italic': return { type:'italic', children: cloneInlineArr(i.children) }
102
- case 'link': return { type:'link', url: i.url, children: cloneInlineArr(i.children) }
103
- default: return { ...i }
104
- }
105
- }