concept-atlas-dense-explain 0.4.2 → 0.6.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,19 +4,25 @@ 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 } from '../template/src/model/validate-content.js';
7
+ import { validateMdxSource, countBySeverity, detectFeatures } from '../template/src/model/validate-content.js';
8
+ import { SKINS, normalizeSkin, COMPONENT_STYLES, normalizeStyle } from '../template/src/model/skins.js';
8
9
 
9
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const templateRoot = path.join(packageRoot, 'template');
11
12
  const args = process.argv.slice(2);
13
+ let buildCounter = 0;
12
14
 
13
15
  function usage() {
14
16
  console.log('Usage:');
15
- console.log(' npx concept-atlas-dense-explain <input.mdx> [--mode atlas|scroll] [-o output.html] [--force] [--json] [--no-validate]');
16
- console.log(' npx concept-atlas-dense-explain render <input.mdx> [--mode atlas|scroll] [-o output.html] [--force]');
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]');
18
+ console.log(' npx concept-atlas-dense-explain render <input.mdx>... [-o output.html|dir]');
17
19
  console.log(' npx concept-atlas-dense-explain validate <input.mdx> [--mode atlas|scroll] [--strict] [--json]');
18
20
  console.log(' npx concept-atlas-dense-explain create <output.mdx> [--mode atlas|scroll] [--force]');
19
21
  console.log(' npx concept-atlas-dense-explain guide [--mode atlas|scroll] [-o output.mdx] [--force]');
