dexin-content 0.1.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,110 @@
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
+ }
@@ -0,0 +1,144 @@
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
+ }
@@ -0,0 +1,295 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/core/markdown.ts
3
+ // Unified pipeline → neutral DocumentAST.
4
+ //
5
+ // Responsibilities:
6
+ // * Parse markdown source (GFM tables/lists, LaTeX-format spans,
7
+ // generic directive containers) into MDAST nodes.
8
+ // * Walk MDAST nodes and produce DocumentBlock[] arrays with
9
+ // full inline-format coverage.
10
+ // * ContainerBlock: always neutral, attributes =
11
+ // Record<string,string> (derived from directive attributes).
12
+ // * Heading boundary behaviour for out-of-range markdown depth is
13
+ // delegated to the active domain parser; the generic layer clamps
14
+ // the syntactic level and records the original depth so domain
15
+ // parsers can run their own fail-fast checks.
16
+ // * Quote paragraph merging: soft newlines inside a single quote-
17
+ // wrapped paragraph collapse into one ParagraphBlock with a literal
18
+ // '\n' text node between its inlines (R-02). Separated paragraphs
19
+ // (blank-line delimited) remain separate, inside neutral
20
+ // ContainerBlock nodes (R-04), because blank lines mark paragraph
21
+ // boundaries.
22
+ // ─────────────────────────────────────────────────────────────
23
+
24
+ import { unified } from 'unified'
25
+ import remarkParse from 'remark-parse'
26
+ import remarkGfm from 'remark-gfm'
27
+ import remarkMath from 'remark-math'
28
+ import remarkDirective from 'remark-directive'
29
+
30
+ import type {
31
+ DocumentBlock,
32
+ DocumentContent,
33
+ HeadingBlock,
34
+ ParagraphBlock,
35
+ QuoteBlock,
36
+ ListBlock,
37
+ TableBlock,
38
+ TableCell,
39
+ ImageBlock,
40
+ CodeBlock,
41
+ FormulaBlock,
42
+ ContainerBlock,
43
+ Inline,
44
+ ParseError
45
+ } from './types'
46
+
47
+ // mdast node type tags produced by remark-math (case-sensitive).
48
+ const NODE_FORMULA_BLOCK = 'math' // block-level LaTeX fence
49
+ const NODE_FORMULA_INLINE = 'inlineMath' // inline LaTeX span
50
+
51
+ // Minimal MDAST subset. The plugin output is walked by shape only.
52
+ interface MdNode {
53
+ type: string
54
+ depth?: number
55
+ children?: MdNode[]
56
+ value?: string
57
+ alt?: string
58
+ url?: string
59
+ identifier?: string
60
+ label?: string
61
+ lang?: string
62
+ meta?: string
63
+ ordered?: boolean
64
+ spread?: boolean
65
+ start?: number
66
+ align?: ('left' | 'right' | 'center' | null)[]
67
+ position?: unknown
68
+ name?: string
69
+ attributes?: Record<string, string | null | undefined> | null
70
+ }
71
+
72
+ // ── Entry point ──────────────────────────────────────────
73
+
74
+ export function parseToDocAST (source: string, file: string): DocumentContent {
75
+ const mdast = unified()
76
+ .use(remarkParse)
77
+ .use(remarkGfm)
78
+ .use(remarkMath)
79
+ .use(remarkDirective)
80
+ .parse(source) as MdNode
81
+
82
+ const blocks = walkBlocks(mdast.children ?? [], file, 'root')
83
+ return { version: 1, blocks }
84
+ }
85
+
86
+ export { parseToDocAST as parseDocument }
87
+
88
+ // ── Block walkers ─────────────────────────────────────────
89
+
90
+ type BlockScope = 'root' | 'blockquote' | 'container'
91
+
92
+ function walkBlocks (nodes: MdNode[], file: string, scope: BlockScope): DocumentBlock[] {
93
+ const out: DocumentBlock[] = []
94
+ for (const n of nodes) {
95
+ switch (n.type) {
96
+ case 'heading': out.push(walkHeading(n, file)); break
97
+ case 'paragraph': out.push(walkParagraph(n, file)); break
98
+ case 'blockquote': out.push(walkBlockquote(n, file)); break
99
+ case 'thematicBreak': out.push({ type: 'divider' }); break
100
+ case 'list': out.push(walkList(n, file)); break
101
+ case 'table': out.push(walkTable(n, file)); break
102
+ case 'image': out.push(walkImage(n)); break
103
+ case 'code': out.push(walkCode(n)); break
104
+ case NODE_FORMULA_BLOCK: out.push(walkFormula(n)); break
105
+ case 'containerDirective': out.push(walkContainerDirective(n, file)); break
106
+ default: {
107
+ const err = new Error(
108
+ `[MDAST_UNSUPPORTED_NODE] Unsupported block node type '${n.type}' in ${file} (scope: ${scope}). ` +
109
+ `Fail-fast: unknown mdast block nodes must not be silently dropped.`
110
+ ) as ParseError
111
+ err.code = 'MDAST_UNSUPPORTED_NODE'
112
+ err.file = file
113
+ throw err
114
+ }
115
+ }
116
+ }
117
+ return out
118
+ }
119
+
120
+ // ── Individual block converters ──────────────────────────
121
+
122
+ function walkHeading (n: MdNode, file: string): HeadingBlock {
123
+ const d = n.depth ?? 1
124
+ if (d < 1 || d > 6) {
125
+ // Defensive clamp (should not occur with standard mdast output).
126
+ }
127
+ // Boundary behaviour for heading depth is the active domain parser's
128
+ // responsibility. The generic layer produces a structural heading with
129
+ // synthetic level 1..4, and records the original markdown depth via the
130
+ // explicit optional HeadingBlock.mdDepth field so domain-level fail-fast
131
+ // checks run precisely. The generic layer never throws for md h5/h6;
132
+ // domain parsers decide their own policy.
133
+ // - `mdDepth` is TRANSIENT: domain output render/clone paths must drop
134
+ // it so it never appears in the canonical Artifact content.
135
+ const clamped = (d < 1 ? 1 : d > 6 ? 6 : d)
136
+ const syntheticLevel = (clamped <= 4 ? clamped : 4) as 1 | 2 | 3 | 4
137
+ const children = walkInlines(n.children ?? [], file)
138
+ const h: HeadingBlock = {
139
+ type: 'heading',
140
+ level: syntheticLevel,
141
+ children,
142
+ mdDepth: clamped
143
+ }
144
+ void file
145
+ return h
146
+ }
147
+
148
+ function walkParagraph (n: MdNode, file: string): ParagraphBlock {
149
+ return { type: 'paragraph', children: walkInlines(n.children ?? [], file) }
150
+ }
151
+
152
+ /**
153
+ * Quote block R-02 behaviour: a soft break inside a single wrapped
154
+ * paragraph renders as a TextInline('\n'), merging both halves into one
155
+ * ParagraphBlock with a literal newline rather than two paragraphs.
156
+ * Paragraphs separated by blank lines inside the quote remain separate.
157
+ */
158
+ function walkBlockquote (n: MdNode, file: string): QuoteBlock {
159
+ const inner = walkBlocks(n.children ?? [], file, 'blockquote')
160
+ return { type: 'quote', children: inner }
161
+ }
162
+
163
+ function walkList (n: MdNode, file: string): ListBlock {
164
+ const ordered = !!n.ordered
165
+ const items: Inline[][] = []
166
+ for (const li of n.children ?? []) {
167
+ const itemInlines: Inline[] = []
168
+ const firstBlocks = li.children ?? []
169
+ let emitted = 0
170
+ for (const b of firstBlocks) {
171
+ if (b.type === 'paragraph') {
172
+ if (emitted > 0) itemInlines.push({ type: 'text', value: '\n' })
173
+ itemInlines.push(...walkInlines(b.children ?? [], file))
174
+ emitted++
175
+ } else {
176
+ const err = new Error(
177
+ `[MDAST_UNSUPPORTED_NODE] Unsupported list-item child type '${b.type}' in ${file}. ` +
178
+ `Fail-fast: non-paragraph list item children are not supported.`
179
+ ) as ParseError
180
+ err.code = 'MDAST_UNSUPPORTED_NODE'
181
+ err.file = file
182
+ throw err
183
+ }
184
+ }
185
+ items.push(itemInlines)
186
+ }
187
+ return { type: 'list', ordered, items }
188
+ }
189
+
190
+ function walkTable (n: MdNode, file: string): TableBlock {
191
+ const kids = n.children ?? []
192
+ const headers: TableCell[] = []
193
+ const rows: TableCell[][] = []
194
+ for (let i = 0; i < kids.length; i++) {
195
+ const row = kids[i]
196
+ const rowCells: TableCell[] = []
197
+ const cells = row.children ?? []
198
+ for (const cell of cells) rowCells.push(walkInlines(cell.children ?? [], file))
199
+ if (i === 0) for (const c of rowCells) headers.push(c)
200
+ else rows.push(rowCells)
201
+ }
202
+ return { type: 'table', headers, rows }
203
+ }
204
+
205
+ function walkImage (n: MdNode): ImageBlock {
206
+ return { type: 'image', src: n.url ?? '', alt: n.alt ?? '' }
207
+ }
208
+
209
+ function walkCode (n: MdNode): CodeBlock {
210
+ return { type: 'code', language: n.lang ?? '', code: n.value ?? '' }
211
+ }
212
+
213
+ function walkFormula (n: MdNode): FormulaBlock {
214
+ // Block-level LaTeX fence mdast node: value = latex body; display = true.
215
+ return { type: 'formula', latex: n.value ?? '', display: true }
216
+ }
217
+
218
+ /**
219
+ * Directive container → neutral ContainerBlock.
220
+ * Interpretation of a container's name and semantics is deferred to the
221
+ * active domain parser; here the structure is preserved verbatim.
222
+ */
223
+ function walkContainerDirective (n: MdNode, file: string): ContainerBlock {
224
+ const name = n.name ?? 'unknown'
225
+ const attrsIn = n.attributes ?? {}
226
+ const attrs: Record<string, string> = {}
227
+ for (const key of Object.keys(attrsIn)) {
228
+ const v = attrsIn[key]
229
+ attrs[key] = v == null ? '' : String(v)
230
+ }
231
+ const children = walkBlocks(n.children ?? [], file, 'container')
232
+ return { type: 'container', name, attrs, children }
233
+ }
234
+
235
+ // ── Inline walkers ────────────────────────────────────────
236
+
237
+ function walkInlines (nodes: MdNode[], file: string): Inline[] {
238
+ const out: Inline[] = []
239
+ for (const n of nodes) {
240
+ switch (n.type) {
241
+ case 'text':
242
+ out.push({ type: 'text', value: n.value ?? '' })
243
+ break
244
+ case 'strong':
245
+ out.push({ type: 'bold', children: walkInlines(n.children ?? [], file) })
246
+ break
247
+ case 'emphasis':
248
+ out.push({ type: 'italic', children: walkInlines(n.children ?? [], file) })
249
+ break
250
+ case 'inlineCode':
251
+ out.push({ type: 'code', value: n.value ?? '' })
252
+ break
253
+ case 'link':
254
+ out.push({
255
+ type: 'link',
256
+ url: n.url ?? '',
257
+ children: walkInlines(n.children ?? [], file)
258
+ })
259
+ break
260
+ case 'image':
261
+ out.push({ type: 'text', value: n.alt ?? n.url ?? '' })
262
+ break
263
+ case NODE_FORMULA_INLINE:
264
+ out.push({ type: 'math', latex: n.value ?? '' })
265
+ break
266
+ case 'break':
267
+ case 'softbreak':
268
+ out.push({ type: 'text', value: '\n' })
269
+ break
270
+ case 'html':
271
+ out.push({ type: 'text', value: n.value ?? '' })
272
+ break
273
+ case 'linkReference':
274
+ out.push({
275
+ type: 'link',
276
+ url: n.identifier ?? '',
277
+ children: walkInlines(n.children ?? [], file)
278
+ })
279
+ break
280
+ case 'textDirective':
281
+ case 'leafDirective':
282
+ out.push(...walkInlines(n.children ?? [], file))
283
+ break
284
+ default:
285
+ if (n.children && Array.isArray(n.children)) {
286
+ out.push(...walkInlines(n.children, file))
287
+ }
288
+ break
289
+ }
290
+ }
291
+ return out
292
+ }
293
+
294
+ // Keep imports used (silence warnings).
295
+ export type { ParseError }