dexin-content 0.1.2 → 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.
- 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} +78 -62
- 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,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'
|
package/package.json
CHANGED
|
@@ -1,69 +1,82 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dexin-content",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"keywords": [
|
|
6
|
-
"markdown",
|
|
7
|
-
"content",
|
|
8
|
-
"compiler",
|
|
9
|
-
"ast",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"./core/
|
|
41
|
-
"
|
|
42
|
-
"./
|
|
43
|
-
"./core/
|
|
44
|
-
"./
|
|
45
|
-
"./
|
|
46
|
-
"./collection": "./collection.ts",
|
|
47
|
-
"./
|
|
48
|
-
"./
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"tsx": "^4.
|
|
68
|
-
|
|
69
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dexin-content",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Open content toolchain — Markdown → LessonAST/ContentPackage compiler, CLI and validator (dexinlabs ecosystem).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"markdown",
|
|
7
|
+
"content",
|
|
8
|
+
"compiler",
|
|
9
|
+
"ast",
|
|
10
|
+
"remark",
|
|
11
|
+
"dexinlabs"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"author": "得心实验室 <https://gitee.com/cuizhn>",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://gitee.com/cuizhn/dexin-content.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://gitee.com/cuizhn/dexin-content#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://gitee.com/cuizhn/dexin-content/issues"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20.0.0"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"dexin-content": "./bin/dexin-content.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.ts",
|
|
32
|
+
"core/**/*.ts",
|
|
33
|
+
"cli/**/*.ts",
|
|
34
|
+
"domains/**/*.ts",
|
|
35
|
+
"bin/**",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"exports": {
|
|
40
|
+
"./core/markdown": "./core/parser/markdown.ts",
|
|
41
|
+
".": "./index.ts",
|
|
42
|
+
"./cli/validate": "./cli/validate.ts",
|
|
43
|
+
"./core/frontmatter": "./core/parser/frontmatter.ts",
|
|
44
|
+
"./diff": "./core/artifact/diff.ts",
|
|
45
|
+
"./cli/package": "./cli/package.ts",
|
|
46
|
+
"./collection": "./core/artifact/collection.ts",
|
|
47
|
+
"./cli/build": "./cli/build.ts",
|
|
48
|
+
"./query": "./core/artifact/query.ts",
|
|
49
|
+
"./core/compiler": "./core/compiler/compiler.ts",
|
|
50
|
+
"./core/types": "./core/types/index.ts",
|
|
51
|
+
"./store": "./core/artifact/store.ts",
|
|
52
|
+
"./cli/serve": "./cli/serve.ts",
|
|
53
|
+
"./cli/check": "./cli/check.ts",
|
|
54
|
+
"./core/discovery": "./core/compiler/discovery.ts",
|
|
55
|
+
"./core/types/lessonAST": "./core/types/lessonAST.ts",
|
|
56
|
+
"./domains/lesson": "./domains/lesson/index.ts",
|
|
57
|
+
"./domains/document": "./domains/document/index.ts"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"typecheck": "tsc --noEmit",
|
|
61
|
+
"runtime:check": "tsx scripts/runtime-check.ts",
|
|
62
|
+
"pack": "npm pack --dry-run",
|
|
63
|
+
"build": "tsx cli/build.ts",
|
|
64
|
+
"package": "tsx cli/package.ts"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"tsx": "^4.23.0",
|
|
68
|
+
"yaml": "^2.5.1",
|
|
69
|
+
"remark-parse": "^11.0.0",
|
|
70
|
+
"remark-gfm": "^4.0.0",
|
|
71
|
+
"remark-math": "^6.0.0",
|
|
72
|
+
"remark-directive": "^3.0.0",
|
|
73
|
+
"unified": "^11.0.0",
|
|
74
|
+
"remark": "^15.0.0",
|
|
75
|
+
"micromark-extension-directive": "^3.0.0"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@types/node": "^22.0.0",
|
|
79
|
+
"@types/mdast": "^4.0.4",
|
|
80
|
+
"typescript": "^5.5.0"
|
|
81
|
+
}
|
|
82
|
+
}
|
package/core/discovery.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// dexin-content/core/discovery.ts
|
|
3
|
-
// Source adapter + collection routing + identity derivation helpers.
|
|
4
|
-
//
|
|
5
|
-
// Document path convention → DocumentIdentity:
|
|
6
|
-
// id = relPath minus .md, minus trailing `/index`
|
|
7
|
-
// path = '/' + id
|
|
8
|
-
// file = relPath WITH .md extension
|
|
9
|
-
// + auxiliary fields: collection, (optionally) index_file:true
|
|
10
|
-
//
|
|
11
|
-
// Hosts needing domain-specific identity shapes define their own types
|
|
12
|
-
// and construct CompileInput identities themselves; core only knows the
|
|
13
|
-
// document convention.
|
|
14
|
-
// ─────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
import path from 'node:path'
|
|
17
|
-
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
18
|
-
import type { DocumentIdentity, ParseError } from './types'
|
|
19
|
-
|
|
20
|
-
// ── Shared BOM / CRLF sanitisation primitives ─────────────
|
|
21
|
-
// Used by both sync (readSourceFile) and async (createLocalSource.read)
|
|
22
|
-
// IO entry points to guarantee byte-identical failure behaviour.
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Build a LINE_ENDING_CONTAMINATION ParseError for a given relative path.
|
|
26
|
-
* Caller supplies a short remediation hint matching its context.
|
|
27
|
-
*/
|
|
28
|
-
export function mkLineEndingError (
|
|
29
|
-
relPath: string,
|
|
30
|
-
hint: string = 'Normalize to LF before processing.'
|
|
31
|
-
): ParseError & Error {
|
|
32
|
-
const err = new Error(
|
|
33
|
-
`[LINE_ENDING_CONTAMINATION] Source file '${relPath}' contains CRLF line endings. ` +
|
|
34
|
-
hint
|
|
35
|
-
) as ParseError & Error
|
|
36
|
-
err.code = 'LINE_ENDING_CONTAMINATION'
|
|
37
|
-
err.file = relPath
|
|
38
|
-
return err
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Strip a leading UTF-8 BOM (U+FEFF) if present and guard against CRLF
|
|
43
|
-
* contamination. Returns the cleaned source string; throws with
|
|
44
|
-
* LINE_ENDING_CONTAMINATION code on CR presence.
|
|
45
|
-
*
|
|
46
|
-
* This is intentionally a pure string-in / string-out (or throw) helper so
|
|
47
|
-
* both sync and async IO paths share one canonical sanitisation.
|
|
48
|
-
*/
|
|
49
|
-
export function sanitizeSourceText (raw: string, relPath: string): string {
|
|
50
|
-
const source = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw
|
|
51
|
-
if (source.includes('\r')) throw mkLineEndingError(relPath)
|
|
52
|
-
return source
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Normalise a relative path to forward slashes and strip any leading `./`.
|
|
57
|
-
*/
|
|
58
|
-
export function normaliseRel (p: string): string {
|
|
59
|
-
let out = p.split(path.sep).join('/')
|
|
60
|
-
while (out.startsWith('./')) out = out.slice(2)
|
|
61
|
-
return out
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Build DocumentIdentity from a `.md` file path relative
|
|
66
|
-
* to the collection source root.
|
|
67
|
-
*/
|
|
68
|
-
export function buildDocumentIdentity (
|
|
69
|
-
relPath: string,
|
|
70
|
-
collection: string
|
|
71
|
-
): DocumentIdentity {
|
|
72
|
-
const norm = normaliseRel(relPath)
|
|
73
|
-
const file = norm
|
|
74
|
-
|
|
75
|
-
let id = file.endsWith('.md') ? file.slice(0, -'.md'.length) : file
|
|
76
|
-
if (id.endsWith('.markdown')) id = id.slice(0, -'.markdown'.length)
|
|
77
|
-
|
|
78
|
-
const isIndex = id.endsWith('/index') || id === 'index'
|
|
79
|
-
if (isIndex) {
|
|
80
|
-
if (id === 'index') id = ''
|
|
81
|
-
else id = id.slice(0, -'/index'.length)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const pathUrl = '/' + id
|
|
85
|
-
const identity: DocumentIdentity = {
|
|
86
|
-
id,
|
|
87
|
-
path: pathUrl,
|
|
88
|
-
file,
|
|
89
|
-
collection
|
|
90
|
-
}
|
|
91
|
-
if (isIndex) identity.index_file = true
|
|
92
|
-
return identity
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Read a single UTF-8 file and pair it with its relative path. */
|
|
96
|
-
export interface SourceFile {
|
|
97
|
-
absPath: string
|
|
98
|
-
relPath: string
|
|
99
|
-
source: string
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function readSourceFile (absPath: string, sourceRoot: string): SourceFile {
|
|
103
|
-
if (!existsSync(absPath) || !statSync(absPath).isFile()) {
|
|
104
|
-
throw new Error(`[discovery] Not a file or missing: ${absPath}`)
|
|
105
|
-
}
|
|
106
|
-
const raw = readFileSync(absPath, 'utf8')
|
|
107
|
-
const rel = normaliseRel(path.relative(sourceRoot, absPath))
|
|
108
|
-
const source = sanitizeSourceText(raw, rel)
|
|
109
|
-
return { absPath, relPath: rel, source }
|
|
110
|
-
}
|