concept-atlas-dense-explain 0.6.0 → 0.7.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
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { build } from 'vite';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { validateMdxSource, countBySeverity, detectFeatures } from '../template/src/model/validate-content.js';
7
+ import { validateMdxSource, countBySeverity, detectFeatures, extractPageTitle } from '../template/src/model/validate-content.js';
8
8
  import { SKINS, normalizeSkin, COMPONENT_STYLES, normalizeStyle } from '../template/src/model/skins.js';
9
9
 
10
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -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) {
@@ -144,6 +145,7 @@ if (command === 'create' || command === 'new') {
144
145
  }
145
146
  await mkdir(path.dirname(output), { recursive: true });
146
147
  const template = mode === 'atlas' ? `\
148
+ {/* shell 的 title 会成为浏览器标签页标题;页面图标固定为 📃。请把“主题名称”改成真实标题。 */}
147
149
  <ExplainPage id="topic-id" title="主题名称" summary="用一句话说明这个主题解决什么问题。">
148
150
  <ConceptGraph root="root-node">
149
151
  <ConceptNode id="root-node" title="核心概念" level="L0" summary="给读者建立整体认知。">
@@ -168,6 +170,7 @@ if (command === 'create' || command === 'new') {
168
170
  </ConceptGraph>
169
171
  </ExplainPage>
170
172
  ` : `\
173
+ {/* shell 的 title 会成为浏览器标签页标题;页面图标固定为 📃。请把“主题名称”改成真实标题。 */}
171
174
  <ScrollDocument>
172
175
  <ScrollHeader title="主题名称">用一两句话说明主题、背景和读者应该带走的判断。</ScrollHeader>
173
176
 
@@ -201,6 +204,8 @@ const strict = parsed.flags.has('--strict');
201
204
  const skipValidate = parsed.flags.has('--no-validate');
202
205
  const force = parsed.flags.has('--force');
203
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;
204
209
  const modeFlag = parsed.values.get('--mode') || null;
205
210
 
206
211
  // Compile-time appearance defaults. Invalid values fail fast with the valid
@@ -300,7 +305,7 @@ const jobs = inputs.map((input, index) => {
300
305
  if (linkAssets && path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))) {
301
306
  console.error(`警告:--link-assets 下 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效。`);
302
307
  }
303
- return { input, output: outputs[index], mode, features: detectFeatures(sources[index]), linkAssets };
308
+ return { input, output: outputs[index], mode, title: extractPageTitle(sources[index]), features: detectFeatures(sources[index]), linkAssets };
304
309
  });
305
310
 
306
311
  const limit = clampConcurrency(parsed.values.get('--concurrency'), jobs.length);
@@ -316,7 +321,7 @@ if (failures.length) {
316
321
  }
317
322
 
318
323
  async function buildOne(job) {
319
- const { input, output, mode, features, linkAssets: link } = job;
324
+ const { input, output, mode, title, features, linkAssets: link } = job;
320
325
  const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
321
326
  // Each build gets its own scratch outDir: the template always writes
322
327
  // `index.html`/`scroll.html`, so concurrent builds sharing a directory would
@@ -326,9 +331,14 @@ async function buildOne(job) {
326
331
  await mkdir(scratch, { recursive: true });
327
332
  const define = { __ATLAS_FEATURES__: JSON.stringify(features) };
328
333
  if (link) define.__ATLAS_INLINE_ASSETS__ = 'false';
334
+ if (title) define.__ATLAS_PAGE_TITLE__ = JSON.stringify(title);
329
335
  if (skinFlag) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skinFlag);
330
336
  if (defaultModeFlag) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(defaultModeFlag);
331
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
+ }
332
342
  try {
333
343
  await build({
334
344
  root: templateRoot,
@@ -343,7 +353,7 @@ async function buildOne(job) {
343
353
  });
344
354
  await rm(output, { force: true });
345
355
  await rename(path.join(scratch, templateEntry), output);
346
- console.log(`Built ${mode} HTML: ${output}${describeFeatures(features)}${link ? ' [figures linked]' : ''}`);
356
+ console.log(`Built ${mode} HTML: ${output}${title ? ` [tab: ${title}]` : ''}${describeFeatures(features)}${link ? ' [figures linked]' : ''}`);
347
357
  return { ok: true, input, output };
348
358
  } catch (error) {
349
359
  return { ok: false, input, output, error };
@@ -354,9 +364,10 @@ async function buildOne(job) {
354
364
 
355
365
  /** Saves the mermaid/KaTeX payload when a document never renders them. */
356
366
  function describeFeatures(features) {
357
- if (features.math && features.mermaid) return '';
358
- const dropped = [features.math ? null : 'KaTeX', features.mermaid ? null : 'Mermaid'].filter(Boolean);
359
- 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(', ')}]` : '';
360
371
  }
361
372
 
