dexin-content 0.1.2 → 0.2.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/LICENSE +202 -21
- package/bin/dexin-content.mjs +6 -0
- package/cli/build.ts +188 -0
- package/cli/check.ts +124 -0
- package/cli/main.ts +207 -0
- package/cli/package.ts +137 -0
- package/cli/serve.ts +135 -0
- package/cli/validate.ts +136 -0
- package/{collection.ts → core/artifact/collection.ts} +297 -289
- package/{diff.ts → core/artifact/diff.ts} +125 -124
- package/{query.ts → core/artifact/query.ts} +56 -56
- package/{store.ts → core/artifact/store.ts} +128 -128
- package/core/{compiler.ts → compiler/compiler.ts} +24 -21
- package/core/compiler/discovery.ts +116 -0
- package/core/{frontmatter.ts → parser/frontmatter.ts} +145 -144
- package/core/{markdown.ts → parser/markdown.ts} +343 -311
- package/core/types/index.ts +131 -0
- package/core/types/lessonAST.ts +264 -0
- package/core/validator/integrity.ts +199 -0
- package/domains/document/identity.ts +27 -0
- package/domains/document/index.ts +5 -0
- package/domains/document/parser.ts +105 -0
- package/domains/lesson/blocks.ts +205 -0
- package/domains/lesson/identity.ts +40 -0
- package/domains/lesson/index.ts +7 -0
- package/domains/lesson/parser.ts +200 -0
- package/index.ts +57 -8
- package/package.json +82 -69
- package/core/discovery.ts +0 -110
- package/core/types.ts +0 -162
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// domains/lesson/blocks.ts
|
|
3
|
+
//
|
|
4
|
+
// LessonAST v1 Block-level mapping (§6.3.3 frozen).
|
|
5
|
+
//
|
|
6
|
+
// * Plain blocks (paragraph / heading / quote / divider / list / table /
|
|
7
|
+
// image / code / formula) are structurally shared with DocumentAST.
|
|
8
|
+
// * ContainerBlock directives → LessonAST domain blocks.
|
|
9
|
+
// * Unknown container name → LESSON_UNKNOWN_CONTAINER (§2.4 L5-1).
|
|
10
|
+
// * Hint level enum invalid → LESSON_INVALID_HINT_LEVEL (§2.4 L5-2).
|
|
11
|
+
//
|
|
12
|
+
// This module is pure: given a Block array (already downgraded &
|
|
13
|
+
// de-h1'd by parser.ts section/grouping pass), map each node to
|
|
14
|
+
// LessonAST Block shape.
|
|
15
|
+
// ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
Inline,
|
|
19
|
+
TextInline,
|
|
20
|
+
BoldInline,
|
|
21
|
+
ItalicInline,
|
|
22
|
+
CodeInline,
|
|
23
|
+
LinkInline,
|
|
24
|
+
FormulaInline,
|
|
25
|
+
ParseContext,
|
|
26
|
+
ParseError
|
|
27
|
+
} from '../../core/types/index'
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
Block,
|
|
31
|
+
HintBlock,
|
|
32
|
+
HeadingBlock,
|
|
33
|
+
QuestionBlock,
|
|
34
|
+
ContainerBlock
|
|
35
|
+
} from '../../core/types/lessonAST'
|
|
36
|
+
|
|
37
|
+
export const LESSON_HINT_LEVELS: ReadonlyArray<HintBlock['level']> = [
|
|
38
|
+
'info','tip','warning','danger','reflect'
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
// The ONLY containers recognised by the lesson domain.
|
|
42
|
+
export const KNOWN_CONTAINERS = new Set([
|
|
43
|
+
'hint','definition','example','question'
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
export interface MapOutput {
|
|
47
|
+
blocks: Block[]
|
|
48
|
+
/** Forbidden-behaviour tags observed during mapping. */
|
|
49
|
+
forbidden: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mapBlocks (
|
|
53
|
+
docBlocks: Block[],
|
|
54
|
+
ctx: ParseContext
|
|
55
|
+
): MapOutput {
|
|
56
|
+
const forbidden: string[] = []
|
|
57
|
+
const blocks: Block[] = []
|
|
58
|
+
for (const b of docBlocks) blocks.push(mapOne(b, ctx, forbidden))
|
|
59
|
+
return { blocks, forbidden }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Individual mapping ───────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function mapOne (
|
|
65
|
+
b: Block,
|
|
66
|
+
ctx: ParseContext,
|
|
67
|
+
forbidden: string[]
|
|
68
|
+
): Block {
|
|
69
|
+
switch (b.type) {
|
|
70
|
+
case 'paragraph': return { type:'paragraph', children: cloneInlines(b.children) }
|
|
71
|
+
case 'heading': return { type:'heading', level:b.level, children: cloneInlines(b.children) }
|
|
72
|
+
case 'quote': return { type:'quote', children: mapInner(b.children, ctx, forbidden) }
|
|
73
|
+
case 'divider': return { type:'divider' }
|
|
74
|
+
case 'list': return {
|
|
75
|
+
type:'list',
|
|
76
|
+
ordered: b.ordered,
|
|
77
|
+
items: b.items.map(item => item.map(it => cloneInline(it)))
|
|
78
|
+
}
|
|
79
|
+
case 'table': return {
|
|
80
|
+
type:'table',
|
|
81
|
+
headers: b.headers.map(c => c.map(it => cloneInline(it))),
|
|
82
|
+
rows: b.rows.map(row => row.map(c => c.map(it => cloneInline(it))))
|
|
83
|
+
}
|
|
84
|
+
case 'image': return { type:'image', src:b.src, alt:b.alt, caption:b.caption }
|
|
85
|
+
case 'code': return { type:'code', language:b.language, code:b.code }
|
|
86
|
+
case 'formula': return { type:'formula', latex:b.latex, display:b.display }
|
|
87
|
+
case 'container': return mapContainer(b, ctx, forbidden)
|
|
88
|
+
case 'hint':
|
|
89
|
+
case 'definition':
|
|
90
|
+
case 'example':
|
|
91
|
+
case 'question':
|
|
92
|
+
case 'section':
|
|
93
|
+
// Already domain-specific blocks — pass through
|
|
94
|
+
return b
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function mapInner (
|
|
99
|
+
inner: Block[],
|
|
100
|
+
ctx: ParseContext,
|
|
101
|
+
forbidden: string[]
|
|
102
|
+
): Block[] {
|
|
103
|
+
return inner.map(x => mapOne(x, ctx, forbidden))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mapContainer (
|
|
107
|
+
node: ContainerBlock,
|
|
108
|
+
ctx: ParseContext,
|
|
109
|
+
forbidden: string[]
|
|
110
|
+
): Block {
|
|
111
|
+
const name = node.name
|
|
112
|
+
if (!KNOWN_CONTAINERS.has(name)) {
|
|
113
|
+
forbidden.push('treat_as_paragraph')
|
|
114
|
+
forbidden.push('silently_drop')
|
|
115
|
+
forbidden.push('return_null')
|
|
116
|
+
throw lessonError(
|
|
117
|
+
'LESSON_UNKNOWN_CONTAINER',
|
|
118
|
+
`[LESSON_UNKNOWN_CONTAINER] Unknown container '${name}' — lesson domain only allows hint/definition/example/question. Container name: ${name}. file: ${ctx.file}`,
|
|
119
|
+
ctx.file,
|
|
120
|
+
{ container: name }
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
switch (name) {
|
|
124
|
+
case 'definition': {
|
|
125
|
+
const term = node.attrs['term'] ?? ''
|
|
126
|
+
return {
|
|
127
|
+
type:'definition',
|
|
128
|
+
term,
|
|
129
|
+
children: mapInner(node.children, ctx, forbidden)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
case 'hint': {
|
|
133
|
+
const level = node.attrs['level'] ?? node.attrs['name'] ?? ''
|
|
134
|
+
const allowedStr = LESSON_HINT_LEVELS.join('|')
|
|
135
|
+
if (!(LESSON_HINT_LEVELS as readonly string[]).includes(level)) {
|
|
136
|
+
forbidden.push('accept_invalid_enum')
|
|
137
|
+
throw lessonError(
|
|
138
|
+
'LESSON_INVALID_HINT_LEVEL',
|
|
139
|
+
`[LESSON_INVALID_HINT_LEVEL] Invalid level '${level}' for hint container. Allowed values: ${allowedStr}. level: ${level}, hint. file: ${ctx.file}`,
|
|
140
|
+
ctx.file,
|
|
141
|
+
{ container: 'hint', invalidEnumValue: level, allowed: allowedStr }
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
type:'hint',
|
|
146
|
+
level: level as HintBlock['level'],
|
|
147
|
+
children: mapInner(node.children, ctx, forbidden)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
case 'example': {
|
|
151
|
+
const title = node.attrs['title']
|
|
152
|
+
return {
|
|
153
|
+
type:'example',
|
|
154
|
+
title: title === undefined ? undefined : (title === '' ? undefined : title),
|
|
155
|
+
children: mapInner(node.children, ctx, forbidden)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
case 'question': {
|
|
159
|
+
const hint = node.attrs['hint']
|
|
160
|
+
const prompt = mapInner(node.children, ctx, forbidden)
|
|
161
|
+
const q: QuestionBlock = { type:'question', prompt }
|
|
162
|
+
if (hint !== undefined && hint !== '') q.hint = hint
|
|
163
|
+
return q
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// unreachable
|
|
167
|
+
throw lessonError(
|
|
168
|
+
'LESSON_UNKNOWN_CONTAINER',
|
|
169
|
+
`[LESSON_UNKNOWN_CONTAINER] Unknown container '${name}'. file: ${ctx.file}`,
|
|
170
|
+
ctx.file,
|
|
171
|
+
{ container: name }
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Inline cloning ──
|
|
176
|
+
|
|
177
|
+
function cloneInline (i: Inline): Inline {
|
|
178
|
+
switch (i.type) {
|
|
179
|
+
case 'text': return { type:'text', value:(i as TextInline).value }
|
|
180
|
+
case 'code': return { type:'code', value:(i as CodeInline).value }
|
|
181
|
+
case 'math': return { type:'math', latex:(i as FormulaInline).latex }
|
|
182
|
+
case 'bold': return { type:'bold', children: cloneInlines((i as BoldInline).children) }
|
|
183
|
+
case 'italic': return { type:'italic', children: cloneInlines((i as ItalicInline).children) }
|
|
184
|
+
case 'link': return { type:'link', url:(i as LinkInline).url, children: cloneInlines((i as LinkInline).children) }
|
|
185
|
+
default:
|
|
186
|
+
return i
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function cloneInlines (xs: Inline[]): Inline[] { return xs.map(cloneInline) }
|
|
190
|
+
|
|
191
|
+
// ── Error helper ─────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export function lessonError (
|
|
194
|
+
code: string,
|
|
195
|
+
message: string,
|
|
196
|
+
file: string,
|
|
197
|
+
loc?: ParseError['loc']
|
|
198
|
+
): ParseError & Error {
|
|
199
|
+
const e = new Error(message) as ParseError & Error
|
|
200
|
+
e.code = code
|
|
201
|
+
e.message = message
|
|
202
|
+
e.file = file
|
|
203
|
+
if (loc) e.loc = loc
|
|
204
|
+
return e
|
|
205
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LessonIdentity — dexinlabs-specific identity builder for lesson content.
|
|
3
|
+
*
|
|
4
|
+
* Maps lesson path slugs (topic/chapter/lesson) to DocumentIdentity shape
|
|
5
|
+
* with host-specific extension fields via index signature.
|
|
6
|
+
*
|
|
7
|
+
* This keeps dexin-content generic while allowing dexinlabs to maintain
|
|
8
|
+
* its hierarchical lesson structure.
|
|
9
|
+
*/
|
|
10
|
+
import type { DocumentIdentity } from '../../core/types/index'
|
|
11
|
+
|
|
12
|
+
export interface LessonIdentityFields {
|
|
13
|
+
slug: string
|
|
14
|
+
topic_slug: string
|
|
15
|
+
chapter_slug: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a DocumentIdentity for a lesson file, extending it with
|
|
20
|
+
* dexinlabs-specific fields via the [k:string]:unknown index signature.
|
|
21
|
+
*/
|
|
22
|
+
export function buildLessonIdentity(
|
|
23
|
+
lessonSlug: string,
|
|
24
|
+
topicSlug: string,
|
|
25
|
+
chapterSlug: string,
|
|
26
|
+
relPath: string
|
|
27
|
+
): DocumentIdentity & LessonIdentityFields {
|
|
28
|
+
const id = `${topicSlug}/${chapterSlug}/${lessonSlug}`
|
|
29
|
+
const path = `/${id}`
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
path,
|
|
34
|
+
file: relPath,
|
|
35
|
+
collection: 'lessons',
|
|
36
|
+
slug: lessonSlug,
|
|
37
|
+
topic_slug: topicSlug,
|
|
38
|
+
chapter_slug: chapterSlug
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lesson-domain barrel
|
|
3
|
+
*/
|
|
4
|
+
export { lessonParser } from './parser'
|
|
5
|
+
export { lessonError, mapBlocks, KNOWN_CONTAINERS, LESSON_HINT_LEVELS } from './blocks'
|
|
6
|
+
export { buildLessonIdentity } from './identity'
|
|
7
|
+
export type { LessonIdentityFields } from './identity'
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// domains/lesson/parser.ts
|
|
3
|
+
//
|
|
4
|
+
// Lesson domain parser — DomainParser implementation.
|
|
5
|
+
//
|
|
6
|
+
// Responsibilities (§6.3.5 frozen, DIFF-RULING R-06/08):
|
|
7
|
+
// a) h1 absorption: drop DocumentAST heading.level=1 nodes (they must be
|
|
8
|
+
// treated as redundant with frontmatter.title). No error.
|
|
9
|
+
// b) §6.3.5 heading downgrade table applied to all remaining headings
|
|
10
|
+
// BEFORE section grouping runs:
|
|
11
|
+
// md h1 → level 1 (dropped by rule a)
|
|
12
|
+
// md h2 → 1
|
|
13
|
+
// md h3 → 2
|
|
14
|
+
// md h4 → 3
|
|
15
|
+
// md h5 → 4
|
|
16
|
+
// The transformation here shifts md depth → lesson level using the
|
|
17
|
+
// *original* mdDepth:
|
|
18
|
+
// lesson_level = mdDepth - 1
|
|
19
|
+
// After shift any lesson level 0 means we had an md h1 → absorption.
|
|
20
|
+
// c) Section grouping: walk blocks; whenever we see LESSON-level=1 heading
|
|
21
|
+
// it opens a new SectionBlock; subsequent blocks go in that section.
|
|
22
|
+
// d) Heading depth fail-fast for h6: lesson domain allows md h5 → level 4
|
|
23
|
+
// (unlike the document domain, which rejects md h5/h6). md depth 6 is
|
|
24
|
+
// always undefined → LESSON_HEADING_DEPTH_UNDEFINED.
|
|
25
|
+
// e) Container mapping delegates to blocks.ts — which handles
|
|
26
|
+
// Hint level enum validation (L5-2) + unknown containers (L5-1).
|
|
27
|
+
// ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
DomainParser,
|
|
31
|
+
ParseContext
|
|
32
|
+
} from '../../core/types/index'
|
|
33
|
+
import type { LessonContent, Block, HeadingBlock, SectionBlock } from '../../core/types/lessonAST'
|
|
34
|
+
import {
|
|
35
|
+
mapBlocks,
|
|
36
|
+
lessonError
|
|
37
|
+
} from './blocks'
|
|
38
|
+
|
|
39
|
+
export const lessonParser: DomainParser = {
|
|
40
|
+
domain: 'lesson',
|
|
41
|
+
parse (content: LessonContent, ctx: ParseContext): LessonContent {
|
|
42
|
+
// 1. Heading pass: absorption + downgrade + fail-fast.
|
|
43
|
+
const afterHeading = passHeadings(content.blocks, ctx)
|
|
44
|
+
|
|
45
|
+
// 2. Section grouping by lesson-level=1 headings.
|
|
46
|
+
const sectioned = passSections(afterHeading)
|
|
47
|
+
|
|
48
|
+
// 3. Container mapping + container validation (L5-1/L5-2).
|
|
49
|
+
const { blocks, forbidden } = mapSectioned(sectioned, ctx)
|
|
50
|
+
void forbidden
|
|
51
|
+
|
|
52
|
+
return { version: 1, blocks }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Pass 1: heading absorption / downgrade / fail-fast ──
|
|
57
|
+
|
|
58
|
+
type TransformedBlock = Block
|
|
59
|
+
|
|
60
|
+
function passHeadings (
|
|
61
|
+
blocks: Block[],
|
|
62
|
+
ctx: ParseContext
|
|
63
|
+
): TransformedBlock[] {
|
|
64
|
+
const out: TransformedBlock[] = []
|
|
65
|
+
walkWithHeadings(blocks, ctx, out)
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function walkWithHeadings (
|
|
70
|
+
blocks: Block[],
|
|
71
|
+
ctx: ParseContext,
|
|
72
|
+
out: Block[],
|
|
73
|
+
depth = 0
|
|
74
|
+
): void {
|
|
75
|
+
void depth
|
|
76
|
+
for (const b of blocks) {
|
|
77
|
+
if (b.type === 'heading') {
|
|
78
|
+
const docLevel = b.level
|
|
79
|
+
const hb = b as HeadingBlock
|
|
80
|
+
const mdDepth = hb.mdDepth ?? docLevel
|
|
81
|
+
if (mdDepth === 6) {
|
|
82
|
+
throw lessonError(
|
|
83
|
+
'LESSON_HEADING_DEPTH_UNDEFINED',
|
|
84
|
+
`[LESSON_HEADING_DEPTH_UNDEFINED] Heading h6 (level 6) has no mapping in §6.3.5 (only h1 absorption plus h2→level1 to h5→level4 defined). Heading depth undefined. Allowed: h1-h5. file: ${ctx.file}`,
|
|
85
|
+
ctx.file,
|
|
86
|
+
{ headingDepth: 6 }
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (mdDepth > 6) {
|
|
90
|
+
throw lessonError(
|
|
91
|
+
'LESSON_HEADING_DEPTH_UNDEFINED',
|
|
92
|
+
`[LESSON_HEADING_DEPTH_UNDEFINED] Heading h${mdDepth} (level ${mdDepth}) has no mapping. Allowed: h1-h5. file: ${ctx.file}`,
|
|
93
|
+
ctx.file,
|
|
94
|
+
{ headingDepth: mdDepth }
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (mdDepth === 1) {
|
|
99
|
+
// R-08: h1 absorption — drop the heading entirely (no error, no downgrade).
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
// Downgrade: lesson_level = md_depth - 1. md ∈ {2,3,4,5}.
|
|
103
|
+
const lessonLevel = mdDepth - 1
|
|
104
|
+
if (lessonLevel < 1 || lessonLevel > 4) {
|
|
105
|
+
throw lessonError(
|
|
106
|
+
'LESSON_HEADING_DEPTH_UNDEFINED',
|
|
107
|
+
`[LESSON_HEADING_DEPTH_UNDEFINED] Heading h${mdDepth} → lesson level ${lessonLevel} out of range. Allowed heading levels: h1-h5. file: ${ctx.file}`,
|
|
108
|
+
ctx.file,
|
|
109
|
+
{ headingDepth: mdDepth }
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
// Explicitly drop the transient mdDepth field when converting to lesson
|
|
113
|
+
// heading; never carry transient non-canonical fields downstream.
|
|
114
|
+
out.push({
|
|
115
|
+
type: 'heading',
|
|
116
|
+
level: lessonLevel as 1|2|3|4,
|
|
117
|
+
children: b.children
|
|
118
|
+
})
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
// Non-heading: descend into block children for quote/container to
|
|
122
|
+
// downgrade any headings they embed (rare, but correct).
|
|
123
|
+
out.push(transformBlockHeadings(b, ctx))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function transformBlockHeadings (b: Block, ctx: ParseContext): Block {
|
|
128
|
+
switch (b.type) {
|
|
129
|
+
case 'quote': {
|
|
130
|
+
const inner: Block[] = []
|
|
131
|
+
walkWithHeadings(b.children, ctx, inner)
|
|
132
|
+
return { type:'quote', children: inner }
|
|
133
|
+
}
|
|
134
|
+
case 'container': {
|
|
135
|
+
const inner: Block[] = []
|
|
136
|
+
walkWithHeadings(b.children, ctx, inner)
|
|
137
|
+
return { type:'container', name:b.name, attrs:b.attrs, children: inner }
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
return b
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Pass 2: Section grouping (§6.3.6 — h2→起节 = lesson level=1) ──
|
|
145
|
+
|
|
146
|
+
interface SectionMarker {
|
|
147
|
+
kind: 'section'
|
|
148
|
+
titleInline: SectionBlock['title'] // original heading inlines
|
|
149
|
+
body: Block[] // body still in Block shape for now
|
|
150
|
+
}
|
|
151
|
+
interface OrphanBlock {
|
|
152
|
+
kind: 'orphan'
|
|
153
|
+
block: Block
|
|
154
|
+
}
|
|
155
|
+
type Grouped = SectionMarker | OrphanBlock
|
|
156
|
+
|
|
157
|
+
function passSections (blocks: Block[]): Grouped[] {
|
|
158
|
+
const out: Grouped[] = []
|
|
159
|
+
let current: SectionMarker | null = null
|
|
160
|
+
for (const b of blocks) {
|
|
161
|
+
if (b.type === 'heading' && b.level === 1) {
|
|
162
|
+
current = { kind:'section', titleInline: b.children, body: [] }
|
|
163
|
+
out.push(current)
|
|
164
|
+
} else {
|
|
165
|
+
if (current) current.body.push(b)
|
|
166
|
+
else out.push({ kind:'orphan', block: b })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Pass 3: Final Block mapping with sections ──
|
|
173
|
+
|
|
174
|
+
function mapSectioned (
|
|
175
|
+
grouped: Grouped[],
|
|
176
|
+
ctx: ParseContext
|
|
177
|
+
): { blocks: Block[]; forbidden: string[] } {
|
|
178
|
+
const forbidden: string[] = []
|
|
179
|
+
const result: Block[] = []
|
|
180
|
+
for (const g of grouped) {
|
|
181
|
+
if (g.kind === 'orphan') {
|
|
182
|
+
const { blocks, forbidden: f } = mapBlocks([g.block], ctx)
|
|
183
|
+
forbidden.push(...f)
|
|
184
|
+
result.push(...blocks)
|
|
185
|
+
} else {
|
|
186
|
+
const { blocks, forbidden: f } = mapBlocks(g.body, ctx)
|
|
187
|
+
forbidden.push(...f)
|
|
188
|
+
const section: SectionBlock = {
|
|
189
|
+
type: 'section',
|
|
190
|
+
title: g.titleInline,
|
|
191
|
+
blocks
|
|
192
|
+
}
|
|
193
|
+
result.push(section)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { blocks: result, forbidden }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Re-export lessonError / mapBlocks for direct use by external callers.
|
|
200
|
+
export { lessonError, mapBlocks }
|
package/index.ts
CHANGED
|
@@ -2,11 +2,60 @@
|
|
|
2
2
|
// Re-exports core types and interfaces for host consumption.
|
|
3
3
|
// Domain parsers are injected by the host via DomainParserRegistry.
|
|
4
4
|
|
|
5
|
-
export type {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export {
|
|
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'
|