22
+ console.log('');
23
+ console.log(' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.');
24
+ console.log(' --link-assets keeps figures as relative links instead of inlining them as base64.');
25
+ 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.`);
20
26
  }
21
27
 
22
28
  async function exists(filePath) {
@@ -37,17 +43,55 @@ function fail(message) {
37
43
  process.exit(1);
38
44
  }
39
45
 
40
- function printDiagnostics(source, options, { json }) {
46
+ const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style']);
47
+
48
+ /** Splits argv into flags, flag values and positional arguments. */
49
+ function parseFlags(argv) {
50
+ const flags = new Set();
51
+ const values = new Map();
52
+ const positional = [];
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const arg = argv[i];
55
+ if (VALUE_FLAGS.has(arg)) {
56
+ values.set(arg, argv[i + 1]);
57
+ i += 1;
58
+ } else if (arg.startsWith('-') && arg.length > 1) {
59
+ flags.add(arg);
60
+ } else {
61
+ positional.push(arg);
62
+ }
63
+ }
64
+ return { flags, values, positional };
65
+ }
66
+
67
+ /**
68
+ * A single input may name an output file; a batch needs a directory, because the
69
+ * per-target file names come from the inputs.
70
+ */
71
+ function resolveOutputs(inputs, explicit) {
72
+ const defaults = inputs.map(input => input.replace(/\.mdx$/i, '.html'));
73
+ if (!explicit) return defaults;
74
+ const target = path.resolve(explicit);
75
+ if (inputs.length === 1) return [target];
76
+ if (path.extname(target).toLowerCase() === '.html') {
77
+ fail('`-o` must be a directory when building more than one input.');
78
+ }
79
+ return inputs.map(input => path.join(target, `${path.basename(input, path.extname(input))}.html`));
80
+ }
81
+
82
+ function printDiagnostics(source, options, { json, label = null, quiet = false }) {
41
83
  const result = validateMdxSource(source, options);
84
+ if (quiet) return result;
42
85
  if (json) {
43
86
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
44
87
  return result;
45
88
  }
89
+ if (label) console.error(`\n${label}`);
46
90
  const { diagnostics, carrier, stats } = result;
47
91
  for (const item of diagnostics) {
48
- const label = item.severity === 'error' ? 'error' : 'warn ';
92
+ const severity = item.severity === 'error' ? 'error' : 'warn ';
49
93
  const where = `${item.line}:${item.column}`;
50
- console.error(`${label} ${where} ${item.code} ${item.message}`);
94
+ console.error(`${severity} ${where} ${item.code} ${item.message}`);
51
95
  }
52
96
  const { error, warning } = countBySeverity(diagnostics);
53
97
  const scope = carrier ? `${carrier} · ${stats.nodes} 节点 / ${stats.relations} 关系` : '未识别载体';
@@ -151,83 +195,201 @@ if (command === 'create' || command === 'new') {
151
195
  process.exit(0);
152
196
  }
153
197
 
154
- const json = args.includes('--json');
155
- const strict = args.includes('--strict');
156
- const skipValidate = args.includes('--no-validate');
198
+ const parsed = parseFlags(args);
199
+ const json = parsed.flags.has('--json');
200
+ const strict = parsed.flags.has('--strict');
201
+ const skipValidate = parsed.flags.has('--no-validate');
202
+ const force = parsed.flags.has('--force');
203
+ const linkAssets = parsed.flags.has('--link-assets');
204
+ const modeFlag = parsed.values.get('--mode') || null;
205
+
206
+ // Compile-time appearance defaults. Invalid values fail fast with the valid
207
+ // options instead of silently baking a broken default into every page.
208
+ let skinFlag = null;
209
+ if (parsed.values.has('--skin')) {
210
+ skinFlag = normalizeSkin(parsed.values.get('--skin'));
211
+ if (!skinFlag) fail(`Unknown skin: ${parsed.values.get('--skin')} (available: ${SKINS.map(skin => skin.id).join(', ')})`);
212
+ }
213
+ let defaultModeFlag = null;
214
+ if (parsed.values.has('--default-mode')) {
215
+ const raw = parsed.values.get('--default-mode');
216
+ if (!['dark', 'light', 'system'].includes(raw)) fail(`Invalid --default-mode: ${raw} (use dark, light or system)`);
217
+ defaultModeFlag = raw;
218
+ }
219
+ let styleFlag = null;
220
+ if (parsed.values.has('--style')) {
221
+ styleFlag = normalizeStyle(parsed.values.get('--style'));
222
+ if (!styleFlag) fail(`Unknown component style: ${parsed.values.get('--style')} (available: ${COMPONENT_STYLES.map(style => style.id).join(', ')})`);
223
+ }
157
224
 
158
225
  if (command === 'validate') {
159
- const target = args[0] ? path.resolve(args[0]) : null;
226
+ const target = parsed.positional[0] ? path.resolve(parsed.positional[0]) : null;
160
227
  if (!target || path.extname(target).toLowerCase() !== '.mdx' || !(await exists(target))) {
161
228
  fail('Provide an existing .mdx file to validate.');
162
229
  }
163
230
  const source = await readFile(target, 'utf8');
164
- const modeFlag = flagValue(args, ['--mode']);
165
231
  const result = printDiagnostics(source, {
166
232
  filePath: target,
167
- mode: modeFlag || null,
233
+ mode: modeFlag,
168
234
  strict,
169
235
  assetExists: spec => existsSync(path.resolve(path.dirname(target), spec)),
170
236
  }, { json });
171
237
  process.exit(countBySeverity(result.diagnostics).error ? 1 : 0);
172
238
  }
173
239
 
174
- if (args[0] && args[0].toLowerCase() === 'render') args.shift();
175
- const input = args[0] ? path.resolve(args[0]) : null;
176
- const modeFlag = flagValue(args, ['--mode']);
177
- const output = path.resolve(flagValue(args, ['-o', '--output']) || (input ? input.replace(/\.mdx$/i, '.html') : ''));
178
- const force = args.includes('--force');
240
+ // `render` is the default command, so it may still appear as a leading token.
241
+ const positional = parsed.positional[0] && parsed.positional[0].toLowerCase() === 'render'
242
+ ? parsed.positional.slice(1)
243
+ : parsed.positional;
244
+ const inputs = positional.map(entry => path.resolve(entry));
179
245
 
180
- if (!input || path.extname(input).toLowerCase() !== '.mdx' || !(await exists(input))) {
181
- console.error('Provide an existing .mdx input file.');
246
+ if (!inputs.length) {
247
+ console.error('Provide at least one existing .mdx input file.');
182
248
  usage();
183
249
  process.exit(1);
184
250
  }
251
+ for (const input of inputs) {
252
+ if (path.extname(input).toLowerCase() !== '.mdx' || !(await exists(input))) {
253
+ console.error(`Not an existing .mdx input: ${input}`);
254
+ process.exit(1);
255
+ }
256
+ }
185
257
 
186
- const source = await readFile(input, 'utf8');
187
- const validation = printDiagnostics(source, {
188
- filePath: input,
189
- mode: modeFlag || null,
258
+ const outputs = resolveOutputs(inputs, parsed.values.get('-o') || parsed.values.get('--output'));
259
+
260
+ for (const output of outputs) {
261
+ if (await exists(output) && !force) {
262
+ console.error(`Refusing to overwrite ${output}; pass --force to replace it.`);
263
+ process.exit(1);
264
+ }
265
+ }
266
+
267
+ const multi = inputs.length > 1;
268
+ const sources = await Promise.all(inputs.map(input => readFile(input, 'utf8')));
269
+
270
+ // Validate every document before building any of them: a batch should fail as a
271
+ // batch rather than leaving half the targets rendered.
272
+ const validations = sources.map((source, index) => printDiagnostics(source, {
273
+ filePath: inputs[index],
274
+ mode: modeFlag,
190
275
  strict,
191
- assetExists: spec => existsSync(path.resolve(path.dirname(input), spec)),
192
- }, { json });
193
- const { error: errorCount } = countBySeverity(validation.diagnostics);
276
+ assetExists: spec => existsSync(path.resolve(path.dirname(inputs[index]), spec)),
277
+ }, json
278
+ ? { json: false, quiet: true }
279
+ : { json: false, label: multi ? inputs[index] : null }));
194
280
 
281
+ if (json) {
282
+ const payload = multi
283
+ ? validations.map((result, index) => ({ file: inputs[index], ...result }))
284
+ : validations[0];
285
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
286
+ }
287
+
288
+ const errorCount = validations.reduce((sum, result) => sum + countBySeverity(result.diagnostics).error, 0);
195
289
  if (errorCount && !skipValidate) {
196
290
  console.error('内容校验未通过,已停止构建。修复后重试,或用 --no-validate 强制构建。');
197
291
  process.exit(1);
198
292
  }
199
293
 
200
- const mode = modeFlag || validation.carrier;
201
- if (!mode || !['atlas', 'scroll'].includes(mode)) {
202
- console.error('Could not detect the MDX carrier; choose --mode atlas or --mode scroll.');
203
- process.exit(1);
204
- }
205
- if (await exists(output) && !force) {
206
- console.error(`Refusing to overwrite ${output}; pass --force to replace it.`);
294
+ const jobs = inputs.map((input, index) => {
295
+ const mode = modeFlag || validations[index].carrier;
296
+ if (!mode || !['atlas', 'scroll'].includes(mode)) {
297
+ console.error(`Could not detect the MDX carrier for ${input}; choose --mode atlas or --mode scroll.`);
298
+ process.exit(1);
299
+ }
300
+ if (linkAssets && path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))) {
301
+ console.error(`警告:--link-assets 下 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效。`);
302
+ }
303
+ return { input, output: outputs[index], mode, features: detectFeatures(sources[index]), linkAssets };
304
+ });
305
+
306
+ const limit = clampConcurrency(parsed.values.get('--concurrency'), jobs.length);
307
+ const started = Date.now();
308
+ const results = await runPool(jobs.map(job => () => buildOne(job)), limit);
309
+
310
+ const failures = results.filter(result => !result.ok);
311
+ const saved = summarizeFeatures(jobs, results);
312
+ console.log(`Built ${results.length - failures.length}/${results.length} page(s) with concurrency ${limit} in ${((Date.now() - started) / 1000).toFixed(1)}s${saved ? ` (${saved})` : ''}.`);
313
+ if (failures.length) {
314
+ for (const failure of failures) console.error(`Build failed for ${failure.input}:`, failure.error);
207
315
  process.exit(1);
208
316
  }
209
- await mkdir(path.dirname(output), { recursive: true });
210
-
211
- const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
212
- const generatedEntry = path.join(path.dirname(output), templateEntry);
213
-
214
- try {
215
- await build({
216
- root: templateRoot,
217
- configFile: path.join(templateRoot, 'vite.config.js'),
218
- resolve: { alias: { '@concept-atlas/content': input } },
219
- build: {
220
- outDir: path.dirname(output),
221
- emptyOutDir: false,
222
- rollupOptions: { input: path.join(templateRoot, templateEntry) },
223
- },
224
- });
225
- if (generatedEntry !== output) {
317
+
318
+ async function buildOne(job) {
319
+ const { input, output, mode, features, linkAssets: link } = job;
320
+ const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
321
+ // Each build gets its own scratch outDir: the template always writes
322
+ // `index.html`/`scroll.html`, so concurrent builds sharing a directory would
323
+ // overwrite each other before the rename.
324
+ const scratch = path.join(path.dirname(output), `.concept-atlas-${process.pid}-${(buildCounter += 1)}`);
325
+ await mkdir(path.dirname(output), { recursive: true });
326
+ await mkdir(scratch, { recursive: true });
327
+ const define = { __ATLAS_FEATURES__: JSON.stringify(features) };
328
+ if (link) define.__ATLAS_INLINE_ASSETS__ = 'false';
329
+ if (skinFlag) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skinFlag);
330
+ if (defaultModeFlag) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(defaultModeFlag);
331
+ if (styleFlag) define.__ATLAS_DEFAULT_STYLE__ = JSON.stringify(styleFlag);
332
+ try {
333
+ await build({
334
+ root: templateRoot,
335
+ configFile: path.join(templateRoot, 'vite.config.js'),
336
+ resolve: { alias: { '@concept-atlas/content': input } },
337
+ define,
338
+ build: {
339
+ outDir: scratch,
340
+ emptyOutDir: false,
341
+ rollupOptions: { input: path.join(templateRoot, templateEntry) },
342
+ },
343
+ });
226
344
  await rm(output, { force: true });
227
- await rename(generatedEntry, output);
345
+ await rename(path.join(scratch, templateEntry), output);
346
+ console.log(`Built ${mode} HTML: ${output}${describeFeatures(features)}${link ? ' [figures linked]' : ''}`);
347
+ return { ok: true, input, output };
348
+ } catch (error) {
349
+ return { ok: false, input, output, error };
350
+ } finally {
351
+ await rm(scratch, { recursive: true, force: true });
228
352
  }
229
- console.log(`Built ${mode} HTML: ${output}`);
230
- } catch (error) {
231
- console.error('Build failed:', error);
232
- process.exit(1);
353
+ }
354
+
355
+ /** Saves the mermaid/KaTeX payload when a document never renders them. */
356
+ 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('/')}]`;
360
+ }
361
+
362
+ function summarizeFeatures(jobs, results) {
363
+ const built = new Set(results.filter(result => result.ok).map(result => result.input));
364
+ const pages = jobs.filter(job => built.has(job.input));
365
+ if (!pages.length) return '';
366
+ const droppedMath = pages.filter(job => !job.features.math).length;
367
+ const droppedMermaid = pages.filter(job => !job.features.mermaid).length;
368
+ const parts = [];
369
+ if (droppedMath) parts.push(`KaTeX dropped on ${droppedMath}/${pages.length}`);
370
+ if (droppedMermaid) parts.push(`Mermaid dropped on ${droppedMermaid}/${pages.length}`);
371
+ return parts.join(', ');
372
+ }
373
+
374
+ function clampConcurrency(raw, count) {
375
+ const parsedValue = Number.parseInt(raw ?? '', 10);
376
+ const fallback = Math.min(2, count);
377
+ if (!Number.isFinite(parsedValue)) return Math.max(1, fallback);
378
+ return Math.max(1, Math.min(4, parsedValue));
379
+ }
380
+
381
+ /** Runs `tasks` with at most `limit` in flight, preserving result order. */
382
+ async function runPool(tasks, limit) {
383
+ const results = new Array(tasks.length);
384
+ let cursor = 0;
385
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
386
+ for (;;) {
387
+ const index = cursor;
388
+ cursor += 1;
389
+ if (index >= tasks.length) return;
390
+ results[index] = await tasks[index]();
391
+ }
392
+ });
393
+ await Promise.all(workers);
394
+ return results;
233
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "0.4.2",
3
+ "version": "0.6.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
@@ -26,13 +26,15 @@ 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]`. 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. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
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.
32
+ 9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
31
33
 
32
34
  ## Carriers
33
35
 
34
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.
35
- - `scroll`: `ScrollDocument` → `ScrollHeader` + `ScrollSection` (+ `ScrollProse`, `ScrollGrid`). Shared components live inside sections. The shell auto-builds a a table of contents and reading progress from section titles — do not hand-build navigation.
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.
36
38
  - Never make one MDX file both shells. When switching shells, convert only the outer structure.
37
39
 
38
40
  ## Component families
@@ -51,10 +53,11 @@ If the user only wants the prompt/methodology and not files, still choose a shel
51
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`.
52
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`).
53
55
  - **Chart**: `type` is `bar` | `line` | `pie`; use `data` for bar/pie and `labels` + `series={[{name, values}]}` for line. Charts follow theme colors.
54
- - **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.
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 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.
57
+ - **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.
55
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.
56
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.
57
- - Be brief about cost: KaTeX fonts and Mermaid roughly double the single-file output (~5 MB), which is normal for an offline explainer. Mention it if the user cares about file size.
60
+ - 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.
58
61
 
59
62
  ## Validation diagnostics
60
63
 
@@ -89,10 +89,12 @@
89
89
  <Children><ConceptRef id="layout-demo" /><ConceptRef id="compact-demo" /></Children>
90
90
  </ConceptNode>
91
91
  <ConceptNode id="layout-demo" title="布局原语:Stack、Grid、Split、Columns 让信息有秩序" level="L2" parent="presentation-family" summary="布局组件只表达空间意图,具体样式由模板统一接管。">
92
+ <Definition>布局原语表达信息之间的空间关系;它们负责组织内容,不负责改变内容本身的语义。</Definition>
92
93
  <Columns><Split ratio="1fr 1fr"><Callout title="左列">结论与定义。</Callout><Callout title="右列">证据与边界。</Callout></Split></Columns>
93
94
  <Grid columns="auto" gap="sm"><Insight title="局部判断">Grid 中的短信息。</Insight><Insight title="另一判断" tone="warn">不要混用职责。</Insight></Grid>
94
95
  </ConceptNode>
95
96
  <ConceptNode id="compact-demo" title="压缩阅读:Tabs、Details、Callout、Insight、NoteGrid" level="L2" parent="presentation-family" summary="这些组件适合把结论、提示、细节和短信息压缩在首屏。">
97
+ <Definition>压缩组件通过折叠、分组和短提示降低首屏负担,同时保留继续阅读所需的上下文。</Definition>
96
98
  <Tabs items={[{label:'结论',content:'先展示最重要的判断。'},{label:'证据',content:'再展开可验证依据。'}]} />
97
99
  <Details summary="展开更多">Details 将次要信息收起,避免首屏过载。</Details>
98
100
  <Insight title="关键判断" tone="success">一个组件只承担一种信息组织方式。</Insight>
@@ -1,16 +1,41 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Concept Atlas · 概念缩放式知识图谱</title>
7
- <!-- Google Fonts for Academic / Editorial Knowledge style -->
8
- <link rel="preconnect" href="https://fonts.googleapis.com">
9
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
- <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Lora:ital,wght@0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Noto+Serif+SC:wght@500;600;700;900&display=swap" rel="stylesheet">
11
- </head>
12
- <body>
13
- <div id="root"></div>
14
- <script type="module" src="/src/main.jsx"></script>
15
- </body>
16
- </html>
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Concept Atlas · 概念缩放式知识图谱</title>
7
+ <script>
8
+ (function () {
9
+ var doc = document.documentElement;
10
+ var stored = null;
11
+ try { stored = JSON.parse(localStorage.getItem('concept_atlas_appearance') || 'null'); } catch (e) {}
12
+ var legacy = localStorage.getItem('concept_atlas_theme');
13
+ // Placeholders are replaced at build time when --skin/--default-mode
14
+ // (or CONCEPT_ATLAS_* env vars) are configured; otherwise the runtime
15
+ // fallbacks below keep the carrier defaults.
16
+ var skin = '__ATLAS_DEFAULT_SKIN__';
17
+ if (/^__.+__$/.test(skin)) skin = 'aurora';
18
+ var mode = '__ATLAS_DEFAULT_MODE__';
19
+ if (/^__.+__$/.test(mode)) mode = 'light';
20
+ var style = '__ATLAS_DEFAULT_STYLE__';
21
+ if (/^__.+__$/.test(style)) style = 'manuscript';
22
+ if (stored && (stored.mode === 'light' || stored.mode === 'dark')) {
23
+ mode = stored.mode;
24
+ } else if (mode !== 'light' && mode !== 'dark') {
25
+ mode = legacy === 'light' || legacy === 'dark' ? legacy : 'light';
26
+ }
27
+ doc.setAttribute('data-skin', stored && stored.skin ? stored.skin : skin);
28
+ doc.setAttribute('data-theme', mode);
29
+ doc.setAttribute('data-style', stored && stored.style ? stored.style : style);
30
+ })();
31
+ </script>
32
+ <!-- Google Fonts for Academic / Editorial Knowledge style -->
33
+ <link rel="preconnect" href="https://fonts.googleapis.com">
34
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
35
+ <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Lora:ital,wght@0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Noto+Serif+SC:wght@500;600;700;900&display=swap" rel="stylesheet">
36
+ </head>
37
+ <body>
38
+ <div id="root"></div>
39
+ <script type="module" src="/src/main.jsx"></script>
40
+ </body>
41
+ </html>
@@ -1,30 +1,52 @@
1
- import { build } from 'vite';
2
- import path from 'path';
3
- import fs from 'fs';
4
- import { fileURLToPath } from 'url';
5
-
6
- const __filename = fileURLToPath(import.meta.url);
7
- const __dirname = path.dirname(__filename);
8
- const rootDir = path.resolve(__dirname, '..');
9
-
1
+ import { build } from 'vite';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { normalizeSkin, normalizeStyle } from '../src/model/skins.js';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const rootDir = path.resolve(__dirname, '..');
10
+
11
+ /**
12
+ * Optional compile-time appearance defaults, read from the environment and
13
+ * forwarded as `define`s. The carrier HTML plugin replaces the placeholders
14
+ * inside the anti-flash inline script only when these are configured.
15
+ */
16
+ function appearanceDefines() {
17
+ const define = {};
18
+ const skin = normalizeSkin(process.env.CONCEPT_ATLAS_SKIN || '');
19
+ const mode = process.env.CONCEPT_ATLAS_DEFAULT_MODE;
20
+ const style = normalizeStyle(process.env.CONCEPT_ATLAS_STYLE || '');
21
+ if (skin) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skin);
22
+ if (['dark', 'light', 'system'].includes(mode)) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(mode);
23
+ if (style) define.__ATLAS_DEFAULT_STYLE__ = JSON.stringify(style);
24
+ return define;
25
+ }
26
+
10
27
  async function runBuild() {
11
28
  console.log('🚀 开始构建 Concept Atlas 知识讲解页面...');
12
-
13
- try {
14
- // 确保 dist 目录存在
15
- const distDir = path.resolve(rootDir, 'dist');
16
- if (!fs.existsSync(distDir)) {
17
- fs.mkdirSync(distDir, { recursive: true });
18
- }
19
-
29
+
30
+ try {
31
+ // 确保 dist 目录存在
32
+ const distDir = path.resolve(rootDir, 'dist');
33
+ if (!fs.existsSync(distDir)) {
34
+ fs.mkdirSync(distDir, { recursive: true });
35
+ }
36
+
20
37
  const mode = process.env.CONCEPT_ATLAS_MODE;
21
38
  const carriers = mode === 'atlas' ? ['index.html'] : mode === 'scroll' ? ['scroll.html'] : ['index.html', 'scroll.html'];
39
+ const define = appearanceDefines();
40
+ if (define.__ATLAS_DEFAULT_SKIN__ || define.__ATLAS_DEFAULT_MODE__) {
41
+ console.log(`🎨 默认外观:skin=${define.__ATLAS_DEFAULT_SKIN__ || '(carrier 默认)'} mode=${define.__ATLAS_DEFAULT_MODE__ || '(carrier 默认)'}`);
42
+ }
22
43
 
23
44
  // vite-plugin-singlefile supports one HTML input per build. Build each
24
45
  // requested carrier separately so every output remains a standalone file.
25
46
  for (const [index, entry] of carriers.entries()) {
26
47
  await build({
27
48
  root: rootDir,
49
+ define,
28
50
  build: {
29
51
  outDir: 'dist',
30
52
  emptyOutDir: index === 0,
@@ -34,10 +56,10 @@ async function runBuild() {
34
56
  }
35
57
 
36
58
  console.log(`✅ 构建成功!产物已生成到 ${carriers.map(entry => `dist/${entry}`).join(' 和 ')}。`);
37
- } catch (err) {
38
- console.error('❌ 构建失败:', err);
39
- process.exit(1);
40
- }
41
- }
42
-
43
- runBuild();
59
+ } catch (err) {
60
+ console.error('❌ 构建失败:', err);
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ runBuild();
@@ -4,6 +4,31 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Concept Atlas · 连续阅读示例</title>
7
+ <script>
8
+ (function () {
9
+ var doc = document.documentElement;
10
+ var stored = null;
11
+ try { stored = JSON.parse(localStorage.getItem('concept_atlas_appearance') || 'null'); } catch (e) {}
12
+ var legacy = localStorage.getItem('concept_atlas_scroll_theme');
13
+ // Placeholders are replaced at build time when --skin/--default-mode
14
+ // (or CONCEPT_ATLAS_* env vars) are configured; otherwise the runtime
15
+ // fallbacks below keep the carrier defaults.
16
+ var skin = '__ATLAS_DEFAULT_SKIN__';
17
+ if (/^__.+__$/.test(skin)) skin = 'aurora';
18
+ var mode = '__ATLAS_DEFAULT_MODE__';
19
+ if (/^__.+__$/.test(mode)) mode = 'light';
20
+ var style = '__ATLAS_DEFAULT_STYLE__';
21
+ if (/^__.+__$/.test(style)) style = 'manuscript';
22
+ if (stored && (stored.mode === 'light' || stored.mode === 'dark')) {
23
+ mode = stored.mode;
24
+ } else if (mode !== 'light' && mode !== 'dark') {
25
+ mode = legacy === 'light' || legacy === 'dark' ? legacy : 'light';
26
+ }
27
+ doc.setAttribute('data-skin', stored && stored.skin ? stored.skin : skin);
28
+ doc.setAttribute('data-theme', mode);
29
+ doc.setAttribute('data-style', stored && stored.style ? stored.style : style);
30
+ })();
31
+ </script>
7
32
  </head>
8
33
  <body>
9
34
  <div id="root"></div>