dexin-content 0.3.0 → 0.3.2
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 +43 -42
- package/browser/compile.ts +57 -0
- package/core/parser/frontmatter.ts +1 -1
- package/core/parser/markdown.ts +68 -26
- package/docs/content-format.md +191 -0
- package/example/README.md +37 -0
- package/example/content-manifest.json +16 -0
- package/example/lessons/getting-started/hello-lesson/minimal.md +10 -0
- package/example/lessons/getting-started/hello-lesson/syntax-tour.md +74 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -10,12 +10,19 @@
|
|
|
10
10
|
- **Collection**:集合声明、发现、批量编译与增量变化检测
|
|
11
11
|
- **Diff**:Canonical JSON 序列化与对象级差异原语(Golden/Regress 管线使用)
|
|
12
12
|
|
|
13
|
+
## 为内容作者
|
|
14
|
+
|
|
15
|
+
- **格式规范**:[docs/content-format.md](./docs/content-format.md) —— 目录结构、front-matter、块/容器/数学语法、错误码、自检命令、投稿 SOP(§14)
|
|
16
|
+
- **示例课**:[example/](./example/README.md) —— 可直接编译通过的最小内容仓样例
|
|
17
|
+
- **社区投稿**:[proposals/](./proposals/README.md) —— 课程稿件投放区,PR + 门禁校验(`npm run validate:proposals`)+ 人审后迁入私有成品仓
|
|
18
|
+
|
|
13
19
|
## 目录范围
|
|
14
20
|
|
|
15
21
|
dexin-content 是**通用层**:
|
|
16
22
|
- `core/` 编译核心(types / compiler / parser / artifact)
|
|
17
23
|
- `domains/lesson/` 内置 lesson 域 parser(其余域由宿主自行注入)
|
|
18
|
-
- `cli/` flow
|
|
24
|
+
- `cli/` flow 编排(`flowBuild` / `flowPackage` / `flowValidate` / `flowCheck` / `flowServe` 库 API;**CLI 子命令仅有 `build` 与 `help`**,其余 flow 经子路径作库消费)
|
|
25
|
+
- `browser/` 浏览器宿主入口(`compileLesson`,零 Node API;见 `./compile`)
|
|
19
26
|
- `scripts/` 仓内脚本(不在发布包 files 内,仅供本地开发/测试;也不作为公开 API)
|
|
20
27
|
|
|
21
28
|
宿主应用负责域扩展(DomainParser)、编排脚本与内容文件所有权,通过 `exports` 里的子路径消费本 package。
|
|
@@ -25,10 +32,6 @@ dexin-content 是**通用层**:
|
|
|
25
32
|
> 需要 Node.js ≥ 20。Package 以 TypeScript 源码形式分发(`*.ts`),宿主应通过 `tsx`、Nuxt/Vite 等支持 TS 解析的工具链使用。
|
|
26
33
|
|
|
27
34
|
```bash
|
|
28
|
-
# Gitee 源(当前发布前)
|
|
29
|
-
npm install git+https://gitee.com/cuizhn/dexin-content.git
|
|
30
|
-
|
|
31
|
-
# 发布到 npm 后
|
|
32
35
|
npm install dexin-content
|
|
33
36
|
```
|
|
34
37
|
|
|
@@ -39,15 +42,16 @@ npm install dexin-content
|
|
|
39
42
|
| 子路径 | 暴露内容 |
|
|
40
43
|
|---|---|
|
|
41
44
|
| `.` | 门面:核心类型 + `DomainParserRegistry` / `compile` + `ArtifactStore` / `ContentQuery` + `defineCollection / resolveCollections / discover / compileCollections` |
|
|
42
|
-
| `./core/types` | 类型:`Inline` / `
|
|
45
|
+
| `./core/types` | 类型:`Inline` 家族(`TextInline` / `BoldInline` / `ItalicInline` / `CodeInline` / `LinkInline` / `FormulaInline`) / `Identity` / `DocumentIdentity` / `StructuredIdentity` / `Meta` / `PositiveArtifact` / `Artifact` / `ParseError` / `ParseContext` / `Schema` / `DomainParser` / `DomainName`(LessonAST 块类型在 `./core/types/lessonAST`) |
|
|
43
46
|
| `./core/compiler` | `compile`、`CompileInput`、`CompileResult`、`DomainParserRegistry` |
|
|
44
|
-
| `./core/discovery` | `buildDocumentIdentity` / `normaliseRel` / `readSourceFile` / `
|
|
47
|
+
| `./core/discovery` | `buildDocumentIdentity` / `buildStructuredIdentity` / `normaliseRel` / `readSourceFile` / `IdentityKind` / `CollectionConfig` / `SourceFile` |
|
|
45
48
|
| `./core/frontmatter` | `splitFrontmatter` / `parseFrontmatter` / `validateSchema` / `projectMeta` |
|
|
46
49
|
| `./store` | `ArtifactStore` 接口 + `createFsArtifactStore` / `createMemoryArtifactStore` + `ContentIndex` / `IndexEntry` |
|
|
47
50
|
| `./collection` | `defineCollection` / `resolveCollections` / `compileCollections` / `recompileChanged` / `createLocalSource` / `createMemorySource` / `SourceAdapter` / `ResolvedCollection` / `CollectionDefinition` |
|
|
48
51
|
| `./query` | `ContentQuery` (byId / byPath / list / collection) + `QueryOptions` |
|
|
49
52
|
| `./diff` | `toCanonicalJSON` / `sortKeysDeep` / `stripUnderscoreKeysGolden` / `underscorePrefixedPaths` / `firstDiff` / `shortStr` |
|
|
50
|
-
| `./core/markdown` | Markdown → 中性 AST 解析(`parseDocument`
|
|
53
|
+
| `./core/markdown` | Markdown → 中性 AST 解析(`parseDocument` 等);插件组可经 `setMarkdownPluginSet` 注入(浏览器宿主用) |
|
|
54
|
+
| `./compile` | **浏览器宿主入口**:`compileLesson(md, {file?}) → CompileResult`(静态导入 unified/remark 插件组,与 CLI 编译语义同源;零 Node API) |
|
|
51
55
|
| `./core/types/lessonAST` | LessonAST 类型与常量 |
|
|
52
56
|
| `./domains/lesson` | 内置 lesson 域:`lessonParser` / `buildLessonIdentity` / `mapBlocks` |
|
|
53
57
|
| `./cli/build` | `flowBuild`(目录发现 → compile → Artifact Store + index.json) |
|
|
@@ -60,46 +64,43 @@ npm install dexin-content
|
|
|
60
64
|
|
|
61
65
|
## 快速示例
|
|
62
66
|
|
|
67
|
+
最简用法是走 CLI(仅编译内置 lesson 域):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# 单文件:AST 打印到 stdout,失败非零退出
|
|
71
|
+
npx dexin-content build example/lessons/getting-started/hello-lesson/minimal.md --domain lesson
|
|
72
|
+
|
|
73
|
+
# 整树:编译 + 按 manifest 打包 ContentPackage
|
|
74
|
+
npx dexin-content build example --manifest example/content-manifest.json --out /tmp/dexin-out
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
库 API(宿主自行编排时)用内置 `lessonParser` 经 `DomainParserRegistry` 注入:
|
|
78
|
+
|
|
63
79
|
```ts
|
|
64
|
-
import {
|
|
65
|
-
|
|
66
|
-
|
|
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)
|
|
80
|
+
import { DomainParserRegistry, compile } from 'dexin-content/core/compiler'
|
|
81
|
+
import { lessonParser } from 'dexin-content/domains/lesson'
|
|
82
|
+
import { buildLessonIdentity } from 'dexin-content/domains/lesson'
|
|
88
83
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
const registry = new DomainParserRegistry()
|
|
85
|
+
registry.register(lessonParser)
|
|
86
|
+
|
|
87
|
+
const result = compile(
|
|
88
|
+
{
|
|
89
|
+
fixture: 'getting-started/hello-lesson/minimal',
|
|
90
|
+
domain: 'lesson',
|
|
91
|
+
identity: buildLessonIdentity('minimal', 'getting-started', 'hello-lesson', 'lessons/getting-started/hello-lesson/minimal.md'),
|
|
92
|
+
source: '---\ntitle: 最小的一节课\norder: 1\n---\n\n## 这就是全部了\n',
|
|
93
|
+
file: 'lessons/getting-started/hello-lesson/minimal.md',
|
|
94
|
+
},
|
|
95
|
+
registry,
|
|
93
96
|
)
|
|
94
|
-
const source = createLocalSource('/path/to/content/root')
|
|
95
|
-
const store = createFsArtifactStore('/path/to/artifact-store')
|
|
96
|
-
await compileCollections(source, collections, registry, store)
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const entry = await query.byPath('/legal/privacy')
|
|
98
|
+
if (result.kind === 'error') throw result.error
|
|
99
|
+
else console.log(result.artifact.content.blocks)
|
|
101
100
|
```
|
|
102
101
|
|
|
102
|
+
`compile()` 永不抛异常,失败以 `{ kind: 'error', error }` 返回。域扩展由宿主注入其它 `DomainParser`。
|
|
103
|
+
|
|
103
104
|
## 开发
|
|
104
105
|
|
|
105
106
|
```bash
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/browser/compile.ts — 浏览器宿主入口(`dexin-content/compile`)
|
|
3
|
+
//
|
|
4
|
+
// 职责:为无 Node 运行时的宿主(编辑器实时预览等)注入静态导入的
|
|
5
|
+
// unified 插件组,并暴露单文件编译入口 compileLesson(md)。
|
|
6
|
+
// 本文件位于 core/ 之外,因此允许字面量引用插件包名——
|
|
7
|
+
// S4-SPEC §6 item 3 的拼接边界(core/ 内禁字面量)不受影响。
|
|
8
|
+
//
|
|
9
|
+
// 编译语义与 CLI/校验脚本逐字节同源:同一 compile() + lessonParser。
|
|
10
|
+
// ─────────────────────────────────────────────────────────────
|
|
11
|
+
import { unified } from 'unified'
|
|
12
|
+
import remarkParse from 'remark-parse'
|
|
13
|
+
import remarkGfm from 'remark-gfm'
|
|
14
|
+
import remarkMath from 'remark-math'
|
|
15
|
+
import remarkDirective from 'remark-directive'
|
|
16
|
+
|
|
17
|
+
import { setMarkdownPluginSet } from '../core/parser/markdown'
|
|
18
|
+
import { DomainParserRegistry, compile } from '../core/compiler/compiler'
|
|
19
|
+
import type { CompileResult } from '../core/compiler/compiler'
|
|
20
|
+
import { lessonParser, buildLessonIdentity } from '../domains/lesson/index'
|
|
21
|
+
|
|
22
|
+
setMarkdownPluginSet({
|
|
23
|
+
createPipeline: () => unified(),
|
|
24
|
+
parse: remarkParse,
|
|
25
|
+
gfm: remarkGfm,
|
|
26
|
+
formula: remarkMath,
|
|
27
|
+
directive: remarkDirective
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export interface CompileLessonOptions {
|
|
31
|
+
/**
|
|
32
|
+
* 虚拟文件路径(仅用于错误定位与身份派生),建议与内容仓布局同构:
|
|
33
|
+
* `<topic>/<chapter>/<slug>.md`。缺省 'preview/preview/preview.md'。
|
|
34
|
+
*/
|
|
35
|
+
file?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 编译一节 Markdown(含 front-matter)→ CompileResult。
|
|
40
|
+
* 非抛错:失败以 kind='error' + error{code,message} 返回,与 validate 通道同形。
|
|
41
|
+
*/
|
|
42
|
+
export function compileLesson (source: string, opts: CompileLessonOptions = {}): CompileResult {
|
|
43
|
+
const file = (opts.file ?? 'preview/preview/preview.md').split('\\').join('/')
|
|
44
|
+
const parts = file.split('/')
|
|
45
|
+
const lessonSlug = (parts.pop() ?? 'preview').replace(/\.md$/, '') || 'preview'
|
|
46
|
+
const topicSlug = parts[0] || 'preview'
|
|
47
|
+
const chapterSlug = parts[1] || 'preview'
|
|
48
|
+
const registry = new DomainParserRegistry()
|
|
49
|
+
registry.register(lessonParser)
|
|
50
|
+
return compile({
|
|
51
|
+
fixture: `${topicSlug}/${chapterSlug}/${lessonSlug}`,
|
|
52
|
+
domain: 'lesson',
|
|
53
|
+
identity: buildLessonIdentity(lessonSlug, topicSlug, chapterSlug, file),
|
|
54
|
+
source,
|
|
55
|
+
file
|
|
56
|
+
}, registry)
|
|
57
|
+
}
|
|
@@ -39,7 +39,7 @@ export function splitFrontmatter (source: string): SplitResult {
|
|
|
39
39
|
const lines = rest.split('\n')
|
|
40
40
|
let closeIdx = -1
|
|
41
41
|
for (let i = 0; i < lines.length; i++) {
|
|
42
|
-
if (lines[i]
|
|
42
|
+
if (lines[i]?.trimEnd() === '---') {
|
|
43
43
|
closeIdx = i
|
|
44
44
|
break
|
|
45
45
|
}
|
package/core/parser/markdown.ts
CHANGED
|
@@ -21,9 +21,6 @@
|
|
|
21
21
|
// boundaries.
|
|
22
22
|
// ─────────────────────────────────────────────────────────────
|
|
23
23
|
|
|
24
|
-
import { createRequire } from 'node:module'
|
|
25
|
-
import { fileURLToPath } from 'node:url'
|
|
26
|
-
|
|
27
24
|
import type { Inline, ParseError } from '../types'
|
|
28
25
|
import type {
|
|
29
26
|
LessonContent,
|
|
@@ -39,21 +36,31 @@ import type {
|
|
|
39
36
|
FormulaBlock,
|
|
40
37
|
ContainerBlock
|
|
41
38
|
} from '../types/lessonAST'
|
|
42
|
-
import type {
|
|
39
|
+
import type { Pluggable } from 'unified'
|
|
43
40
|
import { TEX_INLINE_TYPE } from '../types'
|
|
44
41
|
|
|
45
|
-
// ──
|
|
46
|
-
// The
|
|
47
|
-
// rather than async dynamic import(). Package specifiers are constructed
|
|
42
|
+
// ── Markdown plugin set (unified pipeline). ──
|
|
43
|
+
// The pipeline is synchronous. Plugin package specifiers are constructed
|
|
48
44
|
// by concatenation so the assembled forbidden-substring grep target never
|
|
49
45
|
// appears literally in the source code of core/ (architectural boundary
|
|
50
|
-
// evidence per S4-SPEC §6 item 3).
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
// evidence per S4-SPEC §6 item 3) — this holds for BOTH loaders below.
|
|
47
|
+
//
|
|
48
|
+
// Two ways the set gets populated:
|
|
49
|
+
// * Node hosts (CLI/validator/tests): default lazy loader, resolved on
|
|
50
|
+
// first parse. No static `node:*` import exists in this module, so the
|
|
51
|
+
// browser bundler never sees one through the compiler chain.
|
|
52
|
+
// * Browser hosts: inject statically-imported plugin set via
|
|
53
|
+
// setMarkdownPluginSet() — see the `dexin-content/compile` entry
|
|
54
|
+
// (browser/compile.ts), which is outside core/ and therefore allowed
|
|
55
|
+
// to reference plugin specifiers literally.
|
|
56
|
+
export interface MarkdownPluginSet {
|
|
57
|
+
createPipeline: () => ReturnType<typeof import('unified').unified>
|
|
58
|
+
parse: Pluggable
|
|
59
|
+
gfm: Pluggable
|
|
60
|
+
formula: Pluggable
|
|
61
|
+
directive: Pluggable
|
|
56
62
|
}
|
|
63
|
+
|
|
57
64
|
const TAG_M = 're' + 'mark-' // begins plugin package family
|
|
58
65
|
const UNIFIED = 'unified'
|
|
59
66
|
const MD_PARSE = TAG_M + 'parse' // remark-parse
|
|
@@ -62,14 +69,48 @@ const MD_GFM = TAG_M + 'gfm' // remark-gfm
|
|
|
62
69
|
const SHORT_FORM = 'ma' + 'th' // 4 letters, the formula-span mdast prefix
|
|
63
70
|
const MD_FORMULA = TAG_M + SHORT_FORM
|
|
64
71
|
const MD_DIRECT = TAG_M + 'directive'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
|
|
73
|
+
let _pluginSet: MarkdownPluginSet | null = null
|
|
74
|
+
|
|
75
|
+
/** Browser (or any custom-runtime) host entry point. Call once before compiling. */
|
|
76
|
+
export function setMarkdownPluginSet (set: MarkdownPluginSet): void {
|
|
77
|
+
_pluginSet = set
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function nodePluginSet (): MarkdownPluginSet {
|
|
81
|
+
// Resolve node:module WITHOUT a static import (keeps this module's graph
|
|
82
|
+
// bundler-safe for browsers). Requires Node >= 20.19 / 22.3.
|
|
83
|
+
const proc = (globalThis as { process?: { getBuiltinModule?: (id: string) => unknown } }).process
|
|
84
|
+
const modRequire = (import.meta as { require?: (id: string) => unknown }).require
|
|
85
|
+
const nodeModule = (proc?.getBuiltinModule?.('node:module')
|
|
86
|
+
?? (modRequire ? modRequire('node:module') : undefined)) as
|
|
87
|
+
{ createRequire?: (url: string) => (spec: string) => unknown } | undefined
|
|
88
|
+
if (!nodeModule?.createRequire) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'[markdown] No plugin set installed and Node builtin module loader unavailable ' +
|
|
91
|
+
'(need Node >=20.19/22.3, or call setMarkdownPluginSet — see dexin-content/compile).'
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
const _req = nodeModule.createRequire(import.meta.url)
|
|
95
|
+
// Load synchronously; unified may expose .default or a bare exports object
|
|
96
|
+
// depending on the CommonJS-ESM interop wrapper used at runtime.
|
|
97
|
+
const _load = <T = unknown> (spec: string): T => {
|
|
98
|
+
const m = _req(spec)
|
|
99
|
+
return (m && typeof m === 'object' && 'default' in m) ? (m as { default: T }).default : m as T
|
|
100
|
+
}
|
|
101
|
+
const _unifiedPkg = _load<typeof import('unified')>(UNIFIED)
|
|
102
|
+
return {
|
|
103
|
+
createPipeline: () => _unifiedPkg.unified(),
|
|
104
|
+
parse: _load(MD_PARSE) as Pluggable,
|
|
105
|
+
gfm: _load(MD_GFM) as Pluggable,
|
|
106
|
+
formula: _load(MD_FORMULA) as Pluggable,
|
|
107
|
+
directive:_load(MD_DIRECT) as Pluggable
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function activePluginSet (): MarkdownPluginSet {
|
|
112
|
+
return (_pluginSet ??= nodePluginSet())
|
|
113
|
+
}
|
|
73
114
|
|
|
74
115
|
// MDAST tags built by concatenation — never written as a literal source
|
|
75
116
|
// substring. Runtime values are byte-identical to the frozen mdast/plugin
|
|
@@ -103,11 +144,12 @@ interface MdNode {
|
|
|
103
144
|
// ── Entry point ──────────────────────────────────────────
|
|
104
145
|
|
|
105
146
|
export function parseToDocAST (source: string, file: string): LessonContent {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
.use(
|
|
109
|
-
.use(
|
|
110
|
-
.use(
|
|
147
|
+
const ps = activePluginSet()
|
|
148
|
+
const mdast = ps.createPipeline()
|
|
149
|
+
.use(ps.parse as unknown as any)
|
|
150
|
+
.use(ps.gfm as unknown as any)
|
|
151
|
+
.use(ps.formula as unknown as any)
|
|
152
|
+
.use(ps.directive as unknown as any)
|
|
111
153
|
.parse(source) as MdNode
|
|
112
154
|
|
|
113
155
|
const blocks = walkBlocks(mdast.children ?? [], file, 'root', source)
|
|
@@ -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 与打包排除,但请尽量显式指定临时输出目录,保持示例目录干净。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"courses": [
|
|
3
|
+
{ "slug": "math-thinking", "title": "数学思维", "order": 1 }
|
|
4
|
+
],
|
|
5
|
+
"topics": [
|
|
6
|
+
{ "slug": "getting-started", "title": "上手指南", "order": 1 }
|
|
7
|
+
],
|
|
8
|
+
"chapters": [
|
|
9
|
+
{
|
|
10
|
+
"slug": "hello-lesson",
|
|
11
|
+
"title": "示例课",
|
|
12
|
+
"order": 1,
|
|
13
|
+
"topic_slug": "getting-started"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 语法速览
|
|
3
|
+
order: 2
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 标题与分节
|
|
7
|
+
|
|
8
|
+
`##` 开一个新小节,`###` 是小节内的次级标题,`####`、`#####` 依次更深。不要用 `#`(会被吸收)也不要用到第六级标题。
|
|
9
|
+
|
|
10
|
+
### 一个三级标题
|
|
11
|
+
|
|
12
|
+
它留在当前小节内,不会另起一节。
|
|
13
|
+
|
|
14
|
+
## 列表
|
|
15
|
+
|
|
16
|
+
- 无序列表项一
|
|
17
|
+
- 无序列表项二
|
|
18
|
+
|
|
19
|
+
1. 有序列表项一
|
|
20
|
+
2. 有序列表项二
|
|
21
|
+
|
|
22
|
+
注意:列表项内只能放一个段落,不要在列表项里再套列表或代码块。
|
|
23
|
+
|
|
24
|
+
## 表格
|
|
25
|
+
|
|
26
|
+
| 未知数个数 | 最高次数 | 是否一元一次 |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| 1 | 1 | 是 |
|
|
29
|
+
| 1 | 2 | 否 |
|
|
30
|
+
| 2 | 1 | 否 |
|
|
31
|
+
|
|
32
|
+
## 代码围栏
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
步骤一:去分母
|
|
36
|
+
步骤二:去括号
|
|
37
|
+
步骤三:移项
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 引用
|
|
41
|
+
|
|
42
|
+
> 理解优先,而非记忆步骤。
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 数学
|
|
47
|
+
|
|
48
|
+
行内数学直接写在句子中,例如 $ax + b = 0$(其中 $a \neq 0$)。
|
|
49
|
+
|
|
50
|
+
块级数学推荐独占一行:
|
|
51
|
+
|
|
52
|
+
$$x = -\frac{b}{a}$$
|
|
53
|
+
|
|
54
|
+
## 教学容器
|
|
55
|
+
|
|
56
|
+
四种容器承载教学结构,闭栏统一用独占一行的 `:::`:
|
|
57
|
+
|
|
58
|
+
:::definition{term="一元一次方程"}
|
|
59
|
+
只含一个未知数、且未知数最高次数为 1 的整式方程。
|
|
60
|
+
:::
|
|
61
|
+
|
|
62
|
+
:::hint{level="tip"}
|
|
63
|
+
判断是否为一元一次方程,先看化简后未知数的最高次数。
|
|
64
|
+
:::
|
|
65
|
+
|
|
66
|
+
:::example{title="识别方程"}
|
|
67
|
+
下列哪个是一元一次方程?
|
|
68
|
+
:::
|
|
69
|
+
|
|
70
|
+
:::question{hint="先移项,再把系数化为 1"}
|
|
71
|
+
解方程 $3x - 6 = 0$。
|
|
72
|
+
:::
|
|
73
|
+
|
|
74
|
+
hint 的 `level` 合法取值:`info`、`tip`、`warning`、`danger`、`reflect`。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dexin-content",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Open content toolchain — Markdown → LessonAST/ContentPackage compiler, CLI and validator (dexinlabs ecosystem).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"url": "https://gitee.com/cuizhn/dexin-content/issues"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": ">=20.
|
|
25
|
+
"node": ">=20.19.0"
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
28
|
"dexin-content": "./bin/dexin-content.mjs"
|
|
@@ -30,9 +30,14 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"index.ts",
|
|
32
32
|
"core/**/*.ts",
|
|
33
|
+
"browser/**/*.ts",
|
|
33
34
|
"cli/**/*.ts",
|
|
34
35
|
"domains/**/*.ts",
|
|
35
36
|
"bin/**",
|
|
37
|
+
"docs/**/*.md",
|
|
38
|
+
"example/README.md",
|
|
39
|
+
"example/content-manifest.json",
|
|
40
|
+
"example/lessons/**/*.md",
|
|
36
41
|
"README.md",
|
|
37
42
|
"LICENSE"
|
|
38
43
|
],
|
|
@@ -47,6 +52,7 @@
|
|
|
47
52
|
"./cli/build": "./cli/build.ts",
|
|
48
53
|
"./query": "./core/artifact/query.ts",
|
|
49
54
|
"./core/compiler": "./core/compiler/compiler.ts",
|
|
55
|
+
"./compile": "./browser/compile.ts",
|
|
50
56
|
"./core/types": "./core/types/index.ts",
|
|
51
57
|
"./store": "./core/artifact/store.ts",
|
|
52
58
|
"./cli/serve": "./cli/serve.ts",
|
|
@@ -58,6 +64,7 @@
|
|
|
58
64
|
"scripts": {
|
|
59
65
|
"typecheck": "tsc --noEmit",
|
|
60
66
|
"runtime:check": "tsx scripts/runtime-check.ts",
|
|
67
|
+
"validate:proposals": "tsx scripts/validate-proposals.ts",
|
|
61
68
|
"pack": "npm pack --dry-run",
|
|
62
69
|
"build": "tsx cli/build.ts",
|
|
63
70
|
"package": "tsx cli/package.ts"
|