concept-atlas-dense-explain 1.2.0 → 3.0.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/README.md CHANGED
@@ -26,23 +26,26 @@ npx concept-atlas-dense-explain validate topic.mdx --mode atlas
26
26
  npx concept-atlas-dense-explain topic.mdx --mode atlas
27
27
  ```
28
28
 
29
- `guide` and `create` refuse to overwrite an existing file unless `--force` is
30
- passed. Use `-o` to choose the output path (a directory when passing several
31
- inputs). Validation errors abort the build; `--no-validate` forces a knowingly
29
+ Existing outputs are overwritten; `--force` is accepted for compatibility and is
30
+ a no-op. `--mode` defaults to `scroll`. Use `-o` to choose the output path (a
31
+ directory when passing several inputs). Validation errors abort the build; `--no-validate` forces a knowingly
32
32
  broken build.
33
33
 
34
34
  Mermaid loads from a CDN at runtime by default (fast builds, needs network);
35
35
  `--inline-mermaid` bakes it into the HTML for a fully offline single file, and
36
36
  `--mermaid-cdn` overrides the CDN URL. Appearance defaults can be baked with
37
37
  `--skin`, `--default-mode` and `--style`; readers can still switch in the UI.
38
+ Local figures link by default (small HTML, ship `assets/` beside the output);
39
+ `--inline-assets` bakes them in as base64 for a self-contained file, and a
40
+ per-figure `inline={true|false}` overrides that choice.
38
41
 
39
42
  Run `npx concept-atlas-dense-explain help` for the full flag list.
40
43
 
41
44
  ## Links
42
45
 
43
46
  - Repository: https://github.com/wurenrumian/concept-atlas
44
- - Framework guide: [`docs/FRAMEWORK.md`](https://github.com/wurenrumian/concept-atlas/blob/main/docs/FRAMEWORK.md)
45
- - Usage recipes: [`docs/USAGE.md`](https://github.com/wurenrumian/concept-atlas/blob/main/docs/USAGE.md)
47
+ - Framework guide: [`docs/FRAMEWORK.md`](https://github.com/wurenrumian/concept-atlas/blob/master/docs/FRAMEWORK.md)
48
+ - Usage recipes: [`docs/USAGE.md`](https://github.com/wurenrumian/concept-atlas/blob/master/docs/USAGE.md)
46
49
 
47
50
  ## License
48
51
 
package/bin/cli.mjs CHANGED
@@ -1,11 +1,31 @@
1
1
  #!/usr/bin/env node
2
- import { access, constants, copyFile, cp, mkdir, readFile, rm, rename, writeFile } from 'node:fs/promises';
3
- import { existsSync } from 'node:fs';
2
+ import {
3
+ access,
4
+ constants,
5
+ copyFile,
6
+ cp,
7
+ mkdir,
8
+ readFile,
9
+ rm,
10
+ rename,
11
+ writeFile
12
+ } from 'node:fs/promises';
13
+ import { existsSync, statSync } from 'node:fs';
4
14
  import path from 'node:path';
5
15
  import { build } from 'vite';
6
16
  import { fileURLToPath } from 'node:url';
7
- import { validateMdxSource, countBySeverity, detectFeatures, extractPageTitle } from '../template/src/model/validate-content.js';
8
- import { SKINS, normalizeSkin, COMPONENT_STYLES, normalizeStyle } from '../template/src/model/skins.js';
17
+ import {
18
+ validateMdxSource,
19
+ countBySeverity,
20
+ detectFeatures,
21
+ extractPageTitle
22
+ } from '../template/src/model/validate-content.js';
23
+ import {
24
+ SKINS,
25
+ normalizeSkin,
26
+ COMPONENT_STYLES,
27
+ normalizeStyle
28
+ } from '../template/src/model/skins.js';
9
29
 
10
30
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
31
  const templateRoot = path.join(packageRoot, 'template');
@@ -14,21 +34,50 @@ let buildCounter = 0;
14
34
 
15
35
  function usage() {
16
36
  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] [--inline-mermaid] [--mermaid-cdn <url>] [--json] [--no-validate]');
37
+ console.log(
38
+ ' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [-o output.html|dir] [--concurrency N] [--inline-assets] [--inline-mermaid] [--mermaid-cdn <url>] [--json] [--no-validate]'
39
+ );
18
40
  console.log(' npx concept-atlas-dense-explain render <input.mdx>... [-o output.html|dir]');
19
- console.log(' npx concept-atlas-dense-explain validate <input.mdx> [--mode atlas|scroll] [--strict] [--json]');
20
- console.log(' npx concept-atlas-dense-explain create <output.mdx> [--mode atlas|scroll] [--force]');
21
- console.log(' npx concept-atlas-dense-explain guide [--mode atlas|scroll] [-o output.mdx] [--force]');
41
+ console.log(
42
+ ' npx concept-atlas-dense-explain validate <input.mdx> [--mode atlas|scroll] [--strict] [--json]'
43
+ );
44
+ console.log(' npx concept-atlas-dense-explain create <output.mdx> [--mode atlas|scroll]');
45
+ console.log(' npx concept-atlas-dense-explain guide [--mode atlas|scroll] [-o output.mdx]');
22
46
  console.log('');
47
+ console.log(
48
+ ' --mode defaults to scroll. Existing outputs are overwritten; --force is accepted for compatibility and is a no-op.'
49
+ );
23
50
  console.log(' --help shows this text; --version prints the package version.');
24
- console.log(' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.');
25
- console.log(' --link-assets keeps figures as relative links instead of inlining them as base64.');
26
- 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.');
27
- 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.`);
51
+ console.log(
52
+ ' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.'
53
+ );
54
+ console.log(
55
+ ' Figures are kept as relative links by default (small HTML; ship the assets/ dir beside it). --inline-assets bakes every local image into the HTML as base64 instead; a per-tag inline={true|false} on <Figure> overrides that for one image. --link-assets is kept as an explicit alias for the default.'
56
+ );
57
+ console.log(
58
+ ' 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.'
59
+ );
60
+ console.log(
61
+ ` --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.`
62
+ );
28
63
  }
