concept-atlas-dense-explain 0.6.1 → 0.8.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
@@ -14,7 +14,7 @@ let buildCounter = 0;
14
14
 
15
15
  function usage() {
16
16
  console.log('Usage:');
17
- console.log(' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [-o output.html|dir] [--force] [--concurrency N] [--link-assets] [--json] [--no-validate]');
17
+ console.log(' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [-o output.html|dir] [--force] [--concurrency N] [--link-assets] [--inline-mermaid] [--mermaid-cdn <url>] [--json] [--no-validate]');
18
18
  console.log(' npx concept-atlas-dense-explain render <input.mdx>... [-o output.html|dir]');
19
19
  console.log(' npx concept-atlas-dense-explain validate <input.mdx> [--mode atlas|scroll] [--strict] [--json]');
20
20
  console.log(' npx concept-atlas-dense-explain create <output.mdx> [--mode atlas|scroll] [--force]');
@@ -22,6 +22,7 @@ function usage() {
22
22
  console.log('');
23
23
  console.log(' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.');
24
24
  console.log(' --link-assets keeps figures as relative links instead of inlining them as base64.');
25
+ console.log(' Mermaid diagrams load from a CDN at runtime by default (fast builds, needs network); --inline-mermaid bakes Mermaid into the HTML for a fully offline single file; --mermaid-cdn overrides the CDN URL.');
25
26
  console.log(` --skin bakes a default palette (${SKINS.map(skin => skin.id).join(', ')}); --default-mode bakes a default dark/light mode; --style bakes a default component style (${COMPONENT_STYLES.map(style => style.id).join(', ')}). Readers can still switch in the UI.`);
26
27
  }
27
28
 
@@ -43,7 +44,7 @@ function fail(message) {
43
44
  process.exit(1);
44
45
  }
45
46
 
46
- const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style']);
47
+ const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style', '--mermaid-cdn']);
47
48
 
48
49
  /** Splits argv into flags, flag values and positional arguments. */
49
50
  function parseFlags(argv) {
@@ -203,6 +204,8 @@ const strict = parsed.flags.has('--strict');
203
204
  const skipValidate = parsed.flags.has('--no-validate');
204
205
  const force = parsed.flags.has('--force');
205
206
  const linkAssets = parsed.flags.has('--link-assets');
207
+ const inlineMermaid = parsed.flags.has('--inline-mermaid');
208
+ const mermaidCdn = parsed.values.get('--mermaid-cdn') || null;
206
209
  const modeFlag = parsed.values.get('--mode') || null;
207
210
 
208
211
  // Compile-time appearance defaults. Invalid values fail fast with the valid
@@ -332,6 +335,10 @@ async function buildOne(job) {
332
335
  if (skinFlag) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skinFlag);
333
336
  if (defaultModeFlag) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(defaultModeFlag);
334
337
  if (styleFlag) define.__ATLAS_DEFAULT_STYLE__ = JSON.stringify(styleFlag);
338
+ if (features.mermaid) {
339
+ define.__ATLAS_MERMAID_MODE__ = JSON.stringify(inlineMermaid ? 'inline' : 'cdn');
340
+ if (mermaidCdn) define.__ATLAS_MERMAID_CDN_URL__ = JSON.stringify(mermaidCdn);
341
+ }
335
342
  try {
336
343
  await build({
337
344
  root: templateRoot,
@@ -357,9 +364,10 @@ async function buildOne(job) {
357
364
 
358
365
  /** Saves the mermaid/KaTeX payload when a document never renders them. */
359
366
  function describeFeatures(features) {
360
- if (features.math && features.mermaid) return '';
361
- const dropped = [features.math ? null : 'KaTeX', features.mermaid ? null : 'Mermaid'].filter(Boolean);
362
- return ` [no ${dropped.join('/')}]`;
367
+ const tags = [];
368
+ if (features.mermaid) tags.push(inlineMermaid ? 'mermaid inline' : 'mermaid cdn');
369
+ if (features.math) tags.push('KaTeX');
370
+ return tags.length ? ` [${tags.join(', ')}]` : '';
363
371
  }
364
372
 
365
373
  function summarizeFeatures(jobs, results) {
@@ -368,7 +376,9 @@ function summarizeFeatures(jobs, results) {
368
376
  if (!pages.length) return '';
369
377
  const droppedMath = pages.filter(job => !job.features.math).length;
370
378
  const droppedMermaid = pages.filter(job => !job.features.mermaid).length;
379
+ const cdnMermaid = pages.filter(job => job.features.mermaid && !inlineMermaid).length;
371
380
  const parts = [];
381
+ if (cdnMermaid) parts.push(`Mermaid via CDN on ${cdnMermaid}/${pages.length}`);
372
382
  if (droppedMath) parts.push(`KaTeX dropped on ${droppedMath}/${pages.length}`);
373
383
  if (droppedMermaid) parts.push(`Mermaid dropped on ${droppedMermaid}/${pages.length}`);
374
384
  return parts.join(', ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "0.6.1",
3
+ "version": "0.8.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,17 +23,17 @@ 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>]`. 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.
30
- 7. **Appearance (optional)**: pages ship with a reader-facing appearance menu — palette (`aurora` cool blue, `ember` warm gold), a dark/light toggle, and a component style pack (`manuscript` editorial marginalia, `classic` boxed cards). 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>`/`<Mermaid>` skips KaTeX (its ~1.4MB inlined fonts) and Mermaid, shrinking a typical scroll article from ~5MB to ~250KB. 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
@@ -50,19 +48,19 @@ 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`).
56
55
  - **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.
56
+ - **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.
57
+ - **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
58
  - **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
59
  - 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
60
 
63
61
  ## Validation diagnostics
64
62
 
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.
63
+ `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
64
 
67
65
  ## Before you report
68
66
 
@@ -3,12 +3,21 @@ import path from 'path';
3
3
  import fs from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { normalizeSkin, normalizeStyle } from '../src/model/skins.js';
6
- import { extractPageTitle } from '../src/model/validate-content.js';
6
+ import { extractPageTitle, detectFeatures } from '../src/model/validate-content.js';
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = path.dirname(__filename);
10
10
  const rootDir = path.resolve(__dirname, '..');
11
11
 
12
+ /**
13
+ * Mermaid is served from a CDN at runtime by default so its ~2100-module
14
+ * transform stays out of the build. Set CONCEPT_ATLAS_INLINE_MERMAID=1 to bake
15
+ * it back into the HTML for a fully offline single file; CONCEPT_ATLAS_MERMAID_CDN
16
+ * overrides the CDN URL.
17
+ */
18
+ const INLINE_MERMAID = ['1', 'true', 'yes'].includes((process.env.CONCEPT_ATLAS_INLINE_MERMAID || '').toLowerCase());
19
+ const MERMAID_CDN_URL = process.env.CONCEPT_ATLAS_MERMAID_CDN || '';
20
+
12
21
  /**
13
22
  * Optional compile-time appearance defaults, read from the environment and
14
23
  * forwarded as `define`s. The carrier HTML plugin replaces the placeholders
@@ -26,28 +35,65 @@ function appearanceDefines() {
26
35
  }
27
36
 
28
37
  /**
29
- * The tab title comes from the mounted demo document, not the carrier shell:
30
- * the repository mounts content/components-demo.mdx in index.html while the
31
- * npm template mounts content/compile-runtime.mdx, so both are probed and the
32
- * first existing file supplies its <ExplainPage>/<ScrollHeader> title.
38
+ * The demo document mounted by each carrier. Unlike the npm template (which
39
+ * aliases the user's MDX), the repository hardcodes its demos in main.jsx /
40
+ * scroll-main.jsx, so the build reads them to derive the tab title and the set
41
+ * of optional renderers actually needed.
33
42
  */
34
- function pageTitleFor(entry) {
43
+ function demoSourceFor(entry) {
35
44
  const demos = entry === 'scroll.html'
36
45
  ? ['content/scroll-reading-demo.mdx']
37
46
  : ['content/components-demo.mdx', 'content/compile-runtime.mdx'];
38
47
  const demo = demos.map(name => path.resolve(rootDir, name)).find(file => fs.existsSync(file));
39
- return demo ? extractPageTitle(fs.readFileSync(demo, 'utf8')) : null;
48
+ return demo ? { demo, source: fs.readFileSync(demo, 'utf8') } : null;
49
+ }
50
+
51
+ /**
52
+ * Builds one standalone carrier HTML. `__ATLAS_FEATURES__` mirrors the CLI's
53
+ * per-document stubbing so a demo that never uses <Math>/<Mermaid> skips the
54
+ * KaTeX fonts and Mermaid module graph instead of bundling them unconditionally.
55
+ */
56
+ async function buildCarrier(entry, baseDefine) {
57
+ const define = { ...baseDefine };
58
+ const demo = demoSourceFor(entry);
59
+ if (demo) {
60
+ const title = extractPageTitle(demo.source);
61
+ if (title) {
62
+ define.__ATLAS_PAGE_TITLE__ = JSON.stringify(title);
63
+ console.log(`🔖 ${entry} 标签页标题:${title}`);
64
+ }
65
+ const features = detectFeatures(demo.source);
66
+ define.__ATLAS_FEATURES__ = JSON.stringify(features);
67
+ if (features.mermaid) {
68
+ define.__ATLAS_MERMAID_MODE__ = JSON.stringify(INLINE_MERMAID ? 'inline' : 'cdn');
69
+ if (MERMAID_CDN_URL) define.__ATLAS_MERMAID_CDN_URL__ = JSON.stringify(MERMAID_CDN_URL);
70
+ if (!INLINE_MERMAID) console.log(`🌐 ${entry} Mermaid 运行时从 CDN 加载(--inline-mermaid 可内联)`);
71
+ }
72
+ const dropped = [features.math ? null : 'KaTeX', features.mermaid ? null : 'Mermaid'].filter(Boolean);
73
+ if (dropped.length) console.log(`⚡ ${entry} 省略未使用的渲染器:${dropped.join('、')}`);
74
+ }
75
+
76
+ await build({
77
+ root: rootDir,
78
+ define,
79
+ build: {
80
+ outDir: 'dist',
81
+ // dist is cleared once up front; parallel carriers must not wipe each
82
+ // other's output mid-build.
83
+ emptyOutDir: false,
84
+ rollupOptions: { input: path.resolve(rootDir, entry) },
85
+ }
86
+ });
40
87
  }
41
88
 
42
89
  async function runBuild() {
43
90
  console.log('🚀 开始构建 Concept Atlas 知识讲解页面...');
44
91
 
45
92
  try {
46
- // 确保 dist 目录存在
93
+ // Clear dist once, then let every carrier write into it concurrently.
47
94
  const distDir = path.resolve(rootDir, 'dist');
48
- if (!fs.existsSync(distDir)) {
49
- fs.mkdirSync(distDir, { recursive: true });
50
- }
95
+ fs.rmSync(distDir, { recursive: true, force: true });
96
+ fs.mkdirSync(distDir, { recursive: true });
51
97
 
52
98
  const mode = process.env.CONCEPT_ATLAS_MODE;
53
99
  const carriers = mode === 'atlas' ? ['index.html'] : mode === 'scroll' ? ['scroll.html'] : ['index.html', 'scroll.html'];
@@ -56,22 +102,10 @@ async function runBuild() {
56
102
  console.log(`🎨 默认外观:skin=${define.__ATLAS_DEFAULT_SKIN__ || '(carrier 默认)'} mode=${define.__ATLAS_DEFAULT_MODE__ || '(carrier 默认)'}`);
57
103
  }
58
104
 
59
- // vite-plugin-singlefile supports one HTML input per build. Build each
60
- // requested carrier separately so every output remains a standalone file.
61
- for (const [index, entry] of carriers.entries()) {
62
- const title = pageTitleFor(entry);
63
- const entryDefine = title ? { ...define, __ATLAS_PAGE_TITLE__: JSON.stringify(title) } : define;
64
- if (title) console.log(`🔖 ${entry} 标签页标题:${title}`);
65
- await build({
66
- root: rootDir,
67
- define: entryDefine,
68
- build: {
69
- outDir: 'dist',
70
- emptyOutDir: index === 0,
71
- rollupOptions: { input: path.resolve(rootDir, entry) },
72
- }
73
- });
74
- }
105
+ // vite-plugin-singlefile supports one HTML input per build and emits no
106
+ // shared assets, so the carriers are independent and build in parallel
107
+ // instead of one full pass after another.
108
+ await Promise.all(carriers.map(entry => buildCarrier(entry, define)));
75
109
 
76
110
  console.log(`✅ 构建成功!产物已生成到 ${carriers.map(entry => `dist/${entry}`).join(' 和 ')}。`);
77
111
  } catch (err) {
@@ -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
+ }
@@ -70,7 +70,7 @@ function configureMermaid() {
70
70
  lineColor: read('--mermaid-line', MERMAID_FALLBACKS.lineColor),
71
71
  secondaryColor: nodeBg,
72
72
  tertiaryColor: read('--mermaid-canvas', MERMAID_FALLBACKS.tertiaryColor),
73
- fontFamily: 'Plus Jakarta Sans, sans-serif',
73
+ fontFamily: read('--font-sans', "'Plus Jakarta Sans', sans-serif"),
74
74
  },
75
75
  });
76
76
  }
@@ -942,6 +942,7 @@ export function Chart({ title = '图表', type = 'bar', data = [], series = [],
942
942
  <g key={seriesIndex}>
943
943
  <polyline
944
944
  className="chart-line"
945
+ pathLength="1"
945
946
  style={chartColor(seriesIndex)}
946
947
  points={entry.values.map((value, idx) => `${xFor(idx)},${yFor(value)}`).join(' ')}
947
948
  />