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.
@@ -1,9 +1,9 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // dexin-content/core/compiler.ts
2
+ // dexin-content/core/compiler/compiler.ts
3
3
  //
4
4
  // Pipeline:
5
5
  // read source → split frontmatter → project meta (SCHEMA-FREE) →
6
- // validate schema (if any) → parse Markdown to DocumentAST →
6
+ // validate schema (if any) → parse Markdown to AST →
7
7
  // dispatch to domain parser → assemble PositiveArtifact (5 fields).
8
8
  //
9
9
  // The compiler does NOT hard-code domain parsers internally. Instead they
@@ -13,20 +13,20 @@
13
13
 
14
14
  import type {
15
15
  DomainParser,
16
- DocumentContent,
17
16
  Artifact,
18
17
  ParseContext,
19
- DocumentIdentity,
18
+ Identity,
20
19
  Meta,
21
20
  ParseError,
22
21
  Schema
23
- } from './types'
22
+ } from '../types'
23
+ import type { LessonContent } from '../types/lessonAST'
24
24
  import {
25
25
  parseFrontmatter,
26
26
  splitFrontmatter,
27
27
  validateSchema
28
- } from './frontmatter'
29
- import { parseDocument } from './markdown'
28
+ } from '../parser/frontmatter'
29
+ import { parseDocument } from '../parser/markdown'
30
30
 
31
31
  export type ArtifactKind = 'positive' | 'error'
32
32
 
