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 +217 -55
- package/package.json +1 -1
- package/skill/SKILL.md +8 -5
- package/template/guides/atlas-guide.mdx +2 -0
- package/template/index.html +41 -16
- package/template/scripts/build.mjs +46 -24
- package/template/scroll.html +25 -0
- package/template/src/app/App.jsx +232 -239
- package/template/src/app/use-appearance.js +82 -0
- package/template/src/components/MDXComponents.jsx +202 -16
- package/template/src/components/SkinPicker.jsx +90 -0
- package/template/src/model/concept-schema.js +169 -169
- package/template/src/model/relation-types.js +90 -90
- package/template/src/model/skins.js +44 -0
- package/template/src/model/validate-content.js +22 -0
- package/template/src/scroll-main.jsx +6 -8
- package/template/src/styles/concept-explain.css +439 -184
- package/template/src/styles/skins.css +194 -0
- package/template/src/styles/tokens.css +229 -0
- package/template/src/views/NodeExplorer.jsx +668 -668
- package/template/src/views/RelationGraph.jsx +722 -723
- package/template/vite.config.js +120 -1
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
|
|
16
|
-
console.log(' npx concept-atlas-dense-explain render <input.mdx
|
|
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
|
-
|
|
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
|
|
92
|
+
const severity = item.severity === 'error' ? 'error' : 'warn ';
|
|
49
93
|
const where = `${item.line}:${item.column}`;
|
|
50
|
-
console.error(`${
|
|
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
|
|
155
|
-
const
|
|
156
|
-
const
|
|
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 =
|
|
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
|
|
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
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
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 (!
|
|
181
|
-
console.error('Provide
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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(
|
|
192
|
-
},
|
|
193
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
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.
|
|
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
|
|
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:
|
|
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>
|
package/template/index.html
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
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();
|
package/template/scroll.html
CHANGED
|
@@ -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>
|