dexin-content 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
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()
@@ -0,0 +1,27 @@
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
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * document-domain barrel
3
+ */
4
+ export { documentParser } from './parser'
5
+ export { buildDocumentIdentity } from './identity'
@@ -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'