29
64
 
30
65
  async function exists(filePath) {
31
- try { await access(filePath, constants.F_OK); return true; } catch { return false; }
66
+ try {
67
+ await access(filePath, constants.F_OK);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Byte size of an asset, or null when it does not exist. */
75
+ function assetByteSize(filePath) {
76
+ try {
77
+ return statSync(filePath).size;
78
+ } catch {
79
+ return null;
80
+ }
32
81
  }
33
82
 
34
83
  function flagValue(flags, names) {
@@ -45,8 +94,29 @@ function fail(message) {
45
94
  process.exit(1);
46
95
  }
47
96
 
48
- const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style', '--mermaid-cdn']);
49
- const BOOLEAN_FLAGS = new Set(['--force', '--json', '--strict', '--no-validate', '--link-assets', '--inline-mermaid', '--help', '-h', '--version', '-v']);
97
+ const VALUE_FLAGS = new Set([
98
+ '--mode',
99
+ '-o',
100
+ '--output',
101
+ '--concurrency',
102
+ '--skin',
103
+ '--default-mode',
104
+ '--style',
105
+ '--mermaid-cdn'
106
+ ]);
107
+ const BOOLEAN_FLAGS = new Set([
108
+ '--force',
109
+ '--json',
110
+ '--strict',
111
+ '--no-validate',
112
+ '--link-assets',
113
+ '--inline-assets',
114
+ '--inline-mermaid',
115
+ '--help',
116
+ '-h',
117
+ '--version',
118
+ '-v'
119
+ ]);
50
120
  const COMMAND_NAMES = ['help', 'create', 'new', 'render', 'validate', 'guide'];
51
121
 
52
122
  /** Splits argv into flags, flag values and positional arguments. */
@@ -77,7 +147,10 @@ function extractCommand(argv) {
77
147
  const rest = [...argv];
78
148
  for (let i = 0; i < rest.length; i += 1) {
79
149
  const arg = rest[i];
80
- if (VALUE_FLAGS.has(arg)) { i += 1; continue; }
150
+ if (VALUE_FLAGS.has(arg)) {
151
+ i += 1;
152
+ continue;
153
+ }
81
154
  if (arg.startsWith('-') && arg.length > 1) continue;
82
155
  if (COMMAND_NAMES.includes(arg)) {
83
156
  rest.splice(i, 1);
@@ -100,7 +173,9 @@ function resolveOutputs(inputs, explicit) {
100
173
  if (path.extname(target).toLowerCase() === '.html') {
101
174
  fail('`-o` must be a directory when building more than one input.');
102
175
  }
103
- return inputs.map(input => path.join(target, `${path.basename(input, path.extname(input))}.html`));
176
+ return inputs.map(input =>
177
+ path.join(target, `${path.basename(input, path.extname(input))}.html`)
178
+ );
104
179
  }
105
180
 
106
181
  function printDiagnostics(source, options, { json, label = null, quiet = false }) {
@@ -118,7 +193,9 @@ function printDiagnostics(source, options, { json, label = null, quiet = false }
118
193
  console.error(`${severity} ${where} ${item.code} ${item.message}`);
119
194
  }
120
195
  const { error, warning } = countBySeverity(diagnostics);
121
- const scope = carrier ? `${carrier} · ${stats.nodes} 节点 / ${stats.relations} 关系` : '未识别载体';
196
+ const scope = carrier
197
+ ? `${carrier} · ${stats.nodes} 节点 / ${stats.relations} 关系`
198
+ : '未识别载体';
122
199
  if (error) console.error(`校验失败:${error} 个错误,${warning} 个警告(${scope})`);
123
200
  else if (warning) console.error(`校验通过:${warning} 个警告(${scope})`);
124
201
  else console.error(`校验通过:无问题(${scope})`);
@@ -128,7 +205,10 @@ function printDiagnostics(source, options, { json, label = null, quiet = false }
128
205
  const { command, rest } = extractCommand(args);
129
206
  args = rest;
130
207
 
131
- if (args.includes('--help') || args.includes('-h')) { usage(); process.exit(0); }
208
+ if (args.includes('--help') || args.includes('-h')) {
209
+ usage();
210
+ process.exit(0);
211
+ }
132
212
  if (args.includes('--version') || args.includes('-v')) {
133
213
  const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
134
214
  console.log(pkg.version);
@@ -150,16 +230,17 @@ for (let i = 0; i < args.length; i += 1) {
150
230
  }
151
231
  }
152
232
 
153
- if (command === 'help') { usage(); process.exit(0); }
233
+ if (command === 'help') {
234
+ usage();
235
+ process.exit(0);
236
+ }
154
237
 
155
238
  if (command === 'guide') {
156
- const mode = flagValue(args, ['--mode']) || 'atlas';
239
+ const mode = flagValue(args, ['--mode']) || 'scroll';
157
240
  if (!['atlas', 'scroll'].includes(mode)) fail(`Unknown mode: ${mode}`);
158
- const output = path.resolve(flagValue(args, ['-o', '--output']) || `concept-atlas-${mode}-guide.mdx`);
159
- if (await exists(output) && !args.includes('--force')) {
160
- console.error(`Refusing to overwrite ${output}; pass --force to replace it.`);
161
- process.exit(1);
162
- }
241
+ const output = path.resolve(
242
+ flagValue(args, ['-o', '--output']) || `concept-atlas-${mode}-guide.mdx`
243
+ );
163
244
  const source = path.join(templateRoot, 'references', `${mode}-guide.mdx`);
164
245
  if (!(await exists(source))) {
165
246
  console.error(`Guide for mode "${mode}" is missing from the package.`);
@@ -169,7 +250,7 @@ if (command === 'guide') {
169
250
  await copyFile(source, output);
170
251
  const assetsSource = path.join(templateRoot, 'references', 'assets');
171
252
  const assetsTarget = path.join(path.dirname(output), 'assets');
172
- if (await exists(assetsSource) && path.resolve(assetsSource) !== path.resolve(assetsTarget) && (args.includes('--force') || !(await exists(assetsTarget)))) {
253
+ if ((await exists(assetsSource)) && path.resolve(assetsSource) !== path.resolve(assetsTarget)) {
173
254
  await cp(assetsSource, assetsTarget, { recursive: true, force: true });
174
255
  console.log(`Copied guide assets to ${assetsTarget}`);
175
256
  }
@@ -180,17 +261,19 @@ if (command === 'guide') {
180
261
 
181
262
  if (command === 'create' || command === 'new') {
182
263
  const output = args[0] ? path.resolve(args[0]) : null;
183
- const mode = flagValue(args, ['--mode']) || 'atlas';
184
- if (!output || path.extname(output).toLowerCase() !== '.mdx' || !['atlas', 'scroll'].includes(mode)) {
264
+ const mode = flagValue(args, ['--mode']) || 'scroll';
265
+ if (
266
+ !output ||
267
+ path.extname(output).toLowerCase() !== '.mdx' ||
268
+ !['atlas', 'scroll'].includes(mode)
269
+ ) {
185
270
  usage();
186
271
  process.exit(1);
187
272
  }
188
- if (await exists(output) && !args.includes('--force')) {
189
- console.error(`Refusing to overwrite ${output}; pass --force to replace it.`);
190
- process.exit(1);
191
- }
192
273
  await mkdir(path.dirname(output), { recursive: true });
193
- const template = mode === 'atlas' ? `\
274
+ const template =
275
+ mode === 'atlas'
276
+ ? `\
194
277
  {/* shell 的 title 会成为浏览器标签页标题;页面图标固定为 📃。请把“主题名称”改成真实标题。 */}
195
278
  <ExplainPage id="topic-id" title="主题名称" summary="用一句话说明这个主题解决什么问题。">
196
279
  <ConceptGraph root="root-node">
@@ -216,7 +299,8 @@ if (command === 'create' || command === 'new') {
216
299
  <Relation from="first-branch" to="second-branch" type="depends-on" label="依赖" />
217
300
  </ConceptGraph>
218
301
  </ExplainPage>
219
- ` : `\
302
+ `
303
+ : `\
220
304
  {/* shell 的 title 会成为浏览器标签页标题;页面图标固定为 📃。请把“主题名称”改成真实标题。 */}
221
305
  <ScrollDocument>
222
306
  <ScrollHeader title="主题名称">用一两句话说明主题、背景和读者应该带走的判断。</ScrollHeader>
@@ -242,7 +326,9 @@ if (command === 'create' || command === 'new') {
242
326
  `;
243
327
  await writeFile(output, template, 'utf8');
244
328
  console.log(`Created ${mode} MDX template: ${output}`);
245
- console.log(`Tip: run "npx concept-atlas-dense-explain guide --mode ${mode}" for a full component reference.`);
329
+ console.log(
330
+ `Tip: run "npx concept-atlas-dense-explain guide --mode ${mode}" for a full component reference.`
331
+ );
246
332
  process.exit(0);
247
333
  }
248
334
 
@@ -250,8 +336,11 @@ const parsed = parseFlags(args);
250
336
  const json = parsed.flags.has('--json');
251
337
  const strict = parsed.flags.has('--strict');
252
338
  const skipValidate = parsed.flags.has('--no-validate');
253
- const force = parsed.flags.has('--force');
254
339
  const linkAssets = parsed.flags.has('--link-assets');
340
+ const inlineAssets = parsed.flags.has('--inline-assets');
341
+ if (linkAssets && inlineAssets) {
342
+ fail('--link-assets and --inline-assets are mutually exclusive (linking is the default).');
343
+ }
255
344
  const inlineMermaid = parsed.flags.has('--inline-mermaid');
256
345
  const mermaidCdn = parsed.values.get('--mermaid-cdn') || null;
257
346
  const modeFlag = parsed.values.get('--mode') || null;
@@ -261,18 +350,25 @@ const modeFlag = parsed.values.get('--mode') || null;
261
350
  let skinFlag = null;
262
351
  if (parsed.values.has('--skin')) {
263
352
  skinFlag = normalizeSkin(parsed.values.get('--skin'));
264
- if (!skinFlag) fail(`Unknown skin: ${parsed.values.get('--skin')} (available: ${SKINS.map(skin => skin.id).join(', ')})`);
353
+ if (!skinFlag)
354
+ fail(
355
+ `Unknown skin: ${parsed.values.get('--skin')} (available: ${SKINS.map(skin => skin.id).join(', ')})`
356
+ );
265
357
  }
266
358
  let defaultModeFlag = null;
267
359
  if (parsed.values.has('--default-mode')) {
268
360
  const raw = parsed.values.get('--default-mode');
269
- if (!['dark', 'light', 'system'].includes(raw)) fail(`Invalid --default-mode: ${raw} (use dark, light or system)`);
361
+ if (!['dark', 'light', 'system'].includes(raw))
362
+ fail(`Invalid --default-mode: ${raw} (use dark, light or system)`);
270
363
  defaultModeFlag = raw;
271
364
  }
272
365
  let styleFlag = null;
273
366
  if (parsed.values.has('--style')) {
274
367
  styleFlag = normalizeStyle(parsed.values.get('--style'));
275
- if (!styleFlag) fail(`Unknown component style: ${parsed.values.get('--style')} (available: ${COMPONENT_STYLES.map(style => style.id).join(', ')})`);
368
+ if (!styleFlag)
369
+ fail(
370
+ `Unknown component style: ${parsed.values.get('--style')} (available: ${COMPONENT_STYLES.map(style => style.id).join(', ')})`
371
+ );
276
372
  }
277
373
 
278
374
  if (command === 'validate') {
@@ -281,19 +377,26 @@ if (command === 'validate') {
281
377
  fail('Provide an existing .mdx file to validate.');
282
378
  }
283
379
  const source = await readFile(target, 'utf8');
284
- const result = printDiagnostics(source, {
285
- filePath: target,
286
- mode: modeFlag,
287
- strict,
288
- assetExists: spec => existsSync(path.resolve(path.dirname(target), spec)),
289
- }, { json });
380
+ const result = printDiagnostics(
381
+ source,
382
+ {
383
+ filePath: target,
384
+ mode: modeFlag,
385
+ strict,
386
+ inlineAssets,
387
+ assetExists: spec => existsSync(path.resolve(path.dirname(target), spec)),
388
+ assetSize: spec => assetByteSize(path.resolve(path.dirname(target), spec))
389
+ },
390
+ { json }
391
+ );
290
392
  process.exit(countBySeverity(result.diagnostics).error ? 1 : 0);
291
393
  }
292
394
 
293
395
  // `render` is the default command, so it may still appear as a leading token.
294
- const positional = parsed.positional[0] && parsed.positional[0].toLowerCase() === 'render'
295
- ? parsed.positional.slice(1)
296
- : parsed.positional;
396
+ const positional =
397
+ parsed.positional[0] && parsed.positional[0].toLowerCase() === 'render'
398
+ ? parsed.positional.slice(1)
399
+ : parsed.positional;
297
400
  const inputs = positional.map(entry => path.resolve(entry));
298
401
 
299
402
  if (!inputs.length) {
@@ -310,26 +413,25 @@ for (const input of inputs) {
310
413
 
311
414
  const outputs = resolveOutputs(inputs, parsed.values.get('-o') || parsed.values.get('--output'));
312
415
 
313
- for (const output of outputs) {
314
- if (await exists(output) && !force) {
315
- console.error(`Refusing to overwrite ${output}; pass --force to replace it.`);
316
- process.exit(1);
317
- }
318
- }
319
-
320
416
  const multi = inputs.length > 1;
321
417
  const sources = await Promise.all(inputs.map(input => readFile(input, 'utf8')));
322
418
 
323
419
  // Validate every document before building any of them: a batch should fail as a
324
420
  // batch rather than leaving half the targets rendered.
325
- const validations = sources.map((source, index) => printDiagnostics(source, {
326
- filePath: inputs[index],
327
- mode: modeFlag,
328
- strict,
329
- assetExists: spec => existsSync(path.resolve(path.dirname(inputs[index]), spec)),
330
- }, json
331
- ? { json: false, quiet: true }
332
- : { json: false, label: multi ? inputs[index] : null }));
421
+ const validations = sources.map((source, index) =>
422
+ printDiagnostics(
423
+ source,
424
+ {
425
+ filePath: inputs[index],
426
+ mode: modeFlag,
427
+ strict,
428
+ inlineAssets,
429
+ assetExists: spec => existsSync(path.resolve(path.dirname(inputs[index]), spec)),
430
+ assetSize: spec => assetByteSize(path.resolve(path.dirname(inputs[index]), spec))
431
+ },
432
+ json ? { json: false, quiet: true } : { json: false, label: multi ? inputs[index] : null }
433
+ )
434
+ );
333
435
 
334
436
  if (json) {
335
437
  const payload = multi
@@ -338,7 +440,10 @@ if (json) {
338
440
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
339
441
  }
340
442
 
341
- const errorCount = validations.reduce((sum, result) => sum + countBySeverity(result.diagnostics).error, 0);
443
+ const errorCount = validations.reduce(
444
+ (sum, result) => sum + countBySeverity(result.diagnostics).error,
445
+ 0
446
+ );
342
447
  if (errorCount && !skipValidate) {
343
448
  console.error('内容校验未通过,已停止构建。修复后重试,或用 --no-validate 强制构建。');
344
449
  process.exit(1);
@@ -347,38 +452,65 @@ if (errorCount && !skipValidate) {
347
452
  const jobs = inputs.map((input, index) => {
348
453
  const mode = modeFlag || validations[index].carrier;
349
454
  if (!mode || !['atlas', 'scroll'].includes(mode)) {
350
- console.error(`Could not detect the MDX carrier for ${input}; choose --mode atlas or --mode scroll.`);
455
+ console.error(
456
+ `Could not detect the MDX carrier for ${input}; choose --mode atlas or --mode scroll.`
457
+ );
351
458
  process.exit(1);
352
459
  }
353
- if (linkAssets && path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))) {
354
- console.error(`警告:--link-assets 下 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效。`);
460
+ // Figures link by default. If the HTML is written outside the MDX's directory
461
+ // its relative image paths no longer resolve, so warn unless --inline-assets
462
+ // bakes them in.
463
+ if (
464
+ !inlineAssets &&
465
+ path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))
466
+ ) {
467
+ console.error(
468
+ `警告:默认外链图片,但 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效;请用 --inline-assets,或把 assets/ 一并放到输出目录。`
469
+ );
355
470
  }
356
- return { input, output: outputs[index], mode, title: extractPageTitle(sources[index]), features: detectFeatures(sources[index]), linkAssets };
471
+ return {
472
+ input,
473
+ output: outputs[index],
474
+ mode,
475
+ title: extractPageTitle(sources[index]),
476
+ features: detectFeatures(sources[index]),
477
+ inlineAssets
478
+ };
357
479
  });
358
480
 
359
481
  const limit = clampConcurrency(parsed.values.get('--concurrency'), jobs.length);
360
482
  const started = Date.now();
361
- const results = await runPool(jobs.map(job => () => buildOne(job)), limit);
483
+ const results = await runPool(
484
+ jobs.map(job => () => buildOne(job)),
485
+ limit
486
+ );
362
487
 
363
488
  const failures = results.filter(result => !result.ok);
364
489
  const saved = summarizeFeatures(jobs, results);
365
- console.log(`Built ${results.length - failures.length}/${results.length} page(s) with concurrency ${limit} in ${((Date.now() - started) / 1000).toFixed(1)}s${saved ? ` (${saved})` : ''}.`);
490
+ console.log(
491
+ `Built ${results.length - failures.length}/${results.length} page(s) with concurrency ${limit} in ${((Date.now() - started) / 1000).toFixed(1)}s${saved ? ` (${saved})` : ''}.`
492
+ );
366
493
  if (failures.length) {
367
- for (const failure of failures) console.error(`Build failed for ${failure.input}:`, failure.error);
494
+ for (const failure of failures)
495
+ console.error(`Build failed for ${failure.input}:`, failure.error);
368
496
  process.exit(1);
369
497
  }
370
498
 
371
499
  async function buildOne(job) {
372
- const { input, output, mode, title, features, linkAssets: link } = job;
500
+ const { input, output, mode, title, features, inlineAssets: inline } = job;
373
501
  const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
374
502
  // Each build gets its own scratch outDir: the template always writes
375
503
  // `index.html`/`scroll.html`, so concurrent builds sharing a directory would
376
504
  // overwrite each other before the rename.
377
- const scratch = path.join(path.dirname(output), `.concept-atlas-${process.pid}-${(buildCounter += 1)}`);
505
+ const scratch = path.join(
506
+ path.dirname(output),
507
+ `.concept-atlas-${process.pid}-${(buildCounter += 1)}`
508
+ );
378
509
  await mkdir(path.dirname(output), { recursive: true });
379
510
  await mkdir(scratch, { recursive: true });
380
511
  const define = { __ATLAS_FEATURES__: JSON.stringify(features) };
381
- if (link) define.__ATLAS_INLINE_ASSETS__ = 'false';
512
+ // 'false' is the default (link); only --inline-assets sets 'true'.
513
+ define.__ATLAS_INLINE_ASSETS__ = inline ? 'true' : 'false';
382
514
  if (title) define.__ATLAS_PAGE_TITLE__ = JSON.stringify(title);
383
515
  if (skinFlag) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skinFlag);
384
516
  if (defaultModeFlag) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(defaultModeFlag);
@@ -396,8 +528,8 @@ async function buildOne(job) {
396
528
  build: {
397
529
  outDir: scratch,
398
530
  emptyOutDir: false,
399
- rollupOptions: { input: path.join(templateRoot, templateEntry) },
400
- },
531
+ rollupOptions: { input: path.join(templateRoot, templateEntry) }
532
+ }
401
533
  });
402
534
  // Swap the new build in without a window where no output exists: on
403
535
  // POSIX rename replaces atomically; the fallback path (Windows can refuse
@@ -424,7 +556,9 @@ async function buildOne(job) {
424
556
  }
425
557
  if (hasBackup) await rm(backup, { force: true });
426
558
  }
427
- console.log(`Built ${mode} HTML: ${output}${title ? ` [tab: ${title}]` : ''}${describeFeatures(features)}${link ? ' [figures linked]' : ''}`);
559
+ console.log(
560
+ `Built ${mode} HTML: ${output}${title ? ` [tab: ${title}]` : ''}${describeFeatures(features)}${inline ? ' [figures inlined]' : ' [figures linked]'}`
561
+ );
428
562
  return { ok: true, input, output };
429
563
  } catch (error) {
430
564
  return { ok: false, input, output, error };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "1.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Portable dense-explanation skill and MDX concept atlas template",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@ This is the only runner — there is nothing to look for. Do not search for a lo
24
24
  - `references/atlas-guide.mdx` (atlas) or `references/scroll-guide.mdx` (scroll)
25
25
  It is real, compilable MDX showing that shell's components and their exact props; search it for a component name instead of guessing, and do not look for component documentation anywhere else. Do not run `guide` for this.
26
26
  Run `npx concept-atlas-dense-explain guide --mode <mode> -o <file>` only when you specifically need a project-local copy to compile beside the page, and delete that copy when done.
27
- 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.
27
+ 3. Start from a skeleton when useful: `npx concept-atlas-dense-explain create <file>.mdx --mode atlas|scroll` (`--mode` defaults to `scroll`). `create` and `guide` overwrite their output, so never point them at a file you want to keep.
28
28
  4. Write the semantic MDX into the user's `.mdx` file (see Authoring rules).
29
29
  5. Validate before rendering:
30
30
  ```bash
@@ -32,9 +32,9 @@ This is the only runner — there is nothing to look for. Do not search for a lo
32
32
  npx concept-atlas-dense-explain validate <file>.mdx --json
33
33
  ```
34
34
  Diagnostics are `CODE line:column message`. Fix all `error`s and re-run; address warnings when cheap.
35
- 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.
35
+ 6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--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 stays on its runtime CDN by default — do not pass `--inline-mermaid` on your own.** Only add `--inline-mermaid` when the user explicitly asks for a fully offline single file, since it bloats the HTML with the whole Mermaid bundle.
36
36
  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.
37
- 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.
37
+ 8. For several documents, pass them all in one call: `npx concept-atlas-dense-explain a.mdx b.mdx c.mdx -o dist [--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.
38
38
  9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
39
39
 
40
40
  ## Carriers
@@ -47,12 +47,12 @@ This is the only runner — there is nothing to look for. Do not search for a lo
47
47
  ## Component families
48
48
 
49
49
  - Node semantics: `Overview`, `Definition`, `Mechanism`, `Implementation`, `CodeBlock`, `Boundary`, `Example`, `Counterexample`, `Prerequisite`, `Input`, `Output`, `Glossary`
50
- - Argument and evidence: `Evidence`, `Invariant`, `FailureMode`, `Tradeoff`, `LearningObjectives`, `KeyQuestion`
51
- - Learning loop and provenance: `WorkedExample` + `Step`, `Quiz`, `KeyTakeaways`, `Source`, `Confidence`, `Term`
50
+ - Argument and evidence: `Evidence`, `Invariant`, `FailureMode`, `Tradeoff`, `Checklist`, `LearningObjectives`, `KeyQuestion`
51
+ - Learning loop and provenance: `WorkedExample` + `Step`, `Quiz`, `KeyTakeaways`, `Source`, `Confidence`, `LastReviewed`, `Term`
52
52
  - Information models: `Flow`, `Timeline`, `Compare`, `DecisionMatrix`, `FrameworkModel`, `MatrixModel`, `FormulaModel`, `PyramidModel`, `FunnelModel`
53
- - Data and behaviour: `DataTable`, `Metric`, `StateMachine`, `DecisionTree`, `FeedbackLoop`, `CodeDiff`
54
- - Reading and layout: `Insight`, `Callout`, `Details`, `NoteGrid`, `Tabs`, `Columns`, `Stack`, `Grid`, `Split`, `ScrollGrid`, `ScrollPair`, `ScrollToc`
55
- - Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `Figure` (alias `Image`), `Cite`, `References`
53
+ - Data and behaviour: `DataTable`, `Metric`, `PropertyList`, `TreeView`, `StateMachine`, `DecisionTree`, `FeedbackLoop`, `CodeDiff`
54
+ - Reading and layout: `Insight`, `Callout`, `Details`, `Quote`, `NoteGrid`, `Tabs`, `Columns`, `Stack`, `Grid`, `Split`, `ScrollGrid`, `ScrollPair`, `ScrollToc`
55
+ - Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `CodeTabs`, `AnnotatedCode`, `Figure` (alias `Image`), `FigureRef`, `Cite`, `References`
56
56
 
57
57
  ## Authoring rules
58
58
 
@@ -67,20 +67,23 @@ This is the only runner — there is nothing to look for. Do not search for a lo
67
67
  - **Learning loop**: `WorkedExample` holds `Step` children; give each step a `reason` (the justification) so it teaches reasoning, not just the result. `Quiz question answer` reveals the answer on click with children as the explanation. `KeyTakeaways items={[...]}` closes a section.
68
68
  - **Provenance**: `Source kind="spec|rfc|implementation|experiment|experience" label href` marks one claim's origin inline; `Confidence level="high|medium|low" basis` wraps a claim with how strongly it is established; `Term name` (or children as the definition) gives an inline hover definition.
69
69
  - **Data and behaviour**: `DataTable headers={[...]} rows={[[...]]}` is the neutral table (no comparative stance). `Metric label value unit delta trend note` is one headline number — compose several with `Grid`/`ScrollGrid`. `StateMachine states={[{id,label,terminal}]} transitions={[{from,to,event,guard}]}` expresses cycles and guards. `DecisionTree branches={[{condition,outcome,tone,branches}]}` nests branches. `FeedbackLoop type="reinforcing|balancing" nodes={[{label,description}]}` closes a loop. `CodeDiff before after language` shows a change.
70
- - **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.
71
- - **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.
70
+ - **Structure and code extensions**: `Quote author source href` attributes a quotation. `Checklist title items={[{text,status:'pass'|'fail'|'unknown'|'todo',note}]}` is a verification list. `PropertyList title items={[{name,value,note}]}` is a name/value spec ledger. `TreeView title items={[{label,description,children:[…]}]}` nests a hierarchy. `CodeTabs items={[{label,language,code}]} title caption lineNumbers` gives one collapsible listing per variant. `AnnotatedCode code language title notes={[{line,text}]}` numbers a listing and calls out specific lines. `LastReviewed date by note` is a review stamp.
71
+ - **Figure**: a relative `src` (`./assets/diagram.png`) stays a relative link by default; compile with `--inline-assets` to bake every local image in as base64, or set `inline={true}` / `inline={false}` on one figure to override (precedence: per-figure prop > global flag > link). `http(s)` URLs stay links and warn (`ASSET_REMOTE`). Always set `alt`; 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.
72
+ - **Figure numbering**: give a figure an `id` and reference it with `<FigureRef id="..." />` to render "图 N". Numbering follows document order (per node in `atlas`, whole document in `scroll`) and reflows automatically, so never hand-write "图 1"; an explicit `label` still wins. A ref with no matching `id` warns (`FIGURE_REF_UNRESOLVED`).
73
+ - **Figure size & cost**: linking keeps the HTML small but requires the output to sit beside the MDX's `assets/` (the CLI warns when `-o` points elsewhere). Inlining is what makes a screenshot-heavy page large (a page with no heavy renderers otherwise lands near 250KB), and an inlined asset over 512KB warns (`ASSET_LARGE`). Decide the trade-off with the user instead of choosing silently.
72
74
  - **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.
73
75
  - 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.
74
76
 
75
77
  ## Validation diagnostics
76
78
 
77
- `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.
79
+ `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`, `ASSET_REMOTE`, `ASSET_LARGE`, `FIGURE_REF_UNRESOLVED`, `FIGURE_REF_MISSING_ID`, `FIGURE_DUPLICATE_ID`, `REF_SELF`; `NO_ROOT_LEVEL`, `MULTIPLE_ROOT_LEVEL`, `MECHANISM_KIND_UNVERIFIED` and `FAILURE_KIND_UNSTRUCTURED` are warnings that `--strict` promotes to errors.
78
80
 
79
81
  ## Before you report
80
82
 
81
83
  - All `error` diagnostics resolved (or `--no-validate` explicitly justified).
82
84
  - All `ConceptRef`, `Relation` endpoints, and `ConceptGraph root` point at existing node ids.
83
85
  - Every node has `title` + `summary`; every `Relation` has a `label`.
86
+ - You left Mermaid on its CDN default and did not pass `--inline-mermaid` unless the user explicitly asked for a fully offline single file.
84
87
  - The HTML file exists at the reported path.
85
88
 
86
89
  If `npx` cannot reach the registry, report the blocker. Never copy implementation files into the skill directory.
@@ -132,10 +132,11 @@
132
132
  <Chart title="留存趋势" type="line" labels={['第1周','第2周','第3周','第4周']} series={[{name:'留存率',values:[100,72,58,49]}]} />
133
133
  </ConceptNode>
134
134
 
135
- <ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure 把图片、题注编号和说明绑定,构建时把本地图片内联进单文件。">
136
- <Definition>相对路径的图片会在构建时转成 base64 内联,远程 URL 保持不变;label 提供“图 1”这样的编号。</Definition>
137
- <Figure src="./assets/sample-diagram.svg" alt="概念缩放示意" label="图 1" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
138
- <Boundary>大图会显著增大单文件 HTML;截图类内容建议控制尺寸。</Boundary>
135
+ <ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure 把图片、题注编号和说明绑定;默认外链,按需内联。">
136
+ <Definition>相对路径的图片默认保持外链,构建时用 --inline-assets 可全部内联,单张图也可用 `inline={true}` 或 `inline={false}` 覆盖;远程 URL 保持不变。给图一个 id,正文就能用 FigureRef 自动引用“图 N”。</Definition>
137
+ <Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
138
+ <Example title="自动图号">正文写“见 <FigureRef id="zoom-scale" />”,编号会随插图自动重排,不必手写“图 1”。</Example>
139
+ <Boundary>内联大图会显著增大 HTML;截图类内容建议控制尺寸,或对单张图设 `inline={false}`。</Boundary>
139
140
  </ConceptNode>
140
141
 
141
142
  <ConceptNode id="code-demo" title="CodeBlock:把命令、配置或伪代码放进正文" level="L2" parent="extension-family" summary="与绑定节点的 Implementation 不同,CodeBlock 可以出现在任何位置,并支持标题、题注、换行和行号。">