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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/collection.ts +289 -0
- package/core/compiler.ts +174 -0
- package/core/discovery.ts +110 -0
- package/core/frontmatter.ts +144 -0
- package/core/markdown.ts +295 -0
- package/core/types.ts +162 -0
- package/diff.ts +124 -0
- package/index.ts +12 -0
- package/package.json +68 -0
- package/query.ts +56 -0
- package/store.ts +128 -0
|
@@ -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
|
+
}
|
package/core/markdown.ts
ADDED
|
@@ -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 }
|