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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Portable dense-explanation skill and MDX concept atlas template",
5
5
  "type": "module",
6
6
  "bin": {
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. Use the concept-atlas-dense-explain npm CLI to generate AI-editable MDX, 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.
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 and do not recreate the React/Vite app. If a command name is unclear, run `npx concept-atlas-dense-explain help`.
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 needs to drill into concepts or see relations; recommend `scroll` for linear argument, tutorials, and reports. The component library is shared. If the user already has an `.mdx` file, detect its shell and work with it.
15
- 2. **Learn the components from the canonical guide before authoring.** Generate the guide for the chosen shell into a temporary path and read it:
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 a real, compilable MDX file that demonstrates the components for that shell and their exact props. Search it for a component name to copy the correct prop shape instead of guessing. Delete it when done.
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
- Every diagnostic is `CODE line:column message`. Fix all `error`s and re-run; warnings are quality signals you should also address when cheap.
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; use `--no-validate` only to force a knowingly broken build. Mermaid loads from a CDN at runtime by default (fast build, small HTML, needs network); pass `--inline-mermaid` when the user needs a fully offline single file.
30
- 7. **Appearance (optional)**: pages ship with a reader-facing appearance menu — palette (`aurora` cool indigo, `ember` warm gold, `verdant` forest green, `sakura` pink-plum, `noir` achromatic 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. You can bake different compile-time defaults: `--skin ember --default-mode dark --style classic` (or env `CONCEPT_ATLAS_SKIN` / `CONCEPT_ATLAS_DEFAULT_MODE` / `CONCEPT_ATLAS_STYLE` on the repo build). `--default-mode` only honors `dark`/`light`; `system` is accepted by the CLI but resolves to the carrier default (`light`). Bake a default only when the user asks for one. Content MDX never sets appearance — it is carrier/tooling territory, not content.
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]`. `-o` is then a directory. The batch validates everything first and builds in parallel. Builds only bundle the heavy renderers the content uses: a page with no `<Math>` skips KaTeX (its ~1.4MB inlined fonts), and Mermaid is served from a CDN by default rather than bundled. Do not add dummy `<Math>`/`<Mermaid>` nodes to "enable" them — write the components only when the content needs them. Add `--link-assets` when the page carries many screenshots and size matters.
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="..."` (atlas) or `ScrollHeader title="..."` (scroll) becomes the `<title>`, so give it a real, specific document name — never leave a placeholder like "主题名称". The favicon is a fixed 📃 document emoji on every generated page.
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 produces an `ASSET_MISSING` warning and a placeholder. Readers can click any figure to open it full-screen (wheel/`+`/`−` zoom, drag to pan, double-click for 1x/2x, `Esc` to close) — mention this when a page carries dense diagrams.
58
- - **Figure size**: inlining is what makes a screenshot-heavy page large. When a document carries many images and the user cares about size, compile with `--link-assets` to keep them as relative links (measured: 1.51MB → 270KB on one page). The output then has to live beside the MDX's `assets/` directory, and the CLI warns if `-o` points elsewhere — tell the user that trade-off instead of choosing silently.
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 `MULTIPLE_ROOT_LEVEL` are warnings that `--strict` promotes to errors.
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="扩展能力:公式、图表、图片与引用" level="L1" parent="showroom" summary="数学公式、数据图表、配图题注和参考文献四项能力,让讲解可以带上推导、数据与出处。">
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:'事实、数据与案例'}]} />
@@ -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 [currentNodeId, setCurrentNodeId] = useState(() => {
32
- const hashNode = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
33
- return hashNode && graph.nodes.has(hashNode) ? hashNode : (graph.meta.rootId || '');
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 [history, setHistory] = useState(() => {
37
- const initial = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
38
- return initial && graph.nodes.has(initial) ? [initial] : [graph.meta.rootId].filter(Boolean);
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
- const query = globalQuery.trim().toLowerCase();
46
- if (!query) return [];
47
- return Array.from(graph.nodes.values()).map(node => {
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
- setHistory(previous => {
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 nextIndex = Math.max(0, Math.min(history.length - 1, historyIndex + direction));
76
- if (nextIndex === historyIndex) return;
77
- setHistoryIndex(nextIndex);
78
- const nodeId = history[nextIndex];
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
- // Toggle views with 1 and 2 or 'g' and 'e' if not focused on input
87
- if (e.isComposing || e.metaKey || e.ctrlKey || e.altKey || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable) {
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
- // Alt + arrows move through browsing history.
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, historyIndex, history]);
119
+ }, [graph, currentNodeId, nav]);
120
120
 
121
121
  useEffect(() => {
122
122
  const handlePopState = () => {
123
- const nodeId = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('node');
123
+ const nodeId = readNodeFromHash();
124
124
  if (!nodeId || !graph.nodes.has(nodeId)) return;
125
125
  setCurrentNodeId(nodeId);
126
- setHistory(previous => {
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
  });