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/core/types.ts ADDED
@@ -0,0 +1,162 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/core/types.ts — Generic shared types.
3
+ // ─────────────────────────────────────────────────────────────
4
+ // This file defines the shared type system used across domains.
5
+ // It MUST remain business-agnostic. All names, literals, and
6
+ // comment text are written in generic, domain-neutral vocabulary.
7
+
8
+ // ── Inline content (shared across the neutral AST and all domains) ──
9
+
10
+ export type Inline =
11
+ | TextInline
12
+ | BoldInline
13
+ | ItalicInline
14
+ | CodeInline
15
+ | LinkInline
16
+ | FormulaInline
17
+
18
+ export interface TextInline { type: 'text'; value: string }
19
+ export interface BoldInline { type: 'bold'; children: Inline[] }
20
+ export interface ItalicInline { type: 'italic'; children: Inline[] }
21
+ export interface CodeInline { type: 'code'; value: string }
22
+ export interface LinkInline { type: 'link'; url: string; children: Inline[] }
23
+ /** Inline LaTeX-format formula span (single-paragraph). */
24
+ export interface FormulaInline { type: 'math'; latex: string }
25
+
26
+ // ── Document-level blocks (neutral AST — no domain mapping) ──
27
+
28
+ /**
29
+ * HeadingBlock in the neutral AST: level is clamped to 1–4
30
+ * (h1→1, h2→2, h3→3, h4→4). `mdDepth` is the ORIGINAL markdown heading
31
+ * depth (1..6 inclusive) captured at parse time. It is OPTIONAL and exists
32
+ * ONLY so active domain parsers can run their own fail-fast checks for
33
+ * out-of-range depths. `mdDepth` MUST NOT leak into the serialised
34
+ * canonical Artifact (the `content` output): domain parsers' clone/render
35
+ * paths must drop it.
36
+ */
37
+ export interface HeadingBlock {
38
+ type: 'heading'
39
+ level: 1 | 2 | 3 | 4
40
+ children: Inline[]
41
+ /** Original markdown heading depth (1..6). Transient, non-canonical. */
42
+ mdDepth?: number
43
+ }
44
+ export interface ParagraphBlock { type: 'paragraph'; children: Inline[] }
45
+ export interface QuoteBlock { type: 'quote'; children: DocumentBlock[] }
46
+ export interface DividerBlock { type: 'divider' }
47
+ export interface ListBlock { type: 'list'; ordered: boolean; items: Inline[][] }
48
+ export type TableCell = Inline[]
49
+ export interface TableBlock { type: 'table'; headers: TableCell[]; rows: TableCell[][] }
50
+ export interface ImageBlock { type: 'image'; src: string; alt: string; caption?: string }
51
+ export interface CodeBlock { type: 'code'; language: string; code: string }
52
+ /** Block-level LaTeX-format formula node; display=true for centered fences. */
53
+ export interface FormulaBlock { type: 'formula'; latex: string; display: boolean }
54
+
55
+ /**
56
+ * Neutral ContainerBlock:
57
+ * Produced for every directive container encountered in the markdown body.
58
+ * The active domain decides whether an unknown name is acceptable;
59
+ * the generic layer always preserves the structure as-is.
60
+ */
61
+ export interface ContainerBlock {
62
+ type: 'container'
63
+ name: string
64
+ attrs: Record<string, string>
65
+ children: DocumentBlock[]
66
+ }
67
+
68
+ export type DocumentBlock =
69
+ | ParagraphBlock
70
+ | HeadingBlock
71
+ | QuoteBlock
72
+ | DividerBlock
73
+ | ListBlock
74
+ | TableBlock
75
+ | ImageBlock
76
+ | CodeBlock
77
+ | FormulaBlock
78
+ | ContainerBlock
79
+
80
+ export interface DocumentContent {
81
+ version: 1
82
+ blocks: DocumentBlock[]
83
+ }
84
+
85
+ // ── Artifact shapes (shared five-field header) ──
86
+
87
+ /**
88
+ * Generic domain tag. Literal string values are chosen by the host runner
89
+ * and flow through to the Artifact output untouched. Core itself does not
90
+ * interpret them beyond registry lookup keys.
91
+ */
92
+ export type DomainName = string & { readonly __brand?: unique symbol }
93
+
94
+ /**
95
+ * Plain-document identity:
96
+ * id — relative to content root; strip `.md` and trailing `/index`
97
+ * path — URL semantics: `'/' + id`
98
+ * file — physical path relative to content root, INCLUDING `.md` extension
99
+ *
100
+ * Domain-specific routing fields may be attached by hosts via the index
101
+ * signature; core never interprets them.
102
+ */
103
+ export interface DocumentIdentity {
104
+ id: string
105
+ path: string // URL semantic: '/' + id
106
+ file: string // physical path relative to content root WITH ext
107
+ collection: string
108
+ [k: string]: unknown // auxiliary fields (e.g. index_file:true)
109
+ }
110
+
111
+ /** Frontmatter scalar-only projection (SCHEMA-FREE RULE) */
112
+ export type Meta = Record<string, string | number | boolean>
113
+
114
+ export interface ArtifactHeader {
115
+ id: string // Stable document id (unique within a store)
116
+ domain: DomainName | string
117
+ identity: DocumentIdentity
118
+ meta: Meta
119
+ }
120
+
121
+ export interface PositiveArtifact extends ArtifactHeader {
122
+ content: { version: 1; blocks: unknown[] }
123
+ }
124
+
125
+ /** Compile-failure output — captures first fail-fast error. */
126
+ export interface ErrorArtifact extends ArtifactHeader {
127
+ kind: 'COMPILE_ERROR'
128
+ error: { code: string; message: string }
129
+ }
130
+
131
+ export type Artifact = PositiveArtifact | ErrorArtifact
132
+
133
+ // ── Unified error object ──
134
+
135
+ export interface ParseError extends Error {
136
+ code: string
137
+ message: string
138
+ file: string
139
+ loc?: { container?: string; headingDepth?: number; invalidEnumValue?: string; allowed?: string }
140
+ }
141
+
142
+ // ── Parser contract — domains implement; core dispatches ──
143
+
144
+ export interface ParseContext {
145
+ /** Relative path to the source file, used in error messages. */
146
+ file: string
147
+ /** Collection-level schema (required fields + scalar type checks). */
148
+ schema?: Schema
149
+ /** Frontmatter already projected as scalar-only meta. */
150
+ meta: Meta
151
+ }
152
+
153
+ export interface Schema {
154
+ required?: string[]
155
+ types?: Record<string, 'string' | 'number' | 'boolean'>
156
+ }
157
+
158
+ export interface DomainParser {
159
+ domain: DomainName | string
160
+ /** Convert neutral AST → domain-specific Artifact content.blocks shape. May throw ParseError. */
161
+ parse(content: DocumentContent, ctx: ParseContext): { version: 1; blocks: unknown[] }
162
+ }
package/diff.ts ADDED
@@ -0,0 +1,124 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/diff.ts — 共享 Canonical/Diff 原语
3
+ //
4
+ // 统一 CANONICALIZE 语义:
5
+ // * Golden 侧递归剥离 `_` 前缀键(CANONICALIZE Rule 1)
6
+ // * Candidate 侧 `_` 前缀键路径扫描(CANONICALIZE Rule 2)
7
+ // * 对象级 firstDiff(键序不敏感,首差异 JSON-path)
8
+ // * Canonical JSON 序列化(2 空格、键字典序、LF、末换行、
9
+ // Unicode 不转义、U+2028/U+2029 转义)
10
+ // ─────────────────────────────────────────────────────────────
11
+
12
+ export interface DiffResult {
13
+ path: string
14
+ expected: unknown
15
+ actual: unknown
16
+ }
17
+
18
+ // ── 对象与键工具 ──────────────────────────────────────────
19
+
20
+ export function isPlainObject (v: unknown): v is Record<string, unknown> {
21
+ return !!v && typeof v === 'object' && !Array.isArray(v)
22
+ }
23
+
24
+ // ── 1) Canonical JSON 序列化 ─────────────────────────────
25
+ // 规则:2 空格缩进 + 对象每层级键字典序 + LF 换行 + 尾部换行 +
26
+ // Unicode 原文保留(不 \u 转义,除 JSON 强制字符)+
27
+ // 行分隔符 U+2028 / 段分隔符 U+2029 转义(老 JS 引擎兼容)。
28
+
29
+ export function toCanonicalJSON (value: unknown): string {
30
+ return JSON.stringify(sortKeysDeep(value), null, 2)
31
+ .split('\r\n').join('\n')
32
+ .split('\u2028').join('\\u2028')
33
+ .split('\u2029').join('\\u2029') + '\n'
34
+ }
35
+
36
+ export function sortKeysDeep (v: unknown): unknown {
37
+ if (Array.isArray(v)) return v.map(sortKeysDeep)
38
+ if (isPlainObject(v)) {
39
+ const keys = Object.keys(v).sort()
40
+ const out: Record<string, unknown> = {}
41
+ for (const k of keys) out[k] = sortKeysDeep((v as Record<string, unknown>)[k])
42
+ return out
43
+ }
44
+ return v
45
+ }
46
+
47
+ // ── 2) Golden 侧 _ 键递归剥离(CANONICALIZE §3 Rule 1 v1.1) ─
48
+ // 只剥离 Golden。Candidate 若含 _ 前缀键,属于 Rule 2 违规,
49
+ // 应走 underscorePrefixedPaths 判 FAIL,绝不在 diff 中掩盖。
50
+
51
+ export function stripUnderscoreKeysGolden (obj: unknown): unknown {
52
+ if (Array.isArray(obj)) return obj.map(stripUnderscoreKeysGolden)
53
+ if (isPlainObject(obj)) {
54
+ const out: Record<string, unknown> = {}
55
+ for (const k of Object.keys(obj)) {
56
+ if (k.startsWith('_')) continue
57
+ out[k] = stripUnderscoreKeysGolden((obj as Record<string, unknown>)[k])
58
+ }
59
+ return out
60
+ }
61
+ return obj
62
+ }
63
+
64
+ // ── 3) Candidate/B3 侧全层级 _ 前缀键扫描 ─────────────────
65
+ // 返回 JSON-path 字符串数组(空 = 干净)。
66
+
67
+ export function underscorePrefixedPaths (obj: unknown): string[] {
68
+ const out: string[] = []
69
+ walk(obj, '$')
70
+ return out
71
+ function walk (v: unknown, at: string): void {
72
+ if (Array.isArray(v)) {
73
+ for (let i = 0; i < v.length; i++) walk(v[i], `${at}[${i}]`)
74
+ return
75
+ }
76
+ if (isPlainObject(v)) {
77
+ for (const k of Object.keys(v)) {
78
+ if (k.startsWith('_')) out.push(`${at}.${k}`)
79
+ walk((v as Record<string, unknown>)[k], `${at}.${k}`)
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ // ── 4) 对象级 firstDiff(键序不敏感) ─────────────────────
86
+ // 返回 null(相等)或首个差异(JSON-path + 两边值)。
87
+ // 相等判定使用 Object.is —— NaN 对 NaN 视为相等。
88
+
89
+ export function firstDiff (actual: unknown, expected: unknown, root: string): DiffResult | null {
90
+ if (isPlainObject(expected) && isPlainObject(actual)) {
91
+ const keys = Array.from(
92
+ new Set([...Object.keys(expected), ...Object.keys(actual)])
93
+ ).sort()
94
+ for (const k of keys) {
95
+ const e = (expected as Record<string, unknown>)[k]
96
+ const a = (actual as Record<string, unknown>)[k]
97
+ if (!(k in (expected as object))) {
98
+ return { path: `${root}.${k}`, expected: '<absent>', actual: shortStr(a) }
99
+ }
100
+ if (!(k in (actual as object))) {
101
+ return { path: `${root}.${k}`, expected: shortStr(e), actual: '<absent>' }
102
+ }
103
+ const sub = firstDiff(a, e, `${root}.${k}`)
104
+ if (sub) return sub
105
+ }
106
+ return null
107
+ }
108
+ if (Array.isArray(expected) && Array.isArray(actual)) {
109
+ if (expected.length !== actual.length) {
110
+ return { path: root + '.length', expected: expected.length, actual: actual.length }
111
+ }
112
+ for (let i = 0; i < expected.length; i++) {
113
+ const sub = firstDiff(actual[i], expected[i], `${root}[${i}]`)
114
+ if (sub) return sub
115
+ }
116
+ return null
117
+ }
118
+ if (Object.is(actual, expected)) return null
119
+ return { path: root, expected, actual }
120
+ }
121
+
122
+ export function shortStr (v: unknown): string {
123
+ try { return JSON.stringify(v) } catch { return String(v) }
124
+ }
package/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ // dexin-content — Universal content compilation layer
2
+ // Re-exports core types and interfaces for host consumption.
3
+ // Domain parsers are injected by the host via DomainParserRegistry.
4
+
5
+ export type { DomainName, DocumentIdentity, Schema, Artifact, ParseError, DomainParser, ParseContext, DocumentContent, DocumentBlock } from './core/types'
6
+ export { DomainParserRegistry, compile } from './core/compiler'
7
+ export type { ArtifactStore } from './store'
8
+ export { createFsArtifactStore, createMemoryArtifactStore } from './store'
9
+ export type { CollectionDefinition, SourceAdapter, ResolvedCollection } from './collection'
10
+ export { defineCollection, resolveCollections, discover, compileCollections } from './collection'
11
+ export type { ContentQuery as ContentQueryType } from './query'
12
+ export { ContentQuery } from './query'
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "dexin-content",
3
+ "version": "0.1.0",
4
+ "description": "Structured content compilation & runtime layer — neutral Markdown → Content AST → PositiveArtifact + store/query/collection pipeline.",
5
+ "keywords": [
6
+ "markdown",
7
+ "content",
8
+ "compiler",
9
+ "ast",
10
+ "cms",
11
+ "remark",
12
+ "dexinlabs"
13
+ ],
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "author": "得心实验室 <https://gitee.com/cuizhn>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://gitee.com/cuizhn/dexin-content.git"
20
+ },
21
+ "homepage": "https://gitee.com/cuizhn/dexin-content#readme",
22
+ "bugs": {
23
+ "url": "https://gitee.com/cuizhn/dexin-content/issues"
24
+ },
25
+ "engines": {
26
+ "node": ">=20.0.0"
27
+ },
28
+ "files": [
29
+ "index.ts",
30
+ "collection.ts",
31
+ "store.ts",
32
+ "query.ts",
33
+ "diff.ts",
34
+ "core/**/*.ts",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "exports": {
39
+ ".": "./index.ts",
40
+ "./core/types": "./core/types.ts",
41
+ "./core/compiler": "./core/compiler.ts",
42
+ "./core/frontmatter": "./core/frontmatter.ts",
43
+ "./core/discovery": "./core/discovery.ts",
44
+ "./store": "./store.ts",
45
+ "./collection": "./collection.ts",
46
+ "./query": "./query.ts",
47
+ "./diff": "./diff.ts"
48
+ },
49
+ "scripts": {
50
+ "typecheck": "tsc --noEmit",
51
+ "runtime:check": "tsx scripts/runtime-check.ts",
52
+ "pack": "npm pack --dry-run"
53
+ },
54
+ "dependencies": {
55
+ "yaml": "^2.5.1",
56
+ "remark-parse": "^11.0.0",
57
+ "remark-gfm": "^4.0.0",
58
+ "remark-math": "^6.0.0",
59
+ "remark-directive": "^3.0.0",
60
+ "unified": "^11.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/node": "^22.0.0",
64
+ "@types/mdast": "^4.0.4",
65
+ "typescript": "^5.5.0",
66
+ "tsx": "^4.7.0"
67
+ }
68
+ }
package/query.ts ADDED
@@ -0,0 +1,56 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/query.ts
3
+ //
4
+ // Runtime query — read-only over Artifact store.
5
+ //
6
+ // Query NEVER parses Markdown. No index → throw. byPath/byId
7
+ // return PositiveArtifact (with content.blocks); collection/list
8
+ // return IndexEntry[] (metadata + routing only).
9
+ //
10
+ // grep-zero: no business vocabulary appears anywhere in this
11
+ // module — including error messages.
12
+ // ─────────────────────────────────────────────────────────────
13
+
14
+ import type { PositiveArtifact } from './core/types'
15
+ import type { ArtifactStore, IndexEntry } from './store'
16
+
17
+ export interface QueryOptions {
18
+ where?: (entry: IndexEntry) => boolean
19
+ sort?: (a: IndexEntry, b: IndexEntry) => number
20
+ limit?: number
21
+ }
22
+
23
+ export class ContentQuery {
24
+ constructor (private store: ArtifactStore) {}
25
+
26
+ /** List all entries (optionally filtered to one collection). */
27
+ async list (collection?: string, opts: QueryOptions = {}): Promise<IndexEntry[]> {
28
+ const index = await this.store.readIndex()
29
+ if (!index) throw new Error('[Query] No index — run compile first (runtime never reparses source).')
30
+ let rows = collection ? index.entries.filter(e => e.collection === collection) : [...index.entries]
31
+ if (opts.where) rows = rows.filter(opts.where)
32
+ if (opts.sort) rows.sort(opts.sort)
33
+ if (opts.limit != null) rows = rows.slice(0, opts.limit)
34
+ return rows
35
+ }
36
+
37
+ /** Fetch single doc by URL path. Returns null if not found. */
38
+ async byPath (path: string): Promise<PositiveArtifact | null> {
39
+ const norm = path.replace(/\/+$/, '') || '/'
40
+ const index = await this.store.readIndex()
41
+ if (!index) throw new Error('[Query] No index — run compile first.')
42
+ const entry = index.entries.find(e => e.path === norm)
43
+ if (!entry) return null
44
+ return this.store.readDoc(entry.id)
45
+ }
46
+
47
+ /** Fetch single doc by stable id (= PositiveArtifact.id). */
48
+ async byId (id: string): Promise<PositiveArtifact | null> {
49
+ return this.store.readDoc(id)
50
+ }
51
+
52
+ /** List metadata entries for a collection (host runtime usage). */
53
+ async collection (name: string, opts: QueryOptions = {}): Promise<IndexEntry[]> {
54
+ return this.list(name, opts)
55
+ }
56
+ }
package/store.ts ADDED
@@ -0,0 +1,128 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/store.ts
3
+ //
4
+ // Artifact storage abstraction.
5
+ //
6
+ // The stored Doc shape IS the formal-layer PositiveArtifact
7
+ // (core/types.ts contract). No second Doc alias is introduced;
8
+ // the Store reads/writes the exact shape the compiler emits.
9
+ //
10
+ // The Index (ContentIndex / IndexEntry) is the routing overlay layer:
11
+ // IndexEntry.id maps to PositiveArtifact.id.
12
+ // IndexEntry.path/file/collection are path-derived routing info
13
+ // independent of the domain-specific identity inside the Artifact.
14
+ //
15
+ // grep-zero: no business vocabulary appears anywhere in this
16
+ // module — including error messages.
17
+ // ─────────────────────────────────────────────────────────────
18
+
19
+ import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
20
+ import { dirname } from 'node:path'
21
+ import type { PositiveArtifact, Meta } from './core/types'
22
+ import { toCanonicalJSON } from './diff'
23
+
24
+ // ── Index types ──
25
+
26
+ export interface IndexEntry {
27
+ /** Stable doc id; equals PositiveArtifact.id. */
28
+ id: string
29
+ /** URL semantic path: '/' + id. */
30
+ path: string
31
+ /** Owning collection name. */
32
+ collection: string
33
+ /** Domain tag (consistent with Artifact.domain). */
34
+ domain: string
35
+ /** Physical path relative to content root, INCLUDING .md. */
36
+ file: string
37
+ /** Scalar-only frontmatter projection. */
38
+ meta: Meta
39
+ /** First 12 hex chars of body md5 (change detection). */
40
+ checksum: string
41
+ }
42
+
43
+ export interface ContentIndex {
44
+ generator: 'dexin-content'
45
+ builtAt: string
46
+ entries: IndexEntry[]
47
+ }
48
+
49
+ // ── Store interface ──
50
+
51
+ export interface ArtifactStore {
52
+ writeIndex(index: ContentIndex): Promise<void>
53
+ writeDoc(doc: PositiveArtifact): Promise<void>
54
+ readIndex(): Promise<ContentIndex | null>
55
+ readDoc(id: string): Promise<PositiveArtifact | null>
56
+ }
57
+
58
+ // ── File-system store ──
59
+
60
+ export interface FsOps {
61
+ mkdir(p: string): Promise<void>
62
+ writeFile(p: string, data: string): Promise<void>
63
+ readFile(p: string): Promise<string>
64
+ exists(p: string): Promise<boolean>
65
+ }
66
+
67
+ const defaultFsOps: FsOps = {
68
+ async mkdir (p) { await mkdir(p, { recursive: true }) },
69
+ async writeFile (p, data) { await writeFile(p, data, 'utf8') },
70
+ async readFile (p) { return readFile(p, 'utf8') },
71
+ async exists (p) { try { await access(p); return true } catch { return false } }
72
+ }
73
+
74
+ /**
75
+ * File-system Artifact Store.
76
+ * Layout: <baseDir>/index.json + <baseDir>/docs/<id>.json
77
+ * Docs are serialised via toCanonicalJSON (2-space, key-lexicographic,
78
+ * LF, trailing newline) to match the canonical Artifact form.
79
+ */
80
+ export function createFsArtifactStore (
81
+ baseDir: string,
82
+ fsOps: FsOps = defaultFsOps
83
+ ): ArtifactStore {
84
+ const idxPath = () => `${baseDir}/index.json`
85
+ const docPath = (id: string) => `${baseDir}/docs/${id}.json`
86
+
87
+ return {
88
+ async writeIndex (index) {
89
+ await fsOps.mkdir(baseDir)
90
+ await fsOps.writeFile(idxPath(), toCanonicalJSON(index))
91
+ },
92
+
93
+ async writeDoc (doc) {
94
+ const p = docPath(doc.id)
95
+ await fsOps.mkdir(dirname(p))
96
+ await fsOps.writeFile(p, toCanonicalJSON(doc))
97
+ },
98
+
99
+ async readIndex () {
100
+ if (!(await fsOps.exists(idxPath()))) return null
101
+ return JSON.parse(await fsOps.readFile(idxPath())) as ContentIndex
102
+ },
103
+
104
+ async readDoc (id) {
105
+ if (!(await fsOps.exists(docPath(id)))) return null
106
+ return JSON.parse(await fsOps.readFile(docPath(id))) as PositiveArtifact
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── Memory store (Dev / test) ──
112
+
113
+ export interface MemoryStore extends ArtifactStore {
114
+ snapshot(): { index: ContentIndex | null; docs: Map<string, PositiveArtifact> }
115
+ }
116
+
117
+ export function createMemoryArtifactStore (): MemoryStore {
118
+ let index: ContentIndex | null = null
119
+ const docs = new Map<string, PositiveArtifact>()
120
+
121
+ return {
122
+ async writeIndex (i) { index = i },
123
+ async writeDoc (d) { docs.set(d.id, d) },
124
+ async readIndex () { return index },
125
+ async readDoc (id) { return docs.get(id) ?? null },
126
+ snapshot () { return { index, docs } }
127
+ }
128
+ }