@@ -45,15 +45,15 @@ export class DomainParserRegistry {
45
45
 
46
46
  /**
47
47
  * Inputs required by `compile`. This is deliberately a plain object so the
48
- * host runner can mix-and-match per-collection identity derivation rules.
48
+ * host runner can mix-and-match per-fixture identity derivation rules.
49
49
  */
50
50
  export interface CompileInput {
51
- /** Stable document id — used in the output Artifact.id field */
52
- id: string
51
+ /** Fixture id (e.g. "L1-minimal") — used in output Artifact.fixture field */
52
+ fixture: string
53
53
  /** The domain tag to compile under. Determines parser lookup. */
54
54
  domain: string
55
- /** Identity shape computed by host/discovery layer (document path convention). */
56
- identity: DocumentIdentity
55
+ /** Identity shape computed by host/discovery layer (6.3.15 for docs). */
56
+ identity: Identity
57
57
  /** Raw Markdown source WITH frontmatter. */
58
58
  source: string
59
59
  /** File path used in error messages (relative, short form). */
@@ -65,8 +65,8 @@ export interface CompileInput {
65
65
  export interface CompileResult {
66
66
  kind: ArtifactKind
67
67
  artifact: Artifact
68
- /** Parsed DocumentAST; undefined if parsing failed before this stage. */
69
- docAST?: DocumentContent
68
+ /** Parsed AST; undefined if parsing failed before this stage. */
69
+ docAST?: LessonContent
70
70
  /** Parsed meta (SCHEMA-FREE scalar projection). Always present. */
71
71
  meta: Meta
72
72
  error?: Error & ParseError
@@ -87,15 +87,18 @@ export function compile (
87
87
  const { frontmatter, body } = splitFrontmatter(input.source)
88
88
  meta = parseFrontmatter(frontmatter, input.file)
89
89
 
90
- // 2. Schema validation (schema is validator-only — doesn't change meta).
90
+ // 2. Schema validation (§6.3.13: schema is validator-only — doesn't change meta).
91
91
  validateSchema(meta, input.schema, input.file)
92
92
 
93
- // 3. Markdown → DocumentAST (neutral).
93
+ // 3. Markdown → AST (neutral).
94
94
  // Note: the GENERIC layer does NOT throw for md h5/h6 — it produces a
95
95
  // structural HeadingBlock with synthetic level=4 and attaches the
96
96
  // original md depth to the explicit HeadingBlock.mdDepth optional
97
97
  // field. Fail-fast for out-of-range heading depths is the ACTIVE
98
- // DOMAIN PARSER's responsibility.
98
+ // DOMAIN PARSER's responsibility per 6.3.10 frozen:
99
+ // * document domain → DOC_HEADING_DEPTH_UNDEFINED for md h5/h6
100
+ // * structured-ext-A domain → HEADING_DEPTH_UNDEFINED for md h6
101
+ // (md h5 allowed → level 4)
99
102
  const docAST = parseDocument(body, input.file)
100
103
 
101
104
  // 4. Domain parser dispatch.
@@ -112,18 +115,18 @@ export function compile (
112
115
  schema: input.schema,
113
116
  meta
114
117
  }
115
- const content = parser.parse(docAST, ctx)
118
+ const parsed = parser.parse(docAST, ctx)
116
119
 
117
120
  return {
118
121
  kind: 'positive',
119
122
  meta,
120
123
  docAST,
121
124
  artifact: {
122
- id: input.id,
125
+ fixture: input.fixture,
123
126
  domain: input.domain,
124
127
  identity: input.identity,
125
128
  meta,
126
- content: { version: 1, blocks: content.blocks }
129
+ content: { version: 1, blocks: parsed.blocks }
127
130
  }
128
131
  }
129
132
  } catch (e) {
@@ -133,7 +136,7 @@ export function compile (
133
136
  meta,
134
137
  error: err,
135
138
  artifact: {
136
- id: input.id,
139
+ fixture: input.fixture,
137
140
  domain: input.domain,
138
141
  identity: input.identity,
139
142
  meta,
@@ -0,0 +1,116 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/core/discovery.ts
3
+ // Source adapter + collection routing + identity derivation helpers.
4
+ //
5
+ // Two identity conventions are supported. Both are neutral; the host
6
+ // runner decides which one applies per collection.
7
+ //
8
+ // DOCUMENT path convention → DocumentIdentity
9
+ // id = relPath minus .md, minus trailing `/index`
10
+ // path = '/' + id
11
+ // file = relPath WITH .md extension
12
+ // + auxiliary fields: collection, (optionally) index_file:true
13
+ //
14
+ // STRUCTURED path convention → StructuredIdentity
15
+ // slug / topic_slug / chapter_slug derived from collection layout.
16
+ // P0 fixture layout exercises: <topic>/<chapter>.md → slugs === chapter.
17
+ // ─────────────────────────────────────────────────────────────
18
+
19
+ import path from 'node:path'
20
+ import { readFileSync, existsSync, statSync } from 'node:fs'
21
+ import type { DocumentIdentity, StructuredIdentity, DomainName, ParseError } from '../types'
22
+
23
+ export type IdentityKind = 'document' | 'structured'
24
+
25
+ export interface CollectionConfig {
26
+ /** Collection name carried in identity.collection (document convention). */
27
+ name: string
28
+ /** Absolute path to the source root of this collection. */
29
+ sourceRoot: string
30
+ /** Domain tag this collection routes its source through. */
31
+ domain: DomainName | string
32
+ }
33
+
34
+ /**
35
+ * Normalise a relative path to forward slashes and strip any leading `./`.
36
+ */
37
+ export function normaliseRel (p: string): string {
38
+ let out = p.split(path.sep).join('/')
39
+ while (out.startsWith('./')) out = out.slice(2)
40
+ return out
41
+ }
42
+
43
+ /**
44
+ * Build DocumentIdentity (6.3.15 frozen) from a `.md` file path relative
45
+ * to the collection source root.
46
+ */
47
+ export function buildDocumentIdentity (
48
+ relPath: string,
49
+ collection: string
50
+ ): DocumentIdentity {
51
+ const norm = normaliseRel(relPath)
52
+ const file = norm
53
+
54
+ let id = file.endsWith('.md') ? file.slice(0, -'.md'.length) : file
55
+ if (id.endsWith('.markdown')) id = id.slice(0, -'.markdown'.length)
56
+
57
+ const isIndex = id.endsWith('/index') || id === 'index'
58
+ if (isIndex) {
59
+ if (id === 'index') id = ''
60
+ else id = id.slice(0, -'/index'.length)
61
+ }
62
+
63
+ const pathUrl = '/' + id
64
+ const identity: DocumentIdentity = {
65
+ id,
66
+ path: pathUrl,
67
+ file,
68
+ collection
69
+ }
70
+ if (isIndex) identity.index_file = true
71
+ return identity
72
+ }
73
+
74
+ /**
75
+ * Build StructuredIdentity (slug / topic_slug / chapter_slug) using
76
+ * explicit hierarchical tokens passed by the host. Runner supplies
77
+ * values directly based on fixture routing — no path-parsing guesswork
78
+ * happens here for the P0 corpus.
79
+ */
80
+ export function buildStructuredIdentity (
81
+ params: { topic_slug: string; chapter_slug: string; slug?: string }
82
+ ): StructuredIdentity {
83
+ const { topic_slug, chapter_slug } = params
84
+ return {
85
+ slug: params.slug ?? chapter_slug,
86
+ topic_slug,
87
+ chapter_slug
88
+ }
89
+ }
90
+
91
+ /** Read a single UTF-8 file and pair it with its relative path. */
92
+ export interface SourceFile {
93
+ absPath: string
94
+ relPath: string
95
+ source: string
96
+ }
97
+
98
+ export function readSourceFile (absPath: string, sourceRoot: string): SourceFile {
99
+ if (!existsSync(absPath) || !statSync(absPath).isFile()) {
100
+ throw new Error(`[discovery] Not a file or missing: ${absPath}`)
101
+ }
102
+ const raw = readFileSync(absPath, 'utf8')
103
+ const source = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw
104
+ if (source.includes('\r')) {
105
+ const rel = normaliseRel(path.relative(sourceRoot, absPath))
106
+ const err = new Error(
107
+ `[LINE_ENDING_CONTAMINATION] Source file '${rel}' contains CRLF line endings. ` +
108
+ `Normalize to LF before processing (e.g. sed -i 's/\\r$//' <file>).`
109
+ ) as ParseError
110
+ err.code = 'LINE_ENDING_CONTAMINATION'
111
+ err.file = rel
112
+ throw err
113
+ }
114
+ const rel = normaliseRel(path.relative(sourceRoot, absPath))
115
+ return { absPath, relPath: rel, source }
116
+ }
@@ -1,144 +1,145 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // dexin-content/core/frontmatter.ts
3
- // Split YAML frontmatter + SCHEMA-FREE scalar projection.
4
- // SCHEMA-FREE RULE: meta = frontmatter scalar全集 (string|number|boolean)
5
- // regardless of schema declaration. Schema only validates.
6
- // ─────────────────────────────────────────────────────────────
7
-
8
- import { parse as yamlParse } from 'yaml'
9
- import type { Meta, ParseError, Schema } from './types'
10
-
11
- export interface SplitResult {
12
- /** Frontmatter block (verbatim text between `---` lines) — may be empty string */
13
- frontmatter: string
14
- /** Markdown body that follows the frontmatter block (may be empty string) */
15
- body: string
16
- /** Whether a YAML frontmatter block was actually present */
17
- hasFrontmatter: boolean
18
- }
19
-
20
- /**
21
- * Split `---` delimited YAML frontmatter from the rest of the document.
22
- * Returns empty strings for frontmatter/body if the document has no block.
23
- * The first line must be exactly `---` for a block to be recognised.
24
- */
25
- export function splitFrontmatter (source: string): SplitResult {
26
- if (!source.startsWith('---')) {
27
- return { frontmatter: '', body: source, hasFrontmatter: false }
28
- }
29
- const firstLineEnd = source.indexOf('\n')
30
- if (firstLineEnd === -1) {
31
- return { frontmatter: '', body: source, hasFrontmatter: false }
32
- }
33
- const firstLine = source.slice(0, firstLineEnd).trimEnd()
34
- if (firstLine !== '---') {
35
- return { frontmatter: '', body: source, hasFrontmatter: false }
36
- }
37
- // Find closing `---` line starting at position after the opening line
38
- const rest = source.slice(firstLineEnd + 1)
39
- const lines = rest.split('\n')
40
- let closeIdx = -1
41
- for (let i = 0; i < lines.length; i++) {
42
- if ((lines[i] ?? '').trimEnd() === '---') {
43
- closeIdx = i
44
- break
45
- }
46
- }
47
- if (closeIdx === -1) {
48
- return { frontmatter: '', body: source, hasFrontmatter: false }
49
- }
50
- const frontmatter = lines.slice(0, closeIdx).join('\n')
51
- // Close-line index in `rest` coordinates: closeIdx * (line + newline) + lengthOfLine
52
- // Simpler: rejoin after closeIdx, drop the --- line itself
53
- const afterLines = lines.slice(closeIdx + 1)
54
- // Preserve original line breaks by joining with \n
55
- let body = afterLines.join('\n')
56
- // If body begins with a single \n it's the newline right after closing fence — strip it
57
- if (body.startsWith('\n')) body = body.slice(1)
58
- return { frontmatter, body, hasFrontmatter: true }
59
- }
60
-
61
- /**
62
- * Project parsed YAML object to scalar-only meta.
63
- * SCHEMA-FREE RULE: scalar = string | number | boolean.
64
- * All other value types (null, undefined, object, array, bigint, date, …) are DROPPED.
65
- */
66
- export function projectMeta (raw: unknown): Meta {
67
- const meta: Meta = {}
68
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return meta
69
- const obj = raw as Record<string, unknown>
70
- for (const key of Object.keys(obj)) {
71
- const v = obj[key]
72
- switch (typeof v) {
73
- case 'string':
74
- case 'number':
75
- case 'boolean':
76
- meta[key] = v
77
- break
78
- default:
79
- // drop silently — schema-free projection only keeps scalars
80
- break
81
- }
82
- }
83
- return meta
84
- }
85
-
86
- /** Parse the raw frontmatter string via yaml, then project scalars into Meta. */
87
- export function parseFrontmatter (raw: string, file?: string): Meta {
88
- if (!raw.trim()) return {}
89
- let parsed: unknown
90
- try {
91
- parsed = yamlParse(raw)
92
- } catch (e) {
93
- const yamlMsg = e instanceof Error ? e.message : String(e)
94
- const fileCtx = file ? ` in ${file}` : ''
95
- const err = new Error(
96
- `[SCHEMA_VALIDATION_FAILED] Frontmatter YAML parse error${fileCtx}: ${yamlMsg}`
97
- ) as ParseError
98
- err.code = 'SCHEMA_VALIDATION_FAILED'
99
- if (file) err.file = file
100
- throw err
101
- }
102
- return projectMeta(parsed)
103
- }
104
-
105
- /**
106
- * Validate a meta object against the collection schema.
107
- * Throws a ParseError with code = SCHEMA_VALIDATION_FAILED when violated.
108
- * Caller is responsible for deciding when schema validation runs;
109
- * schema-less collections are allowed (SCHEMA-FREE RULE).
110
- */
111
- export function validateSchema (
112
- meta: Meta,
113
- schema: Schema | undefined,
114
- file: string
115
- ): void {
116
- if (!schema) return
117
- if (schema.required && schema.required.length > 0) {
118
- for (const key of schema.required) {
119
- if (!(key in meta)) {
120
- const err = new Error(
121
- `[SCHEMA_VALIDATION_FAILED] Missing required frontmatter field '${key}' in ${file}`
122
- ) as ParseError
123
- err.code = 'SCHEMA_VALIDATION_FAILED'
124
- err.file = file
125
- throw err
126
- }
127
- }
128
- }
129
- if (schema.types) {
130
- for (const key of Object.keys(schema.types)) {
131
- if (!(key in meta)) continue // presence checked separately
132
- const expected = schema.types[key]
133
- const actual = typeof meta[key]
134
- if (actual !== expected) {
135
- const err = new Error(
136
- `[SCHEMA_VALIDATION_FAILED] Field '${key}' expected type ${expected}, got ${actual} in ${file}`
137
- ) as ParseError
138
- err.code = 'SCHEMA_VALIDATION_FAILED'
139
- err.file = file
140
- throw err
141
- }
142
- }
143
- }
144
- }
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/core/frontmatter.ts
3
+ // Split YAML frontmatter + SCHEMA-FREE scalar projection.
4
+ // 6.3.13 frozen: meta = frontmatter scalar全集 (string|number|boolean)
5
+ // regardless of schema declaration. Schema only validates.
6
+ // ─────────────────────────────────────────────────────────────
7
+
8
+ import { parse as yamlParse } from 'yaml'
9
+ import type { Meta, ParseError, Schema } from '../types'
10
+
11
+ export interface SplitResult {
12
+ /** Frontmatter block (verbatim text between `---` lines) — may be empty string */
13
+ frontmatter: string
14
+ /** Markdown body that follows the frontmatter block (may be empty string) */
15
+ body: string
16
+ /** Whether a YAML frontmatter block was actually present */
17
+ hasFrontmatter: boolean
18
+ }
19
+
20
+ /**
21
+ * Split `---` delimited YAML frontmatter from the rest of the document.
22
+ * Returns empty strings for frontmatter/body if the document has no block.
23
+ * The first line must be exactly `---` for a block to be recognised.
24
+ */
25
+ export function splitFrontmatter (source: string): SplitResult {
26
+ if (!source.startsWith('---')) {
27
+ return { frontmatter: '', body: source, hasFrontmatter: false }
28
+ }
29
+ const firstLineEnd = source.indexOf('\n')
30
+ if (firstLineEnd === -1) {
31
+ return { frontmatter: '', body: source, hasFrontmatter: false }
32
+ }
33
+ const firstLine = source.slice(0, firstLineEnd).trimEnd()
34
+ if (firstLine !== '---') {
35
+ return { frontmatter: '', body: source, hasFrontmatter: false }
36
+ }
37
+ // Find closing `---` line starting at position after the opening line
38
+ const rest = source.slice(firstLineEnd + 1)
39
+ const lines = rest.split('\n')
40
+ let closeIdx = -1
41
+ for (let i = 0; i < lines.length; i++) {
42
+ if (lines[i].trimEnd() === '---') {
43
+ closeIdx = i
44
+ break
45
+ }
46
+ }
47
+ if (closeIdx === -1) {
48
+ return { frontmatter: '', body: source, hasFrontmatter: false }
49
+ }
50
+ const frontmatter = lines.slice(0, closeIdx).join('\n')
51
+ // Close-line index in `rest` coordinates: closeIdx * (line + newline) + lengthOfLine
52
+ // Simpler: rejoin after closeIdx, drop the --- line itself
53
+ const afterLines = lines.slice(closeIdx + 1)
54
+ // Preserve original line breaks by joining with \n
55
+ let body = afterLines.join('\n')
56
+ // If body begins with a single \n it's the newline right after closing fence — strip it
57
+ if (body.startsWith('\n')) body = body.slice(1)
58
+ return { frontmatter, body, hasFrontmatter: true }
59
+ }
60
+
61
+ /**
62
+ * Project parsed YAML object to scalar-only meta.
63
+ * SCHEMA-FREE RULE (6.3.13): scalar = string | number | boolean.
64
+ * All other value types (null, undefined, object, array, bigint, date, …) are DROPPED.
65
+ */
66
+ export function projectMeta (raw: unknown): Meta {
67
+ const meta: Meta = {}
68
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return meta
69
+ const obj = raw as Record<string, unknown>
70
+ for (const key of Object.keys(obj)) {
71
+ const v = obj[key]
72
+ switch (typeof v) {
73
+ case 'string':
74
+ case 'number':
75
+ case 'boolean':
76
+ meta[key] = v
77
+ break
78
+ default:
79
+ // drop silently — schema-free projection only keeps scalars
80
+ break
81
+ }
82
+ }
83
+ return meta
84
+ }
85
+
86
+ /** Parse the raw frontmatter string via yaml, then project scalars into Meta. */
87
+ export function parseFrontmatter (raw: string, file?: string): Meta {
88
+ if (!raw.trim()) return {}
89
+ let parsed: unknown
90
+ try {
91
+ parsed = yamlParse(raw)
92
+ } catch (e) {
93
+ const yamlMsg = e instanceof Error ? e.message : String(e)
94
+ const fileCtx = file ? ` in ${file}` : ''
95
+ const err = new Error(
96
+ `[SCHEMA_VALIDATION_FAILED] Frontmatter YAML parse error${fileCtx}: ${yamlMsg}`
97
+ ) as ParseError
98
+ err.code = 'SCHEMA_VALIDATION_FAILED'
99
+ if (file) err.file = file
100
+ throw err
101
+ }
102
+ return projectMeta(parsed)
103
+ }
104
+
105
+ /**
106
+ * Validate a meta object against the collection schema.
107
+ * Throws a ParseError with code = SCHEMA_VALIDATION_FAILED when violated.
108
+ * Caller is responsible for deciding when schema validation runs;
109
+ * schema-less collections are allowed (SCHEMA-FREE RULE).
110
+ */
111
+ export function validateSchema (
112
+ meta: Meta,
113
+ schema: Schema | undefined,
114
+ file: string
115
+ ): void {
116
+ if (!schema) return
117
+ if (schema.required && schema.required.length > 0) {
118
+ for (const key of schema.required) {
119
+ if (!(key in meta)) {
120
+ const err = new Error(
121
+ `[SCHEMA_VALIDATION_FAILED] Missing required frontmatter field '${key}' in ${file}`
122
+ ) as ParseError
123
+ err.code = 'SCHEMA_VALIDATION_FAILED'
124
+ err.file = file
125
+ err.message = err.message
126
+ throw err
127
+ }
128
+ }
129
+ }
130
+ if (schema.types) {
131
+ for (const key of Object.keys(schema.types)) {
132
+ if (!(key in meta)) continue // presence checked separately
133
+ const expected = schema.types[key]
134
+ const actual = typeof meta[key]
135
+ if (actual !== expected) {
136
+ const err = new Error(
137
+ `[SCHEMA_VALIDATION_FAILED] Field '${key}' expected type ${expected}, got ${actual} in ${file}`
138
+ ) as ParseError
139
+ err.code = 'SCHEMA_VALIDATION_FAILED'
140
+ err.file = file
141
+ throw err
142
+ }
143
+ }
144
+ }
145
+ }