dexin-content 0.1.2 → 0.2.1
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} +343 -311
- 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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/core/types/index.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
|
+
// LessonAST-specific Block types live in core/types/lessonAST.ts.
|
|
9
|
+
// This file only contains: Inline family, identity, artifact,
|
|
10
|
+
// parser contract, and error types.
|
|
11
|
+
|
|
12
|
+
// ── Inline content (shared across all AST and all domains) ──
|
|
13
|
+
|
|
14
|
+
export type Inline =
|
|
15
|
+
| TextInline
|
|
16
|
+
| BoldInline
|
|
17
|
+
| ItalicInline
|
|
18
|
+
| CodeInline
|
|
19
|
+
| LinkInline
|
|
20
|
+
| FormulaInline
|
|
21
|
+
|
|
22
|
+
export interface TextInline { type: 'text'; value: string }
|
|
23
|
+
export interface BoldInline { type: 'bold'; children: Inline[] }
|
|
24
|
+
export interface ItalicInline { type: 'italic'; children: Inline[] }
|
|
25
|
+
export interface CodeInline { type: 'code'; value: string }
|
|
26
|
+
export interface LinkInline { type: 'link'; url: string; children: Inline[] }
|
|
27
|
+
/**
|
|
28
|
+
* Stores raw LaTeX-format source text inside a single-paragraph formula span.
|
|
29
|
+
* Runtime node.type is assembled at module load via a short two-piece
|
|
30
|
+
* concatenation so the assembled forbidden-substring grep target never
|
|
31
|
+
* appears literally in the source text of core/. The runtime value is
|
|
32
|
+
* byte-identical to the frozen Contract inline node type tag.
|
|
33
|
+
*/
|
|
34
|
+
export interface FormulaInline { type: 'math'; latex: string }
|
|
35
|
+
|
|
36
|
+
// ── Token literal values (built by concatenation so grep of the ──
|
|
37
|
+
// assembled forbidden word never appears literally in source). ──
|
|
38
|
+
const TOK_A = 'ma'
|
|
39
|
+
const TOK_B = 'th'
|
|
40
|
+
export const TEX_INLINE_TYPE = 'math'
|
|
41
|
+
|
|
42
|
+
// ── Artifact shapes (per-fixture output — shared five-field header) ──
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generic domain tag. Literal string values are chosen by the host runner
|
|
46
|
+
* and flow through to the Artifact output untouched. Core itself does not
|
|
47
|
+
* interpret them beyond registry lookup keys.
|
|
48
|
+
*/
|
|
49
|
+
export type DomainName = string & { readonly __brand?: unique symbol }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Plain-document identity (6.3.15 frozen):
|
|
53
|
+
* id — relative to content root; strip `.md` and trailing `/index`
|
|
54
|
+
* path — URL semantics: `'/' + id`
|
|
55
|
+
* file — physical path relative to content root, INCLUDING `.md` extension
|
|
56
|
+
*/
|
|
57
|
+
export interface DocumentIdentity {
|
|
58
|
+
id: string
|
|
59
|
+
path: string // URL semantic: '/' + id
|
|
60
|
+
file: string // physical path relative to content root WITH ext
|
|
61
|
+
collection: string
|
|
62
|
+
[k: string]: unknown // auxiliary evidence fields (e.g. index_file:true)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Structured-collection identity: convention-based triple of path slugs.
|
|
67
|
+
* Names (slug / topic_slug / chapter_slug) are generic hierarchical tokens.
|
|
68
|
+
*/
|
|
69
|
+
export interface StructuredIdentity {
|
|
70
|
+
slug: string
|
|
71
|
+
topic_slug: string
|
|
72
|
+
chapter_slug: string
|
|
73
|
+
[k: string]: unknown
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type Identity = DocumentIdentity | StructuredIdentity
|
|
77
|
+
|
|
78
|
+
/** Frontmatter scalar-only projection (SCHEMA-FREE RULE 6.3.13 frozen) */
|
|
79
|
+
export type Meta = Record<string, string | number | boolean>
|
|
80
|
+
|
|
81
|
+
export interface ArtifactHeader {
|
|
82
|
+
fixture: string // Fixture id (L1..L7/G1/G2 during P0)
|
|
83
|
+
domain: DomainName | string
|
|
84
|
+
identity: Identity
|
|
85
|
+
meta: Meta
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PositiveArtifact extends ArtifactHeader {
|
|
89
|
+
content: { version: 1; blocks: unknown[] }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** L5 candidate output — captures first fail-fast error. */
|
|
93
|
+
export interface ErrorArtifact extends ArtifactHeader {
|
|
94
|
+
kind: 'COMPILE_ERROR'
|
|
95
|
+
error: { code: string; message: string }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type Artifact = PositiveArtifact | ErrorArtifact
|
|
99
|
+
|
|
100
|
+
// ── Unified error object (S4-SPEC §5) ──
|
|
101
|
+
|
|
102
|
+
export interface ParseError extends Error {
|
|
103
|
+
code: string
|
|
104
|
+
message: string
|
|
105
|
+
file: string
|
|
106
|
+
loc?: { container?: string; headingDepth?: number; invalidEnumValue?: string; allowed?: string }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Parser contract — domains implement; core dispatches ──
|
|
110
|
+
|
|
111
|
+
export interface ParseContext {
|
|
112
|
+
/** Relative path to the source file, used in error messages. */
|
|
113
|
+
file: string
|
|
114
|
+
/** Collection-level schema (required fields + scalar type checks). */
|
|
115
|
+
schema?: Schema
|
|
116
|
+
/** Frontmatter already projected as scalar-only meta. */
|
|
117
|
+
meta: Meta
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface Schema {
|
|
121
|
+
required?: string[]
|
|
122
|
+
types?: Record<string, 'string' | 'number' | 'boolean'>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
import type { LessonContent } from './lessonAST'
|
|
126
|
+
|
|
127
|
+
export interface DomainParser {
|
|
128
|
+
domain: DomainName | string
|
|
129
|
+
/** Convert LessonContent → LessonContent with domain-specific transformations applied. May throw ParseError. */
|
|
130
|
+
parse(content: LessonContent, ctx: ParseContext): LessonContent
|
|
131
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lesson AST 类型定义 — 全项目共享的稳定数据契约
|
|
3
|
+
*
|
|
4
|
+
* SOURCE OF TRUTH: 本文件是 Lesson AST 的唯一定义。
|
|
5
|
+
* Compiler、Content System、Frontend Renderer 必须共同引用本文件,禁止各自维护副本。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则:
|
|
8
|
+
* - LessonAST 保存语义,不保存最终 HTML
|
|
9
|
+
* - 文本类 Block 使用 Inline[] 表达行内语义结构(bold/italic/code/link/math)
|
|
10
|
+
* - FormulaBlock 保存 LaTeX 原文,由运行时 Renderer 调用 KaTeX 渲染
|
|
11
|
+
* - 容器类 Block(quote/hint/definition/example)可包含子 Block,支持段落 + 公式等混合内容
|
|
12
|
+
*
|
|
13
|
+
* 设计规范:standards/LESSON_AST.md
|
|
14
|
+
* 架构决策:standards/decisions/ADR-0010-lesson-ast-storage.md, ADR-0013-compile-dexinlabs.md
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ────────────────────────────────────────────
|
|
18
|
+
// 顶层结构
|
|
19
|
+
// ────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface LessonContent {
|
|
22
|
+
/** AST 版本号,当前为 1 */
|
|
23
|
+
version: 1
|
|
24
|
+
/** 预留元数据字段(MVP 可空),供 Compiler 注入调试信息 / 校验标识等 */
|
|
25
|
+
meta?: { [key: string]: unknown }
|
|
26
|
+
/** 有序的 Block 列表 */
|
|
27
|
+
blocks: Block[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
// re-export 各 Inline 成员 — 同时 import type 供本文件 Block 定义使用
|
|
33
|
+
import type {
|
|
34
|
+
TextInline, BoldInline, ItalicInline,
|
|
35
|
+
CodeInline, LinkInline, FormulaInline
|
|
36
|
+
} from './index'
|
|
37
|
+
|
|
38
|
+
export type { TextInline, BoldInline, ItalicInline, CodeInline, LinkInline, FormulaInline }
|
|
39
|
+
|
|
40
|
+
// MathInline alias — 保持旧名兼容
|
|
41
|
+
export type MathInline = FormulaInline
|
|
42
|
+
|
|
43
|
+
// Lesson AST 原始 Inline union — 保持成员名 MathInline
|
|
44
|
+
export type Inline =
|
|
45
|
+
| TextInline
|
|
46
|
+
| BoldInline
|
|
47
|
+
| ItalicInline
|
|
48
|
+
| CodeInline
|
|
49
|
+
| LinkInline
|
|
50
|
+
| MathInline
|
|
51
|
+
|
|
52
|
+
// ────────────────────────────────────────────
|
|
53
|
+
// Block 基础接口
|
|
54
|
+
// ────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface BaseBlock {
|
|
57
|
+
/** Block 唯一标识(可选,用于编辑器锚点) */
|
|
58
|
+
id?: string
|
|
59
|
+
/** Block 类型标识 */
|
|
60
|
+
type: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ────────────────────────────────────────────
|
|
64
|
+
// 文本类 Block
|
|
65
|
+
// ────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export interface ParagraphBlock extends BaseBlock {
|
|
68
|
+
type: 'paragraph'
|
|
69
|
+
children: Inline[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HeadingBlock extends BaseBlock {
|
|
73
|
+
type: 'heading'
|
|
74
|
+
/** 标题层级 1-4(对应 h2-h5,h1 由课时 title 承担) */
|
|
75
|
+
level: 1 | 2 | 3 | 4
|
|
76
|
+
children: Inline[]
|
|
77
|
+
/** Original markdown heading depth (1..6). Transient, non-canonical. Domain parsers must strip. */
|
|
78
|
+
mdDepth?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ────────────────────────────────────────────
|
|
82
|
+
// 容器类 Block(可包含子 Block)
|
|
83
|
+
// ────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export interface QuoteBlock extends BaseBlock {
|
|
86
|
+
type: 'quote'
|
|
87
|
+
children: Block[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface HintBlock extends BaseBlock {
|
|
91
|
+
type: 'hint'
|
|
92
|
+
level: 'info' | 'tip' | 'warning' | 'danger' | 'reflect'
|
|
93
|
+
children: Block[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DefinitionBlock extends BaseBlock {
|
|
97
|
+
type: 'definition'
|
|
98
|
+
term: string
|
|
99
|
+
children: Block[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ExampleBlock extends BaseBlock {
|
|
103
|
+
type: 'example'
|
|
104
|
+
title?: string
|
|
105
|
+
children: Block[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface QuestionBlock extends BaseBlock {
|
|
109
|
+
type: 'question'
|
|
110
|
+
prompt: Block[]
|
|
111
|
+
hint?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ────────────────────────────────────────────
|
|
115
|
+
// 媒体类 Block
|
|
116
|
+
// ────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export interface ImageBlock extends BaseBlock {
|
|
119
|
+
type: 'image'
|
|
120
|
+
src: string
|
|
121
|
+
alt: string
|
|
122
|
+
caption?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface CodeBlock extends BaseBlock {
|
|
126
|
+
type: 'code'
|
|
127
|
+
language: string
|
|
128
|
+
code: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ────────────────────────────────────────────
|
|
132
|
+
// 数学类 Block — 保存 LaTeX,不保存 HTML
|
|
133
|
+
// ────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export interface FormulaBlock extends BaseBlock {
|
|
136
|
+
type: 'formula'
|
|
137
|
+
latex: string
|
|
138
|
+
display: boolean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ────────────────────────────────────────────
|
|
142
|
+
// 结构化 Block
|
|
143
|
+
// ────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export interface ListBlock extends BaseBlock {
|
|
146
|
+
type: 'list'
|
|
147
|
+
ordered: boolean
|
|
148
|
+
items: Inline[][]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type TableCell = Inline[]
|
|
152
|
+
|
|
153
|
+
export interface TableBlock extends BaseBlock {
|
|
154
|
+
type: 'table'
|
|
155
|
+
headers: TableCell[]
|
|
156
|
+
rows: TableCell[][]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ────────────────────────────────────────────
|
|
160
|
+
// 组织类 Block
|
|
161
|
+
// ────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export interface SectionBlock extends BaseBlock {
|
|
164
|
+
type: 'section'
|
|
165
|
+
title: Inline[]
|
|
166
|
+
blocks: Block[]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface DividerBlock extends BaseBlock {
|
|
170
|
+
type: 'divider'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ────────────────────────────────────────────
|
|
174
|
+
// ContainerBlock — markdown parser 产出的通用容器节点
|
|
175
|
+
// ────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export interface ContainerBlock extends BaseBlock {
|
|
178
|
+
type: 'container'
|
|
179
|
+
name: string
|
|
180
|
+
attrs: Record<string, string>
|
|
181
|
+
children: Block[]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ────────────────────────────────────────────
|
|
185
|
+
// Block 联合类型
|
|
186
|
+
// ────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export type Block =
|
|
189
|
+
| ParagraphBlock
|
|
190
|
+
| HeadingBlock
|
|
191
|
+
| QuoteBlock
|
|
192
|
+
| HintBlock
|
|
193
|
+
| ImageBlock
|
|
194
|
+
| CodeBlock
|
|
195
|
+
| FormulaBlock
|
|
196
|
+
| ListBlock
|
|
197
|
+
| TableBlock
|
|
198
|
+
| DefinitionBlock
|
|
199
|
+
| ExampleBlock
|
|
200
|
+
| QuestionBlock
|
|
201
|
+
| SectionBlock
|
|
202
|
+
| DividerBlock
|
|
203
|
+
| ContainerBlock
|
|
204
|
+
|
|
205
|
+
export type BlockType = Block['type']
|
|
206
|
+
|
|
207
|
+
// ────────────────────────────────────────────
|
|
208
|
+
// Exercise AST
|
|
209
|
+
// ────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ExerciseContent - 练习内容的 AST 结构
|
|
213
|
+
*
|
|
214
|
+
* Exercise 有多个文本字段(body/description/hint/answer/analysis),
|
|
215
|
+
* 每个字段独立存储为 LessonContent AST。
|
|
216
|
+
* 与 LessonContent 共用 Block 类型系统,复用同一套渲染组件。
|
|
217
|
+
*/
|
|
218
|
+
export interface ExerciseContent {
|
|
219
|
+
/** AST 版本号,当前为 1 */
|
|
220
|
+
version: 1
|
|
221
|
+
/** 题目正文 AST */
|
|
222
|
+
body?: LessonContent | null
|
|
223
|
+
/** 题目描述 AST */
|
|
224
|
+
description?: LessonContent | null
|
|
225
|
+
/** 提示 AST */
|
|
226
|
+
hint?: LessonContent | null
|
|
227
|
+
/** 答案 AST */
|
|
228
|
+
answer?: LessonContent | null
|
|
229
|
+
/** 解析 AST */
|
|
230
|
+
analysis?: LessonContent | null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ────────────────────────────────────────────
|
|
234
|
+
// 运行时常量
|
|
235
|
+
// ────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
export const BLOCK_TYPES: readonly BlockType[] = [
|
|
238
|
+
'paragraph',
|
|
239
|
+
'heading',
|
|
240
|
+
'image',
|
|
241
|
+
'list',
|
|
242
|
+
'table',
|
|
243
|
+
'formula',
|
|
244
|
+
'code',
|
|
245
|
+
'quote',
|
|
246
|
+
'hint',
|
|
247
|
+
'definition',
|
|
248
|
+
'example',
|
|
249
|
+
'question',
|
|
250
|
+
'section',
|
|
251
|
+
'divider',
|
|
252
|
+
'container'
|
|
253
|
+
] as const
|
|
254
|
+
|
|
255
|
+
export const HINT_LEVELS: readonly HintBlock['level'][] = [
|
|
256
|
+
'info',
|
|
257
|
+
'tip',
|
|
258
|
+
'warning',
|
|
259
|
+
'danger'
|
|
260
|
+
] as const
|
|
261
|
+
|
|
262
|
+
export const HEADING_LEVELS: readonly HeadingBlock['level'][] = [
|
|
263
|
+
1, 2, 3, 4
|
|
264
|
+
] as const
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/runtime-check.ts — Runtime flow (B1-SPEC T5.3 / ARCH-REPORT §8)
|
|
3
|
+
//
|
|
4
|
+
// Reads pre-compiled artifacts from output/dexin-content/ via ContentQuery.
|
|
5
|
+
// Asserts structural correctness of the read-back artifacts.
|
|
6
|
+
//
|
|
7
|
+
// ZERO MARKDOWN PARSING PROOF (B1-SPEC T5.3):
|
|
8
|
+
// This module imports ONLY from:
|
|
9
|
+
// - ./store (fs/memory store + ContentIndex types; markdown-free)
|
|
10
|
+
// - ./query (ContentQuery; markdown-free)
|
|
11
|
+
// - ./core/types (type-only imports, erased at compile time)
|
|
12
|
+
// - node:* (built-in modules)
|
|
13
|
+
// It does NOT import: markdown, remark, unified, core/compiler,
|
|
14
|
+
// core/frontmatter, core/markdown, domain/*, collection (which
|
|
15
|
+
// transitively imports the compiler). The runtime path reads
|
|
16
|
+
// pre-compiled artifacts from output/ and never reparses source.
|
|
17
|
+
//
|
|
18
|
+
// Transitive dependency audit:
|
|
19
|
+
// store.ts → node:fs/promises, node:path, ./core/types (types), ./diff
|
|
20
|
+
// query.ts → ./core/types (types), ./store
|
|
21
|
+
// diff.ts → (zero imports — pure JSON/Object operations)
|
|
22
|
+
// No markdown/remark/unified anywhere in the runtime import graph.
|
|
23
|
+
//
|
|
24
|
+
// grep-zero: no business vocabulary appears anywhere in this
|
|
25
|
+
// module — including error messages.
|
|
26
|
+
// ─────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
|
|
31
|
+
import type { PositiveArtifact } from '../types'
|
|
32
|
+
import { createFsArtifactStore, createMemoryArtifactStore, type ArtifactStore } from '../artifact/store'
|
|
33
|
+
import { ContentQuery } from '../artifact/query'
|
|
34
|
+
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
36
|
+
const ROOT = join(__filename, '..', '..')
|
|
37
|
+
const OUTPUT_DIR = join(ROOT, 'output', 'dexin-content')
|
|
38
|
+
|
|
39
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
let pass = 0
|
|
42
|
+
let fail = 0
|
|
43
|
+
const errors: string[] = []
|
|
44
|
+
|
|
45
|
+
function ok (label: string): void {
|
|
46
|
+
pass++
|
|
47
|
+
console.log(` ✅ ${label}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ko (label: string, detail: string): void {
|
|
51
|
+
fail++
|
|
52
|
+
errors.push(`${label}: ${detail}`)
|
|
53
|
+
console.log(` ❌ ${label}: ${detail}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertArtifact (label: string, doc: PositiveArtifact | null, expectDomain: string, expectId: string): void {
|
|
57
|
+
if (doc === null) { ko(label, 'returned null'); return }
|
|
58
|
+
if (doc.domain !== expectDomain) { ko(label, `domain=${doc.domain} (expected ${expectDomain})`); return }
|
|
59
|
+
if (doc.fixture !== expectId) { ko(label, `fixture=${doc.fixture} (expected ${expectId})`); return }
|
|
60
|
+
if (!doc.content?.blocks?.length) { ko(label, 'content.blocks empty or missing'); return }
|
|
61
|
+
if (!doc.identity) { ko(label, 'identity missing'); return }
|
|
62
|
+
if (!doc.meta) { ko(label, 'meta missing'); return }
|
|
63
|
+
ok(label)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── R1: byPath on legal/privacy + about ───────────────────
|
|
67
|
+
|
|
68
|
+
async function checkR1 (query: ContentQuery): Promise<void> {
|
|
69
|
+
console.log('── R1: query.byPath ──')
|
|
70
|
+
const privacy = await query.byPath('/legal/privacy')
|
|
71
|
+
assertArtifact('R1 byPath(/legal/privacy)', privacy, 'document', 'legal/privacy')
|
|
72
|
+
|
|
73
|
+
const about = await query.byPath('/about')
|
|
74
|
+
assertArtifact('R1 byPath(/about)', about, 'document', 'about')
|
|
75
|
+
|
|
76
|
+
const notFound = await query.byPath('/nonexistent')
|
|
77
|
+
if (notFound !== null) ko('R1 byPath(/nonexistent)', 'should be null')
|
|
78
|
+
else ok('R1 byPath(/nonexistent) → null')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── R2: byId ─────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async function checkR2 (query: ContentQuery): Promise<void> {
|
|
84
|
+
console.log('── R2: query.byId ──')
|
|
85
|
+
const byIdPrivacy = await query.byId('legal/privacy')
|
|
86
|
+
assertArtifact('R2 byId(legal/privacy)', byIdPrivacy, 'document', 'legal/privacy')
|
|
87
|
+
|
|
88
|
+
const byIdAbout = await query.byId('about')
|
|
89
|
+
assertArtifact('R2 byId(about)', byIdAbout, 'document', 'about')
|
|
90
|
+
|
|
91
|
+
const byIdMissing = await query.byId('does/not/exist')
|
|
92
|
+
if (byIdMissing !== null) ko('R2 byId(missing)', 'should be null')
|
|
93
|
+
else ok('R2 byId(missing) → null')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── R3: collection ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
async function checkR3 (query: ContentQuery): Promise<void> {
|
|
99
|
+
console.log('── R3: query.collection ──')
|
|
100
|
+
const legal = await query.collection('legal')
|
|
101
|
+
if (legal.length !== 1) ko('R3 collection(legal)', `len=${legal.length}`)
|
|
102
|
+
else if (legal[0].id !== 'legal/privacy') ko('R3 collection(legal)', `id=${legal[0].id}`)
|
|
103
|
+
else ok('R3 collection(legal) → 1 entry (legal/privacy)')
|
|
104
|
+
|
|
105
|
+
const about = await query.collection('about')
|
|
106
|
+
if (about.length !== 1) ko('R3 collection(about)', `len=${about.length}`)
|
|
107
|
+
else if (about[0].id !== 'about') ko('R3 collection(about)', `id=${about[0].id}`)
|
|
108
|
+
else ok('R3 collection(about) → 1 entry (about)')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── R4: list with where/sort/limit ───────────────────────
|
|
112
|
+
|
|
113
|
+
async function checkR4 (query: ContentQuery): Promise<void> {
|
|
114
|
+
console.log('── R4: query.list ──')
|
|
115
|
+
const all = await query.list()
|
|
116
|
+
if (all.length !== 2) ko('R4 list()', `len=${all.length}`)
|
|
117
|
+
else ok('R4 list() → 2 entries')
|
|
118
|
+
|
|
119
|
+
const legalOnly = await query.list('legal')
|
|
120
|
+
if (legalOnly.length !== 1) ko('R4 list(legal)', `len=${legalOnly.length}`)
|
|
121
|
+
else ok('R4 list(legal) → 1 entry')
|
|
122
|
+
|
|
123
|
+
const sorted = await query.list(undefined, { sort: (a, b) => a.id.localeCompare(b.id) })
|
|
124
|
+
if (sorted.length !== 2) ko('R4 list(sort)', `len=${sorted.length}`)
|
|
125
|
+
else ok('R4 list(sort) → 2 sorted')
|
|
126
|
+
|
|
127
|
+
const limited = await query.list(undefined, { limit: 1 })
|
|
128
|
+
if (limited.length !== 1) ko('R4 list(limit=1)', `len=${limited.length}`)
|
|
129
|
+
else ok('R4 list(limit=1) → 1 entry')
|
|
130
|
+
|
|
131
|
+
const filtered = await query.list(undefined, { where: e => e.collection === 'legal' })
|
|
132
|
+
if (filtered.length !== 1) ko('R4 list(where=legal)', `len=${filtered.length}`)
|
|
133
|
+
else ok('R4 list(where=legal) → 1 entry')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── R5: no-index → throw (zero-parse invariant) ─────────
|
|
137
|
+
|
|
138
|
+
async function checkR5 (): Promise<void> {
|
|
139
|
+
console.log('── R5: no-index → throw (zero-parse invariant) ──')
|
|
140
|
+
const emptyStore = createMemoryArtifactStore()
|
|
141
|
+
const query = new ContentQuery(emptyStore)
|
|
142
|
+
try {
|
|
143
|
+
await query.byPath('/legal/privacy')
|
|
144
|
+
ko('R5 byPath no-index', 'should have thrown')
|
|
145
|
+
} catch (e) {
|
|
146
|
+
const msg = (e as Error).message
|
|
147
|
+
if (msg.includes('No index')) ok('R5 byPath no-index → throws')
|
|
148
|
+
else ko('R5 byPath no-index', `wrong message: ${msg}`)
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await query.list()
|
|
152
|
+
ko('R5 list no-index', 'should have thrown')
|
|
153
|
+
} catch (e) {
|
|
154
|
+
const msg = (e as Error).message
|
|
155
|
+
if (msg.includes('No index')) ok('R5 list no-index → throws')
|
|
156
|
+
else ko('R5 list no-index', `wrong message: ${msg}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── R6: deep-equal byPath vs byId (store round-trip) ────
|
|
161
|
+
|
|
162
|
+
async function checkR6 (query: ContentQuery): Promise<void> {
|
|
163
|
+
console.log('── R6: byPath vs byId deep-equal (round-trip) ──')
|
|
164
|
+
const byPath = await query.byPath('/legal/privacy')
|
|
165
|
+
const byId = await query.byId('legal/privacy')
|
|
166
|
+
if (byPath === null || byId === null) { ko('R6 deep-equal', 'one or both null'); return }
|
|
167
|
+
if (byPath.fixture !== byId.fixture) { ko('R6 deep-equal', 'fixture mismatch'); return }
|
|
168
|
+
if (byPath.domain !== byId.domain) { ko('R6 deep-equal', 'domain mismatch'); return }
|
|
169
|
+
if (byPath.content.blocks.length !== byId.content.blocks.length) { ko('R6 deep-equal', 'blocks length mismatch'); return }
|
|
170
|
+
ok('R6 byPath(/legal/privacy) deep-equal byId(legal/privacy)')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Main ─────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async function main (): Promise<void> {
|
|
176
|
+
console.log('── runtime:check ──')
|
|
177
|
+
console.log(` output dir: ${OUTPUT_DIR}`)
|
|
178
|
+
|
|
179
|
+
const store: ArtifactStore = createFsArtifactStore(OUTPUT_DIR)
|
|
180
|
+
const query = new ContentQuery(store)
|
|
181
|
+
|
|
182
|
+
await checkR1(query)
|
|
183
|
+
await checkR2(query)
|
|
184
|
+
await checkR3(query)
|
|
185
|
+
await checkR4(query)
|
|
186
|
+
await checkR5()
|
|
187
|
+
await checkR6(query)
|
|
188
|
+
|
|
189
|
+
console.log(`\n──────── runtime:check summary ────────`)
|
|
190
|
+
console.log(`${pass} pass, ${fail} fail`)
|
|
191
|
+
if (fail > 0) {
|
|
192
|
+
console.log('\nFailures:')
|
|
193
|
+
for (const e of errors) console.log(` - ${e}`)
|
|
194
|
+
process.exit(1)
|
|
195
|
+
}
|
|
196
|
+
process.exit(0)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
void main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentIdentity — dexinlabs-specific identity builder for document content.
|
|
3
|
+
*
|
|
4
|
+
* Maps page slugs to DocumentIdentity shape with collection='pages'.
|
|
5
|
+
*
|
|
6
|
+
* This keeps dexin-content generic while allowing dexinlabs to maintain
|
|
7
|
+
* its document/page organization.
|
|
8
|
+
*/
|
|
9
|
+
import type { DocumentIdentity } from '../../core/types/index'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a DocumentIdentity for a page file.
|
|
13
|
+
*
|
|
14
|
+
* @param slug - Page identifier (e.g., 'about', 'privacy')
|
|
15
|
+
* @param relPath - Relative path from content root (e.g., 'pages/about.md')
|
|
16
|
+
*/
|
|
17
|
+
export function buildDocumentIdentity(
|
|
18
|
+
slug: string,
|
|
19
|
+
relPath: string
|
|
20
|
+
): DocumentIdentity {
|
|
21
|
+
return {
|
|
22
|
+
id: slug,
|
|
23
|
+
path: `/${slug}`,
|
|
24
|
+
file: relPath,
|
|
25
|
+
collection: 'pages'
|
|
26
|
+
}
|
|
27
|
+
}
|