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
|
@@ -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
|
|
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
|
-
|
|
18
|
+
Identity,
|
|
20
19
|
Meta,
|
|
21
20
|
ParseError,
|
|
22
21
|
Schema
|
|
23
|
-
} from '
|
|
22
|
+
} from '../types'
|
|
23
|
+
import type { LessonContent } from '../types/lessonAST'
|
|
24
24
|
import {
|
|
25
25
|
parseFrontmatter,
|
|
26
26
|
splitFrontmatter,
|
|
27
27
|
validateSchema
|
|
28
|
-
} from '
|
|
29
|
-
import { parseDocument } from '
|
|
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-
|
|
48
|
+
* host runner can mix-and-match per-fixture identity derivation rules.
|
|
49
49
|
*/
|
|
50
50
|
export interface CompileInput {
|
|
51
|
-
/**
|
|
52
|
-
|
|
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 (
|
|
56
|
-
identity:
|
|
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
|
|
69
|
-
docAST?:
|
|
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 →
|
|
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
|
|
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
|
-
|
|
125
|
+
fixture: input.fixture,
|
|
123
126
|
domain: input.domain,
|
|
124
127
|
identity: input.identity,
|
|
125
128
|
meta,
|
|
126
|
-
content: { version: 1, 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
|
-
|
|
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
|
-
//
|
|
5
|
-
// regardless of schema declaration. Schema only validates.
|
|
6
|
-
// ─────────────────────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
import { parse as yamlParse } from 'yaml'
|
|
9
|
-
import type { Meta, ParseError, Schema } from '
|
|
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 (
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
err.
|
|
140
|
-
|
|
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
|
+
}
|