362
373
  function summarizeFeatures(jobs, results) {
@@ -365,7 +376,9 @@ function summarizeFeatures(jobs, results) {
365
376
  if (!pages.length) return '';
366
377
  const droppedMath = pages.filter(job => !job.features.math).length;
367
378
  const droppedMermaid = pages.filter(job => !job.features.mermaid).length;
379
+ const cdnMermaid = pages.filter(job => job.features.mermaid && !inlineMermaid).length;
368
380
  const parts = [];
381
+ if (cdnMermaid) parts.push(`Mermaid via CDN on ${cdnMermaid}/${pages.length}`);
369
382
  if (droppedMath) parts.push(`KaTeX dropped on ${droppedMath}/${pages.length}`);
370
383
  if (droppedMermaid) parts.push(`Mermaid dropped on ${droppedMermaid}/${pages.length}`);
371
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.0",
3
+ "version": "0.7.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
@@ -17,7 +17,7 @@ If the user only wants the prompt/methodology and not files, still choose a shel
17
17
  npx concept-atlas-dense-explain guide --mode atlas -o ./concept-atlas-atlas-guide.mdx
18
18
  npx concept-atlas-dense-explain guide --mode scroll -o ./concept-atlas-scroll-guide.mdx
19
19
  ```
20
- It is a real, compilable MDX file demonstrating every component and its exact props. Search it for a component name to copy the correct prop shape instead of guessing. Delete it when done.
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.
21
21
  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
22
  4. Write the semantic MDX into the user's `.mdx` file (see Authoring rules).
23
23
  5. Validate before rendering:
@@ -26,15 +26,16 @@ If the user only wants the prompt/methodology and not files, still choose a shel
26
26
  npx concept-atlas-dense-explain validate <file>.mdx --json
27
27
  ```
28
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). 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.
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.
32
32
  9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
33
33
 
34
34
  ## Carriers
35
35
 
36
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.
37
37
  - `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.
38
39
  - Never make one MDX file both shells. When switching shells, convert only the outer structure.
39
40
 
40
41
  ## Component families
@@ -42,8 +43,8 @@ If the user only wants the prompt/methodology and not files, still choose a shel
42
43
  - Node semantics: `Overview`, `Definition`, `Mechanism`, `Implementation`, `Boundary`, `Example`, `Counterexample`, `Prerequisite`, `Input`, `Output`, `Glossary`
43
44
  - Argument and evidence: `Evidence`, `Invariant`, `FailureMode`, `Tradeoff`, `LearningObjectives`, `KeyQuestion`
44
45
  - Information models: `Flow`, `Timeline`, `Compare`, `DecisionMatrix`, `FrameworkModel`, `MatrixModel`, `FormulaModel`, `PyramidModel`, `FunnelModel`
45
- - Reading and layout: `Insight`, `Callout`, `Details`, `NoteGrid`, `Tabs`, `Columns`, `Stack`, `Grid`, `Split`, `ScrollGrid`
46
- - Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `Figure`, `Cite`, `References`
46
+ - Reading and layout: `Insight`, `Callout`, `Details`, `NoteGrid`, `Tabs`, `Columns`, `Stack`, `Grid`, `Split`, `ScrollGrid`, `ScrollPair`, `ScrollToc`
47
+ - Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `Figure` (alias `Image`), `Cite`, `References`
47
48
 
48
49
  ## Authoring rules
49
50
 
@@ -61,7 +62,7 @@ If the user only wants the prompt/methodology and not files, still choose a shel
61
62
 
62
63
  ## Validation diagnostics
63
64
 
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_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`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `GRAPH_MISSING_ROOT`, `ASSET_MISSING`.
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.
65
66
 
66
67
  ## Before you report
67
68
 
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E📃%3C/text%3E%3C/svg%3E" />
6
7
  <title>Concept Atlas · 概念缩放式知识图谱</title>
7
8
  <script>
8
9
  (function () {
@@ -3,11 +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, detectFeatures } from '../src/model/validate-content.js';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
9
10
  const rootDir = path.resolve(__dirname, '..');
10
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
+
11
21
  /**
12
22
  * Optional compile-time appearance defaults, read from the environment and
13
23
  * forwarded as `define`s. The carrier HTML plugin replaces the placeholders
@@ -24,15 +34,66 @@ function appearanceDefines() {
24
34
  return define;
25
35
  }
26
36
 
37
+ /**
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.
42
+ */
43
+ function demoSourceFor(entry) {
44
+ const demos = entry === 'scroll.html'
45
+ ? ['content/scroll-reading-demo.mdx']
46
+ : ['content/components-demo.mdx', 'content/compile-runtime.mdx'];
47
+ const demo = demos.map(name => path.resolve(rootDir, name)).find(file => fs.existsSync(file));
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
+ });
87
+ }
88
+
27
89
  async function runBuild() {
28
90
  console.log('🚀 开始构建 Concept Atlas 知识讲解页面...');
29
91
 
30
92
  try {
31
- // 确保 dist 目录存在
93
+ // Clear dist once, then let every carrier write into it concurrently.
32
94
  const distDir = path.resolve(rootDir, 'dist');
33
- if (!fs.existsSync(distDir)) {
34
- fs.mkdirSync(distDir, { recursive: true });
35
- }
95
+ fs.rmSync(distDir, { recursive: true, force: true });
96
+ fs.mkdirSync(distDir, { recursive: true });
36
97
 
37
98
  const mode = process.env.CONCEPT_ATLAS_MODE;
38
99
  const carriers = mode === 'atlas' ? ['index.html'] : mode === 'scroll' ? ['scroll.html'] : ['index.html', 'scroll.html'];
@@ -41,19 +102,10 @@ async function runBuild() {
41
102
  console.log(`🎨 默认外观:skin=${define.__ATLAS_DEFAULT_SKIN__ || '(carrier 默认)'} mode=${define.__ATLAS_DEFAULT_MODE__ || '(carrier 默认)'}`);
42
103
  }
43
104
 
44
- // vite-plugin-singlefile supports one HTML input per build. Build each
45
- // requested carrier separately so every output remains a standalone file.
46
- for (const [index, entry] of carriers.entries()) {
47
- await build({
48
- root: rootDir,
49
- define,
50
- build: {
51
- outDir: 'dist',
52
- emptyOutDir: index === 0,
53
- rollupOptions: { input: path.resolve(rootDir, entry) },
54
- }
55
- });
56
- }
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)));
57
109
 
