concept-atlas-dense-explain 0.7.0 → 0.9.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/bin/cli.mjs +4 -2
- package/package.json +1 -1
- package/skill/SKILL.md +17 -18
- package/template/guides/atlas-guide.mdx +12 -3
- package/template/guides/scroll-guide.mdx +8 -0
- package/template/src/app/App.jsx +52 -61
- package/template/src/app/navigation.js +41 -0
- package/template/src/app/search.js +84 -0
- package/template/src/components/MDXComponents.jsx +32 -0
- package/template/src/model/concept-schema.js +15 -0
- package/template/src/model/node-kinds.js +68 -0
- package/template/src/model/normalize-content.js +53 -1
- package/template/src/model/validate-content.js +26 -0
- package/template/src/styles/core.css +113 -0
- package/template/src/styles/tokens.css +21 -0
- package/template/src/views/NodeExplorer.jsx +33 -2
- package/template/src/views/RelationGraph.jsx +118 -24
package/bin/cli.mjs
CHANGED
|
@@ -156,12 +156,13 @@ if (command === 'create' || command === 'new') {
|
|
|
156
156
|
<Children><ConceptRef id="first-branch" /><ConceptRef id="second-branch" /></Children>
|
|
157
157
|
</ConceptNode>
|
|
158
158
|
|
|
159
|
-
<ConceptNode id="first-branch" title="第一条关键分支" level="L1" parent="root-node">
|
|
159
|
+
<ConceptNode id="first-branch" title="第一条关键分支" level="L1" parent="root-node" summary="第一条关键分支解决什么问题,以及它为什么值得先理解。">
|
|
160
160
|
<Overview>解释第一个重要组成部分。</Overview>
|
|
161
161
|
<Example title="典型例子">填写一个具体例子,帮助读者验证理解。</Example>
|
|
162
|
+
<CodeBlock language="bash" title="验证方式" code="npm run validate" lineNumbers />
|
|
162
163
|
</ConceptNode>
|
|
163
164
|
|
|
164
|
-
<ConceptNode id="second-branch" title="第二条关键分支" level="L1" parent="root-node">
|
|
165
|
+
<ConceptNode id="second-branch" title="第二条关键分支" level="L1" parent="root-node" summary="第二条关键分支解决什么问题,以及它和第一条分支如何配合。">
|
|
165
166
|
<Overview>解释第二个重要组成部分。</Overview>
|
|
166
167
|
<Boundary>填写它的边界、代价或常见误区。</Boundary>
|
|
167
168
|
</ConceptNode>
|
|
@@ -182,6 +183,7 @@ if (command === 'create' || command === 'new') {
|
|
|
182
183
|
<ScrollSection title="解释关键机制">
|
|
183
184
|
<ScrollProse>按输入、步骤、输出的顺序解释过程,不要只罗列名词。</ScrollProse>
|
|
184
185
|
<Flow title="处理流程" steps={[{title:'输入',description:'原始条件或数据'},{title:'处理',description:'关键变化或判断'},{title:'输出',description:'结果与可观察证据'}]} />
|
|
186
|
+
<CodeBlock language="bash" title="实践命令" code="npm run validate" caption="通用代码块可以放在任意章节;需要逐行讲解时加 lineNumbers。" />
|
|
185
187
|
</ScrollSection>
|
|
186
188
|
|
|
187
189
|
<ScrollSection title="边界与实践">
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: concept-atlas-dense-explain
|
|
3
|
-
description: Turn a topic or an existing document into an interactive Concept Atlas explainer.
|
|
3
|
+
description: Turn a topic or an existing document into an interactive Concept Atlas explainer. Generate AI-editable MDX with the concept-atlas-dense-explain npm CLI, validate its structure, and compile standalone HTML with concept nodes, relation graphs, math (KaTeX), charts, figures, citations, and reading aids. Use when a technical explanation, concept map, layered knowledge page, dense explainer, or interactive teaching page is requested.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Concept Atlas Dense Explain
|
|
7
7
|
|
|
8
|
-
Drive everything through the `concept-atlas-dense-explain` npm CLI. This skill is intentionally lightweight: do not copy implementation files from the skill directory
|
|
9
|
-
|
|
10
|
-
If the user only wants the prompt/methodology and not files, still choose a shell and emit valid MDX; the CLI is needed only to compile.
|
|
8
|
+
Drive everything through the `concept-atlas-dense-explain` npm CLI. This skill is intentionally lightweight: do not copy implementation files from the skill directory or recreate the React/Vite app. If a command name is unclear, run `npx concept-atlas-dense-explain help`. If the user only wants the prompt/methodology and not files, still choose a shell and emit valid MDX; the CLI is needed only to compile.
|
|
11
9
|
|
|
12
10
|
## Workflow
|
|
13
11
|
|
|
14
|
-
1. Choose one page shell: `atlas` (concept graph with node navigation) or `scroll` (continuous document). Recommend `atlas` when the reader
|
|
15
|
-
2. **Learn the components from the canonical guide before authoring.** Generate
|
|
12
|
+
1. Choose one page shell: `atlas` (concept graph with node navigation) or `scroll` (continuous document). Recommend `atlas` when the reader drills into concepts or follows relations, `scroll` for linear argument, tutorials, and reports. The library is shared. If an `.mdx` already exists, detect its shell and work with it.
|
|
13
|
+
2. **Learn the components from the canonical guide before authoring.** Generate it for the chosen shell and read it:
|
|
16
14
|
```bash
|
|
17
15
|
npx concept-atlas-dense-explain guide --mode atlas -o ./concept-atlas-atlas-guide.mdx
|
|
18
16
|
npx concept-atlas-dense-explain guide --mode scroll -o ./concept-atlas-scroll-guide.mdx
|
|
19
17
|
```
|
|
20
|
-
It is
|
|
18
|
+
It is real, compilable MDX showing that shell's components and their exact props; search it for a component name instead of guessing. Delete it when done.
|
|
21
19
|
3. Start from a skeleton when useful: `npx concept-atlas-dense-explain create <file>.mdx --mode atlas|scroll`. `create` and `guide` refuse to overwrite an existing file unless `--force` is passed.
|
|
22
20
|
4. Write the semantic MDX into the user's `.mdx` file (see Authoring rules).
|
|
23
21
|
5. Validate before rendering:
|
|
@@ -25,22 +23,22 @@ If the user only wants the prompt/methodology and not files, still choose a shel
|
|
|
25
23
|
npx concept-atlas-dense-explain validate <file>.mdx --mode atlas|scroll
|
|
26
24
|
npx concept-atlas-dense-explain validate <file>.mdx --json
|
|
27
25
|
```
|
|
28
|
-
|
|
29
|
-
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--inline-mermaid] [--mermaid-cdn <url>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build
|
|
30
|
-
7. **Appearance (optional)**: pages ship
|
|
31
|
-
8. For several documents, pass them all in one call: `npx concept-atlas-dense-explain a.mdx b.mdx c.mdx -o dist --force [--concurrency 3]
|
|
26
|
+
Diagnostics are `CODE line:column message`. Fix all `error`s and re-run; address warnings when cheap.
|
|
27
|
+
6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--inline-mermaid] [--mermaid-cdn <url>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build (`--no-validate` forces a knowingly broken build). Mermaid loads from a CDN at runtime by default (needs network); pass `--inline-mermaid` for a fully offline single file.
|
|
28
|
+
7. **Appearance (optional)**: pages ship a reader-facing appearance menu — palette (`aurora` indigo, `ember` gold, `verdant` forest, `sakura` pink-plum, `noir` ink), a dark/light toggle, and a component style pack (`manuscript` editorial marginalia, `classic` boxed cards, `shadcn` hairline-bordered minimal UI, `elastic` bordered observability panels). The shipped default is aurora × manuscript × light; choices persist in localStorage across both carriers. Bake different compile-time defaults with `--skin ember --default-mode dark --style classic` (or `CONCEPT_ATLAS_SKIN` / `CONCEPT_ATLAS_DEFAULT_MODE` / `CONCEPT_ATLAS_STYLE` on the repo build); `--default-mode` honors `dark`/`light` and resolves `system` to the carrier default (`light`). Bake a default only when the user asks for one — content MDX never sets appearance.
|
|
29
|
+
8. For several documents, pass them all in one call: `npx concept-atlas-dense-explain a.mdx b.mdx c.mdx -o dist --force [--concurrency 3]` (`-o` is then a directory; everything validates first, then builds in parallel). Builds bundle only the heavy renderers the content uses: no `<Math>` skips KaTeX's ~1.4MB inlined fonts, and Mermaid stays on a CDN. Never add dummy `<Math>`/`<Mermaid>` nodes to "enable" them.
|
|
32
30
|
9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
|
|
33
31
|
|
|
34
32
|
## Carriers
|
|
35
33
|
|
|
36
|
-
- `atlas`: `ExplainPage` → `ConceptGraph` → `ConceptNode`, plus `Children`/`ConceptRef` and cross-branch `Relation`s. Exactly one `L0` root, several `L1` branches, depth to `L3`/`L4`. Shared components live inside nodes.
|
|
34
|
+
- `atlas`: `ExplainPage` → `ConceptGraph` → `ConceptNode`, plus `Children`/`ConceptRef` and cross-branch `Relation`s. Exactly one `L0` root, several `L1` branches, depth to `L3`/`L4`. Shared components live inside nodes. `ConceptNode` also takes an optional `kind` (see Authoring rules) that labels a node's knowledge role independently of its level.
|
|
37
35
|
- `scroll`: `ScrollDocument` → `ScrollHeader` + `ScrollSection` (+ `ScrollProse`, `ScrollGrid`). Shared components live inside sections. The shell auto-builds a table of contents and reading progress from section titles — do not hand-build navigation.
|
|
38
|
-
- The browser tab comes from the shell, not the build flags: `ExplainPage title
|
|
36
|
+
- The browser tab comes from the shell, not the build flags: `ExplainPage title` (atlas) or `ScrollHeader title` (scroll) becomes the `<title>`, so give it a real, specific name — never a placeholder like "主题名称". Every page uses a fixed 📃 favicon.
|
|
39
37
|
- Never make one MDX file both shells. When switching shells, convert only the outer structure.
|
|
40
38
|
|
|
41
39
|
## Component families
|
|
42
40
|
|
|
43
|
-
- Node semantics: `Overview`, `Definition`, `Mechanism`, `Implementation`, `Boundary`, `Example`, `Counterexample`, `Prerequisite`, `Input`, `Output`, `Glossary`
|
|
41
|
+
- Node semantics: `Overview`, `Definition`, `Mechanism`, `Implementation`, `CodeBlock`, `Boundary`, `Example`, `Counterexample`, `Prerequisite`, `Input`, `Output`, `Glossary`
|
|
44
42
|
- Argument and evidence: `Evidence`, `Invariant`, `FailureMode`, `Tradeoff`, `LearningObjectives`, `KeyQuestion`
|
|
45
43
|
- Information models: `Flow`, `Timeline`, `Compare`, `DecisionMatrix`, `FrameworkModel`, `MatrixModel`, `FormulaModel`, `PyramidModel`, `FunnelModel`
|
|
46
44
|
- Reading and layout: `Insight`, `Callout`, `Details`, `NoteGrid`, `Tabs`, `Columns`, `Stack`, `Grid`, `Split`, `ScrollGrid`, `ScrollPair`, `ScrollToc`
|
|
@@ -50,19 +48,20 @@ If the user only wants the prompt/methodology and not files, still choose a shel
|
|
|
50
48
|
|
|
51
49
|
- Keep MDX semantic. Never write CSS, coordinates, SVG, or replacement application code; never invent component names or props.
|
|
52
50
|
- Give each important node a claim-like title, a one-sentence `summary`, and real substance (`Definition`, `Mechanism`, `Example`, `Boundary`, `Evidence`, a model, a chart, or math). Do not restate the same text across `Overview`, `Definition`, and `Insight`.
|
|
51
|
+
- **`kind` (optional, atlas)**: tag a node's knowledge role independently of `level`. One of `system`, `stage`, `mechanism`, `artifact`, `failure`, `tool`, `boundary`, `decision`. It powers the graph's "知识类型" filter and a node badge, so use it where the role is clear rather than on every node. Two opt-in contracts fire once you declare one: `kind="mechanism"` should contain an `Invariant` or `Evidence`, and `kind="failure"` should contain a `FailureMode` (with `symptom`/`cause`/`evidence`/`remedy`).
|
|
53
52
|
- Array props are arrays of objects: `Flow steps={[{title, description}]}`, `Timeline events={[{label, content}]}`, `MatrixModel cells={[{title, description, tone}]}`, `DecisionMatrix headers={[...]} rows={[[...]]}`, `Chart data={[{label, value}]}`, `References items={[{id, authors, year, title, url, source}]}`. The validator warns (`PROP_EXPECTS_ARRAY`) when an array prop gets a string or non-array.
|
|
54
53
|
- `Relation type` must be one of `prerequisite`, `causes`, `produces`, `uses`, `implements`, `contrasts`, `depends-on`, `exception-of`, `precedes`, and each `Relation` needs a `label`. Parent/child hierarchy is implicit (via `parent` and `Children`/`ConceptRef`) — do not express it with a `Relation`.
|
|
55
54
|
- **Math**: MDX parses `{ ... }` in children as expressions, so pass LaTeX with braces or backslashes through `formula`: `<Math formula="r_{\text{ann}} = (1 + r)^{12} - 1" />`, `<MathBlock formula="I(x) = -\log_2 p(x)" variables={[{symbol, description}]} />`. Brace-free children such as `<Math>\log_2 N</Math>` are fine. The validator warns (`MATH_CHILDREN_BRACES`).
|
|
55
|
+
- **CodeBlock**: a general code block usable anywhere; `Implementation` is the node-bound variant. Pass the code as a string via `code` (or as children) wrapped in a template literal so MDX does not read it as expressions: `<CodeBlock language="bash" title="..." lineNumbers>{`npm run validate`}</CodeBlock>`. `language` adds the badge, `title`/`caption` add labels, `lineNumbers` and `wrap` are booleans.
|
|
56
56
|
- **Chart**: `type` is `bar` | `line` | `pie`; use `data` for bar/pie and `labels` + `series={[{name, values}]}` for line. Charts follow theme colors.
|
|
57
|
-
- **Figure**: a relative `src` (`./assets/diagram.png`) is inlined as base64 at build time so the HTML stays standalone; `http(s)` URLs stay links. Always set `alt`; add `label` and `caption` for a numbered caption. A missing relative file
|
|
58
|
-
- **Figure size**: inlining is what makes a screenshot-heavy page large
|
|
57
|
+
- **Figure**: a relative `src` (`./assets/diagram.png`) is inlined as base64 at build time so the HTML stays standalone; `http(s)` URLs stay links. Always set `alt`; add `label` and `caption` for a numbered caption. A missing relative file warns (`ASSET_MISSING`) and shows a placeholder. Readers can click a figure to open it full-screen (zoom, drag, `Esc`) — mention it for diagram-heavy pages.
|
|
58
|
+
- **Figure size & cost**: inlining images is what makes a screenshot-heavy page large; a page with no heavy renderers otherwise lands near 250KB. When the user cares, compile with `--link-assets` to keep images as relative links (measured 1.51MB → 270KB); the output must then sit beside the MDX's `assets/`, and the CLI warns if `-o` points elsewhere — tell the user that trade-off instead of choosing silently.
|
|
59
59
|
- **Cite/References**: `<Cite id="..." />` renders `[n]` from the matching item's position in `<References items={...} />`. In `scroll`, `References` can sit anywhere. In `atlas`, keep the cites and the `References` block in the same node, because node content only renders when that node is open.
|
|
60
60
|
- Continuous reading is configured on the shell, not with manual CSS: `spacing="compact|comfortable|airy"` for rhythm, `fontSize="compact|normal|large|xlarge"` (or numeric `scale`/`lineHeight`) for text size.
|
|
61
|
-
- Be brief about cost: the build only bundles the heavy optional renderers the document actually uses, so a page with no `<Math>`/`<Mermaid>` comes out around 250KB. Inlined figures are usually the largest remaining cost — a page with a dozen screenshots lands near 1–2MB, which is normal for an offline explainer. Mention it if the user cares about file size.
|
|
62
61
|
|
|
63
62
|
## Validation diagnostics
|
|
64
63
|
|
|
65
|
-
`validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `REF_SELF`; `NO_ROOT_LEVEL` and `
|
|
64
|
+
`validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_KIND`, `MECHANISM_KIND_UNVERIFIED`, `FAILURE_KIND_UNSTRUCTURED`, `FAILURE_MODE_EMPTY`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `REF_SELF`; `NO_ROOT_LEVEL`, `MULTIPLE_ROOT_LEVEL`, `MECHANISM_KIND_UNVERIFIED` and `FAILURE_KIND_UNSTRUCTURED` are warnings that `--strict` promotes to errors.
|
|
66
65
|
|
|
67
66
|
## Before you report
|
|
68
67
|
|
|
@@ -114,9 +114,9 @@
|
|
|
114
114
|
<RelationMap title="可视化摘要" items={[{from:'MDX',type:'produces',to:'Graph',note:'生成关系图'},{from:'Node',type:'uses',to:'Mermaid',note:'展示局部流程'}]} />
|
|
115
115
|
</ConceptNode>
|
|
116
116
|
|
|
117
|
-
<ConceptNode id="extension-family" title="
|
|
118
|
-
<Definition>扩展组件把文本之外的证据接进页面:Math/MathBlock 渲染数学、Chart 绘制数据、Figure 承载配图、Cite/References 保留出处。</Definition>
|
|
119
|
-
<Children><ConceptRef id="math-demo" /><ConceptRef id="chart-demo" /><ConceptRef id="figure-demo" /><ConceptRef id="citation-demo" /></Children>
|
|
117
|
+
<ConceptNode id="extension-family" title="扩展能力:公式、图表、图片、代码与引用" level="L1" parent="showroom" summary="数学公式、数据图表、配图题注、代码块和参考文献五项能力,让讲解可以带上推导、数据、实现与出处。">
|
|
118
|
+
<Definition>扩展组件把文本之外的证据接进页面:Math/MathBlock 渲染数学、Chart 绘制数据、Figure 承载配图、CodeBlock 展示代码或命令、Cite/References 保留出处。</Definition>
|
|
119
|
+
<Children><ConceptRef id="math-demo" /><ConceptRef id="chart-demo" /><ConceptRef id="figure-demo" /><ConceptRef id="code-demo" /><ConceptRef id="citation-demo" /></Children>
|
|
120
120
|
</ConceptNode>
|
|
121
121
|
|
|
122
122
|
<ConceptNode id="math-demo" title="Math 与 MathBlock:把推导写进正文" level="L2" parent="extension-family" summary="Math 用于行内符号,MathBlock 用于独立公式并支持变量说明。">
|
|
@@ -138,6 +138,15 @@
|
|
|
138
138
|
<Boundary>大图会显著增大单文件 HTML;截图类内容建议控制尺寸。</Boundary>
|
|
139
139
|
</ConceptNode>
|
|
140
140
|
|
|
141
|
+
<ConceptNode id="code-demo" title="CodeBlock:把命令、配置或伪代码放进正文" level="L2" parent="extension-family" summary="与绑定节点的 Implementation 不同,CodeBlock 可以出现在任何位置,并支持标题、题注、换行和行号。">
|
|
142
|
+
<Definition>CodeBlock 通过 code(或子内容)接收字符串,language 决定语言标记,title 与 caption 提供说明,lineNumbers 和 wrap 控制逐行讲解与长行折行。</Definition>
|
|
143
|
+
<CodeBlock language="bash" title="校验与构建" caption="命令行、伪代码或配置都可以用它承载,不依附于某个概念节点。" lineNumbers>{`npm run validate
|
|
144
|
+
npm run sync
|
|
145
|
+
npm run build`}</CodeBlock>
|
|
146
|
+
<CodeBlock language="javascript" title="语义优先" wrap>{`const explain = concept => concept.mechanism ?? concept.definition ?? concept.overview;`}</CodeBlock>
|
|
147
|
+
<Boundary>代码里的反引号、花括号请放在模板字符串中传入,避免被 MDX 当作表达式解析。</Boundary>
|
|
148
|
+
</ConceptNode>
|
|
149
|
+
|
|
141
150
|
<ConceptNode id="citation-demo" title="Cite 与 References:让结论可以追溯" level="L2" parent="extension-family" summary="行内用 Cite 标记引用,文末用 References 列出完整出处,编号自动对应。">
|
|
142
151
|
<Definition>Cite 的 id 与 References 条目的 id 对应,渲染时显示条目在列表中的序号,并链接到该条目。</Definition>
|
|
143
152
|
<Overview>信息密度的价值在于可验证性<Cite id="shannon1948" />,而可验证性依赖清晰的出处<Cite id="tufte1983" />。</Overview>
|
|
@@ -42,6 +42,14 @@
|
|
|
42
42
|
<Chart title="四周留存趋势" type="line" labels={['第1周','第2周','第3周','第4周']} series={[{name:'留存率',values:[100,72,58,49]},{name:'活跃度',values:[100,64,52,40]}]} />
|
|
43
43
|
</ScrollSection>
|
|
44
44
|
|
|
45
|
+
<ScrollSection title="把命令与代码放进正文">
|
|
46
|
+
<ScrollProse>CodeBlock 承载命令行、配置或伪代码。它不依附于某个概念节点,可以出现在任何章节;需要逐行讲解时打开行号,长行过多时可以折行。</ScrollProse>
|
|
47
|
+
<CodeBlock language="bash" title="本地校验与预览" caption="CodeBlock 复用模板的代码表面,因此自动跟随当前皮肤与组件风格。" lineNumbers>{`npm run validate
|
|
48
|
+
npm run sync
|
|
49
|
+
npm run dev`}</CodeBlock>
|
|
50
|
+
<CodeBlock language="javascript" title="语义优先" wrap>{`const explain = concept => concept.mechanism ?? concept.definition ?? concept.overview;`}</CodeBlock>
|
|
51
|
+
</ScrollSection>
|
|
52
|
+
|
|
45
53
|
<ScrollSection title="组织证据,再收敛到行动">
|
|
46
54
|
<ScrollGrid columns="3">
|
|
47
55
|
<PyramidModel title="论证层级" levels={[{title:'结论',description:'读者需要带走的判断'},{title:'理由',description:'支撑判断的关键分组'},{title:'证据',description:'事实、数据与案例'}]} />
|
package/template/src/app/App.jsx
CHANGED
|
@@ -3,11 +3,19 @@ import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide
|
|
|
3
3
|
import { buildGraphModel } from '../model/concept-schema.js';
|
|
4
4
|
import { extractConceptData } from '../model/normalize-content.js';
|
|
5
5
|
import { useAppearance } from './use-appearance.js';
|
|
6
|
+
import { pushNode, stepHistory, syncFromLocation } from './navigation.js';
|
|
7
|
+
import { searchNodes } from './search.js';
|
|
8
|
+
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
6
9
|
import { SkinPicker } from '../components/SkinPicker.jsx';
|
|
7
10
|
import { NodeExplorer } from '../views/NodeExplorer.jsx';
|
|
8
11
|
import { RelationGraph } from '../views/RelationGraph.jsx';
|
|
9
12
|
import '../styles/concept-explain.css';
|
|
10
13
|
|
|
14
|
+
function readNodeFromHash() {
|
|
15
|
+
if (typeof window === 'undefined') return null;
|
|
16
|
+
return new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export function App({ mdxContent, initialData }) {
|
|
12
20
|
// Extract graph model from MDX JSX Element or raw data
|
|
13
21
|
const [graph] = useState(() => {
|
|
@@ -28,54 +36,41 @@ export function App({ mdxContent, initialData }) {
|
|
|
28
36
|
|
|
29
37
|
// Global shared state
|
|
30
38
|
const [currentView, setCurrentView] = useState('explore'); // 'explore' | 'graph'
|
|
31
|
-
const
|
|
32
|
-
const hashNode =
|
|
33
|
-
return hashNode && graph.nodes.has(hashNode) ? hashNode :
|
|
34
|
-
});
|
|
39
|
+
const initialHashNode = (() => {
|
|
40
|
+
const hashNode = readNodeFromHash();
|
|
41
|
+
return hashNode && graph.nodes.has(hashNode) ? hashNode : null;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
const [currentNodeId, setCurrentNodeId] = useState(
|
|
45
|
+
() => initialHashNode || graph.meta.rootId || '',
|
|
46
|
+
);
|
|
35
47
|
const [selectedLevel, setSelectedLevel] = useState(null);
|
|
36
|
-
const [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
const [historyIndex, setHistoryIndex] = useState(0);
|
|
48
|
+
const [nav, setNav] = useState(() => ({
|
|
49
|
+
entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
|
|
50
|
+
index: 0,
|
|
51
|
+
}));
|
|
41
52
|
const [globalQuery, setGlobalQuery] = useState('');
|
|
42
53
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
43
54
|
|
|
44
|
-
const searchResults = useMemo(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const searchable = [
|
|
49
|
-
node.title, node.id, node.summary, node.definition, node.overview,
|
|
50
|
-
node.mechanism, node.input, node.output,
|
|
51
|
-
...(node.examples || []).flatMap(item => [item.title, item.content]),
|
|
52
|
-
...(node.glossary || []).flatMap(item => [item.term, item.definition]),
|
|
53
|
-
...(node.boundaries || []).flatMap(item => [item.title, item.content]),
|
|
54
|
-
].filter(value => typeof value === 'string').join(' ').toLowerCase();
|
|
55
|
-
return searchable.includes(query) ? node : null;
|
|
56
|
-
}).filter(Boolean).slice(0, 8);
|
|
57
|
-
}, [globalQuery, graph.nodes]);
|
|
55
|
+
const searchResults = useMemo(
|
|
56
|
+
() => searchNodes(Array.from(graph.nodes.values()), globalQuery),
|
|
57
|
+
[globalQuery, graph.nodes],
|
|
58
|
+
);
|
|
58
59
|
|
|
59
60
|
const navigateToNode = (nodeId, { replace = false } = {}) => {
|
|
60
61
|
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
61
62
|
setCurrentNodeId(nodeId);
|
|
62
|
-
|
|
63
|
-
const base = previous.slice(0, historyIndex + 1);
|
|
64
|
-
if (base[base.length - 1] === nodeId) return previous;
|
|
65
|
-
const next = [...base, nodeId];
|
|
66
|
-
setHistoryIndex(next.length - 1);
|
|
67
|
-
return next;
|
|
68
|
-
});
|
|
63
|
+
setNav(previous => pushNode(previous, nodeId));
|
|
69
64
|
const nextHash = `#node=${encodeURIComponent(nodeId)}`;
|
|
70
65
|
if (replace) window.history.replaceState({}, '', nextHash);
|
|
71
66
|
else window.history.pushState({}, '', nextHash);
|
|
72
67
|
};
|
|
73
68
|
|
|
74
69
|
const moveHistory = (direction) => {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
const nodeId =
|
|
70
|
+
const next = stepHistory(nav, direction);
|
|
71
|
+
if (!next) return;
|
|
72
|
+
setNav(next);
|
|
73
|
+
const nodeId = next.entries[next.index];
|
|
79
74
|
setCurrentNodeId(nodeId);
|
|
80
75
|
window.history.pushState({}, '', `#node=${encodeURIComponent(nodeId)}`);
|
|
81
76
|
};
|
|
@@ -83,10 +78,26 @@ export function App({ mdxContent, initialData }) {
|
|
|
83
78
|
// Keyboard navigation shortcuts
|
|
84
79
|
useEffect(() => {
|
|
85
80
|
const handleKeyDown = (e) => {
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
if (e.isComposing) return;
|
|
82
|
+
|
|
83
|
+
// Alt + arrows move through browsing history. Handled before the modifier
|
|
84
|
+
// guard below, otherwise the altKey short-circuit makes them unreachable.
|
|
85
|
+
if (e.altKey && e.key === 'ArrowLeft') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
moveHistory(-1);
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
|
+
if (e.altKey && e.key === 'ArrowRight') {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
moveHistory(1);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Toggle views with 1 and 2 or 'g' and 'e' if not focused on input.
|
|
97
|
+
const target = document.activeElement;
|
|
98
|
+
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
|
|
99
|
+
if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
|
|
100
|
+
|
|
90
101
|
const key = e.key.toLowerCase();
|
|
91
102
|
if (e.key === '1' || key === 'e') {
|
|
92
103
|
setCurrentView('explore');
|
|
@@ -95,18 +106,7 @@ export function App({ mdxContent, initialData }) {
|
|
|
95
106
|
} else if (key === 't') {
|
|
96
107
|
toggleTheme();
|
|
97
108
|
} else if (e.key === 'Escape') {
|
|
98
|
-
//
|
|
99
|
-
if (e.altKey && e.key === 'ArrowLeft') {
|
|
100
|
-
e.preventDefault();
|
|
101
|
-
moveHistory(-1);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (e.altKey && e.key === 'ArrowRight') {
|
|
105
|
-
e.preventDefault();
|
|
106
|
-
moveHistory(1);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
// Return to root or parent
|
|
109
|
+
// Return to the parent node.
|
|
110
110
|
const curr = graph.nodes.get(currentNodeId);
|
|
111
111
|
if (curr && curr.parent) {
|
|
112
112
|
navigateToNode(curr.parent);
|
|
@@ -116,23 +116,14 @@ export function App({ mdxContent, initialData }) {
|
|
|
116
116
|
|
|
117
117
|
window.addEventListener('keydown', handleKeyDown);
|
|
118
118
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
119
|
-
}, [graph, currentNodeId,
|
|
119
|
+
}, [graph, currentNodeId, nav]);
|
|
120
120
|
|
|
121
121
|
useEffect(() => {
|
|
122
122
|
const handlePopState = () => {
|
|
123
|
-
const nodeId =
|
|
123
|
+
const nodeId = readNodeFromHash();
|
|
124
124
|
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
125
125
|
setCurrentNodeId(nodeId);
|
|
126
|
-
|
|
127
|
-
const index = previous.lastIndexOf(nodeId);
|
|
128
|
-
if (index >= 0) {
|
|
129
|
-
setHistoryIndex(index);
|
|
130
|
-
return previous;
|
|
131
|
-
}
|
|
132
|
-
const next = [...previous, nodeId];
|
|
133
|
-
setHistoryIndex(next.length - 1);
|
|
134
|
-
return next;
|
|
135
|
-
});
|
|
126
|
+
setNav(previous => syncFromLocation(previous, nodeId));
|
|
136
127
|
};
|
|
137
128
|
window.addEventListener('popstate', handlePopState);
|
|
138
129
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
@@ -176,7 +167,7 @@ export function App({ mdxContent, initialData }) {
|
|
|
176
167
|
<div className="global-search-results" role="listbox">
|
|
177
168
|
{searchResults.map(node => (
|
|
178
169
|
<button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
|
|
179
|
-
<span>{node.title}</span><small>{node.level} · {node.summary || node.id}</small>
|
|
170
|
+
<span>{node.title}</span><small>{node.level}{node.kind && NODE_KINDS[node.kind] ? ` · ${NODE_KINDS[node.kind].label}` : ''} · {node.summary || node.id}</small>
|
|
180
171
|
</button>
|
|
181
172
|
))}
|
|
182
173
|
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure browsing-history helpers for the atlas carrier.
|
|
3
|
+
*
|
|
4
|
+
* History is a single `{ entries, index }` value instead of two separate
|
|
5
|
+
* `useState` calls. Keeping them together removes the side effect that used to
|
|
6
|
+
* live inside the `setHistory` updater (which React StrictMode can double
|
|
7
|
+
* invoke) and makes the whole navigation model trivially unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Push a node onto the history, truncating any forward entries.
|
|
12
|
+
* Returns the same object when `nodeId` is already the current entry so React
|
|
13
|
+
* can bail out of the state update.
|
|
14
|
+
*/
|
|
15
|
+
export function pushNode(state, nodeId) {
|
|
16
|
+
const base = state.entries.slice(0, state.index + 1);
|
|
17
|
+
if (base[base.length - 1] === nodeId) return state;
|
|
18
|
+
const entries = [...base, nodeId];
|
|
19
|
+
return { entries, index: entries.length - 1 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Move one step through the history. Returns `null` when the move would leave
|
|
24
|
+
* the bounds, so the caller can avoid a redundant re-render.
|
|
25
|
+
*/
|
|
26
|
+
export function stepHistory(state, direction) {
|
|
27
|
+
const nextIndex = Math.max(0, Math.min(state.entries.length - 1, state.index + direction));
|
|
28
|
+
if (nextIndex === state.index) return null;
|
|
29
|
+
return { entries: state.entries, index: nextIndex };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a node id arriving from a `popstate` event (browser back/forward).
|
|
34
|
+
* Reuses an existing entry when present so the back stack stays consistent.
|
|
35
|
+
*/
|
|
36
|
+
export function syncFromLocation(state, nodeId) {
|
|
37
|
+
const existing = state.entries.lastIndexOf(nodeId);
|
|
38
|
+
if (existing >= 0) return { entries: state.entries, index: existing };
|
|
39
|
+
const entries = [...state.entries, nodeId];
|
|
40
|
+
return { entries, index: entries.length - 1 };
|
|
41
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { NODE_KINDS } from '../model/node-kinds.js';
|
|
3
|
+
|
|
4
|
+
const MAX_DEPTH = 6;
|
|
5
|
+
const MAX_LENGTH = 4000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Flatten whatever an MDX component renders — strings, React elements, arrays
|
|
9
|
+
* and prop objects — into searchable plain text. Bounded in depth and length so
|
|
10
|
+
* a pathological tree cannot stall the search box.
|
|
11
|
+
*/
|
|
12
|
+
export function collectText(value, depth = 0) {
|
|
13
|
+
if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean') return '';
|
|
14
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return value.map(item => collectText(item, depth + 1)).join(' ');
|
|
17
|
+
}
|
|
18
|
+
if (React.isValidElement(value)) {
|
|
19
|
+
const props = value.props || {};
|
|
20
|
+
const parts = [];
|
|
21
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
22
|
+
// `components` is the MDX component registry, never content.
|
|
23
|
+
if (key === 'children' || key === 'components') continue;
|
|
24
|
+
if (typeof prop === 'string' || typeof prop === 'number') parts.push(String(prop));
|
|
25
|
+
else if (prop && typeof prop === 'object') parts.push(collectText(prop, depth + 1));
|
|
26
|
+
}
|
|
27
|
+
parts.push(collectText(props.children, depth + 1));
|
|
28
|
+
return parts.join(' ');
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'object') {
|
|
31
|
+
return Object.values(value).map(item => collectText(item, depth + 1)).join(' ');
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build one lowercase haystack per node. Includes the semantic content fields,
|
|
38
|
+
* the structured argument/evidence records (Evidence, Invariant, FailureMode,
|
|
39
|
+
* Tradeoff, …) and the free-form `customSections` that the same components also
|
|
40
|
+
* render through. The kind id and its label are indexed too, so "故障" finds
|
|
41
|
+
* `kind="failure"` nodes.
|
|
42
|
+
*/
|
|
43
|
+
export function nodeSearchText(node) {
|
|
44
|
+
if (!node) return '';
|
|
45
|
+
const kind = node.kind ? NODE_KINDS[node.kind] : null;
|
|
46
|
+
const parts = [
|
|
47
|
+
node.title,
|
|
48
|
+
node.id,
|
|
49
|
+
node.kind,
|
|
50
|
+
kind ? kind.label : '',
|
|
51
|
+
node.summary,
|
|
52
|
+
node.overview,
|
|
53
|
+
node.definition,
|
|
54
|
+
node.mechanism,
|
|
55
|
+
node.input,
|
|
56
|
+
node.output,
|
|
57
|
+
node.implementation,
|
|
58
|
+
node.prerequisites,
|
|
59
|
+
node.examples,
|
|
60
|
+
node.counterexamples,
|
|
61
|
+
node.boundaries,
|
|
62
|
+
node.glossary,
|
|
63
|
+
node.learningObjectives,
|
|
64
|
+
node.keyQuestions,
|
|
65
|
+
node.evidence,
|
|
66
|
+
node.invariants,
|
|
67
|
+
node.failureModes,
|
|
68
|
+
node.tradeoffs,
|
|
69
|
+
node.customSections,
|
|
70
|
+
];
|
|
71
|
+
return parts.map(part => collectText(part)).join(' ').slice(0, MAX_LENGTH).toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rank-free substring search returning at most `limit` nodes. */
|
|
75
|
+
export function searchNodes(nodes, query, limit = 8) {
|
|
76
|
+
const needle = String(query || '').trim().toLowerCase();
|
|
77
|
+
if (!needle) return [];
|
|
78
|
+
const results = [];
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
if (nodeSearchText(node).includes(needle)) results.push(node);
|
|
81
|
+
if (results.length >= limit) break;
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
@@ -180,6 +180,38 @@ export function Implementation({ language = 'text', title = '实现代码', chil
|
|
|
180
180
|
}
|
|
181
181
|
Implementation.displayName = 'Implementation';
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Generic code block for any carrier. `Implementation` is bound to a node's
|
|
185
|
+
* implementation section; `CodeBlock` is meant for shell commands, config,
|
|
186
|
+
* output, prompts and pseudocode that live anywhere in a document.
|
|
187
|
+
* Pass the code as a string through `code` (or as children) so MDX parsing and
|
|
188
|
+
* the validator never confuse code with markup.
|
|
189
|
+
*/
|
|
190
|
+
export function CodeBlock({ code, language = 'text', title, caption, lineNumbers = false, wrap = false, children }) {
|
|
191
|
+
const raw = typeof code === 'string' ? code : childrenToText(children);
|
|
192
|
+
const text = typeof raw === 'string' ? raw.replace(/^\n+|\s+$/g, '') : '';
|
|
193
|
+
if (!text) return null;
|
|
194
|
+
const showLanguage = Boolean(language) && language !== 'text';
|
|
195
|
+
const showHead = Boolean(title) || showLanguage;
|
|
196
|
+
return (
|
|
197
|
+
<figure className="semantic-code" data-language={language}>
|
|
198
|
+
{showHead && (
|
|
199
|
+
<div className="semantic-code-head">
|
|
200
|
+
{title && <span className="semantic-code-title">{title}</span>}
|
|
201
|
+
{showLanguage && <span className="lang-badge">{language}</span>}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
<pre className={`code-block${lineNumbers ? ' code-block-numbered' : ''}${wrap ? ' code-block-wrap' : ''}`}>
|
|
205
|
+
{lineNumbers
|
|
206
|
+
? <code>{text.split('\n').map((line, index) => <span className="code-line" key={index}>{line}</span>)}</code>
|
|
207
|
+
: <code>{text}</code>}
|
|
208
|
+
</pre>
|
|
209
|
+
{caption && <figcaption className="semantic-code-caption">{caption}</figcaption>}
|
|
210
|
+
</figure>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
CodeBlock.displayName = 'CodeBlock';
|
|
214
|
+
|
|
183
215
|
export function Boundary({ title = '边界与约束', children }) {
|
|
184
216
|
return (
|
|
185
217
|
<div className="semantic-boundary">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { RELATION_TYPES, LEVEL_DEFS } from './relation-types.js';
|
|
3
|
+
import { NODE_KIND_SET, normalizeKind } from './node-kinds.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Creates an empty Concept Knowledge Graph structure
|
|
@@ -26,6 +27,7 @@ export function normalizeNode(raw) {
|
|
|
26
27
|
id,
|
|
27
28
|
title: raw.title || id,
|
|
28
29
|
level: raw.level || 'L2',
|
|
30
|
+
kind: normalizeKind(raw.kind),
|
|
29
31
|
parent: raw.parent || null,
|
|
30
32
|
children: Array.isArray(raw.children) ? raw.children : [],
|
|
31
33
|
|
|
@@ -43,6 +45,16 @@ export function normalizeNode(raw) {
|
|
|
43
45
|
boundaries: raw.boundaries || [],
|
|
44
46
|
glossary: raw.glossary || [],
|
|
45
47
|
customSections: raw.customSections || [],
|
|
48
|
+
|
|
49
|
+
// Structured argument / evidence content. These mirror the matching
|
|
50
|
+
// customSections (which still render), but as data the graph, search and
|
|
51
|
+
// validator can reason about.
|
|
52
|
+
learningObjectives: Array.isArray(raw.learningObjectives) ? raw.learningObjectives : [],
|
|
53
|
+
keyQuestions: Array.isArray(raw.keyQuestions) ? raw.keyQuestions : [],
|
|
54
|
+
evidence: Array.isArray(raw.evidence) ? raw.evidence : [],
|
|
55
|
+
invariants: Array.isArray(raw.invariants) ? raw.invariants : [],
|
|
56
|
+
failureModes: Array.isArray(raw.failureModes) ? raw.failureModes : [],
|
|
57
|
+
tradeoffs: Array.isArray(raw.tradeoffs) ? raw.tradeoffs : [],
|
|
46
58
|
};
|
|
47
59
|
}
|
|
48
60
|
|
|
@@ -58,6 +70,9 @@ export function buildGraphModel(rawGraph) {
|
|
|
58
70
|
// Register all nodes
|
|
59
71
|
rawNodes.forEach(n => {
|
|
60
72
|
const node = normalizeNode(n);
|
|
73
|
+
if (n.kind && !NODE_KIND_SET.has(n.kind)) {
|
|
74
|
+
diagnostics.push({ level: 'warning', code: 'UNKNOWN_KIND', nodeId: node.id, kind: n.kind });
|
|
75
|
+
}
|
|
61
76
|
if (nodes.has(node.id)) diagnostics.push({ level: 'error', code: 'DUPLICATE_NODE_ID', nodeId: node.id });
|
|
62
77
|
nodes.set(node.id, node);
|
|
63
78
|
});
|