58
110
  console.log(`✅ 构建成功!产物已生成到 ${carriers.map(entry => `dist/${entry}`).join(' 和 ')}。`);
59
111
  } catch (err) {
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E📃%3C/text%3E%3C/svg%3E" />
6
7
  <title>Concept Atlas · 连续阅读示例</title>
7
8
  <script>
8
9
  (function () {
@@ -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
  />
@@ -13,6 +13,21 @@ export const SKINS = [
13
13
  id: 'ember',
14
14
  label: 'Ember · 炉火',
15
15
  swatch: { dark: '#1c1812', light: '#f6f1e7', accent: '#d99a4e' }
16
+ },
17
+ {
18
+ id: 'verdant',
19
+ label: 'Verdant · 苔原',
20
+ swatch: { dark: '#0f1813', light: '#f3f7f2', accent: '#10b981' }
21
+ },
22
+ {
23
+ id: 'sakura',
24
+ label: 'Sakura · 樱雾',
25
+ swatch: { dark: '#1e131d', light: '#fbf3f7', accent: '#ec4899' }
26
+ },
27
+ {
28
+ id: 'noir',
29
+ label: 'Noir · 墨白',
30
+ swatch: { dark: '#141416', light: '#f5f5f4', accent: '#e63946' }
16
31
  }
17
32
  ];
18
33
 
@@ -32,7 +47,9 @@ export function normalizeSkin(value) {
32
47
  */
33
48
  export const COMPONENT_STYLES = [
34
49
  { id: 'manuscript', label: 'Manuscript · 评注手稿' },
35
- { id: 'classic', label: 'Classic · 经典卡片' }
50
+ { id: 'classic', label: 'Classic · 经典卡片' },
51
+ { id: 'shadcn', label: 'shadcn · 极简界面' },
52
+ { id: 'elastic', label: 'Elastic · 观测面板' }
36
53
  ];
37
54
 
38
55
  export const DEFAULT_STYLE = 'manuscript';
@@ -598,3 +598,28 @@ export function detectFeatures(source) {
598
598
  mermaid: used.has('Mermaid'),
599
599
  };
600
600
  }
601
+
602
+ /** JSX string literals decode these five entities; a single pass avoids
603
+ * double-decoding sequences like `&amp;lt;`. */
604
+ const ENTITY_MAP = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" };
605
+ const decodeEntities = text => text.replace(/&(amp|lt|gt|quot|#39);/g, (_, entity) => ENTITY_MAP[entity]);
606
+
607
+ /**
608
+ * Reads the browser-tab title from the MDX source without rendering: the atlas
609
+ * shell declares it on <ExplainPage title="...">, the scroll shell on
610
+ * <ScrollHeader title="...">. Only quoted string props count — a `{...}`
611
+ * expression title cannot be known at build time. Returns null when no static
612
+ * title exists, so the build keeps the carrier's default <title>.
613
+ */
614
+ export function extractPageTitle(source) {
615
+ const tags = tokenize(maskIgnored(source));
616
+ for (const shell of ['ExplainPage', 'ScrollHeader']) {
617
+ const tag = tags.find(item => item.name === shell && item.kind !== 'close');
618
+ if (!tag) continue;
619
+ const attr = attrsToMap(tag.attrs).title;
620
+ if (attr && attr.hasValue && attr.quoted && attr.value.trim()) {
621
+ return decodeEntities(attr.value).trim();
622
+ }
623
+ }
624
+ return null;
625
+ }