dsh-nerd-mode 0.2.0 → 0.4.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 +215 -163
- package/README.zh.md +217 -163
- package/assets/calibration.svg +69 -0
- package/assets/choose-a-mode.svg +48 -0
- package/assets/nerd.svg +22 -0
- package/assets/two-paths.svg +93 -0
- package/client.js +1375 -161
- package/cordis.patch.yml +28 -19
- package/lib/fold.js +1 -1
- package/lib/index.js +125 -5
- package/package.json +15 -3
- package/skills/study/SKILL.md +78 -86
- package/skills/study-plus/SKILL.md +239 -185
- package/skills/study-plus/scripts/book-client.js +286 -0
- package/skills/study-plus/scripts/book-data.mjs +289 -0
- package/skills/study-plus/scripts/build-book.mjs +149 -0
- package/skills/writing-beats/agents/openai.yaml +1 -1
- package/assets/icon-dark.svg +0 -5
- package/assets/icon-light.svg +0 -5
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Build study/<topic>/book.html from the graph and its existing lecture files.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, readdirSync, writeFileSync, existsSync, unlinkSync, realpathSync, statSync, lstatSync, copyFileSync, renameSync } from 'node:fs';
|
|
5
|
+
import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { parseBookData } from './book-data.mjs';
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const AM = resolve(here, '..', '..', 'answer-me-with-html', 'scripts', 'am.mjs');
|
|
13
|
+
const CLIENT = join(here, 'book-client.js');
|
|
14
|
+
|
|
15
|
+
function die(message) {
|
|
16
|
+
console.error(message);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findTopic(arg) {
|
|
21
|
+
const studyPath = resolve('study');
|
|
22
|
+
let studyStat;
|
|
23
|
+
try {
|
|
24
|
+
studyStat = lstatSync(studyPath);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT') die('No study/ folder here. Pass a topic folder under workspace study/.');
|
|
27
|
+
die(`Cannot inspect workspace study/ folder: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
if (studyStat.isSymbolicLink()) die(`Refusing to use symlink as workspace study/ folder: ${studyPath}`);
|
|
30
|
+
let studyRoot;
|
|
31
|
+
try {
|
|
32
|
+
studyRoot = realpathSync(studyPath);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
die(`Cannot resolve workspace study/ folder: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
if (!statSync(studyRoot).isDirectory()) die(`Workspace study/ path is not a directory: ${studyRoot}`);
|
|
37
|
+
|
|
38
|
+
let topicPath = arg ? resolve(arg) : '';
|
|
39
|
+
if (!arg) {
|
|
40
|
+
const hits = readdirSync(studyRoot).filter((name) => existsSync(join(studyRoot, name, 'graph.md')));
|
|
41
|
+
if (hits.length !== 1) die(`Pass a topic folder under workspace study/. Found ${hits.length} topics under study/: ${hits.join(', ') || 'none'}.`);
|
|
42
|
+
topicPath = join(studyRoot, hits[0]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let topicDir;
|
|
46
|
+
try {
|
|
47
|
+
topicDir = realpathSync(topicPath);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
die(`Cannot resolve topic folder ${topicPath}: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
const topicRelative = relative(studyRoot, topicDir);
|
|
52
|
+
if (!statSync(topicDir).isDirectory() || !topicRelative || topicRelative === '..' || topicRelative.startsWith(`..${sep}`) || isAbsolute(topicRelative)) {
|
|
53
|
+
die(`Topic folder must be a real directory at least one level below workspace study/: ${topicPath}`);
|
|
54
|
+
}
|
|
55
|
+
return topicDir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function build() {
|
|
59
|
+
const dir = findTopic(process.argv[2]);
|
|
60
|
+
const graphPath = join(dir, 'graph.md');
|
|
61
|
+
const lectureDir = join(dir, 'lectures');
|
|
62
|
+
if (!existsSync(graphPath)) die(`graph.md not found at ${graphPath}`);
|
|
63
|
+
if (!existsSync(lectureDir)) die(`No lectures folder found at ${lectureDir}`);
|
|
64
|
+
if (!existsSync(AM)) die(`am CLI not found at ${AM}. Install answer-me-with-html next to study-plus.`);
|
|
65
|
+
if (!existsSync(CLIENT)) die(`book-client.js not found at ${CLIENT}. Install the study-plus runtime scripts first.`);
|
|
66
|
+
|
|
67
|
+
const files = readdirSync(lectureDir)
|
|
68
|
+
.filter((name) => name.endsWith('.md') && !name.startsWith('.'))
|
|
69
|
+
.sort()
|
|
70
|
+
.map((name) => ({ name, text: readFileSync(join(lectureDir, name), 'utf8') }));
|
|
71
|
+
let book;
|
|
72
|
+
try {
|
|
73
|
+
book = parseBookData(readFileSync(graphPath, 'utf8'), files, { topicFallback: dir.split(/[\\/]/).pop() });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
die(error.message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const draft = [
|
|
79
|
+
'---',
|
|
80
|
+
'template: doc',
|
|
81
|
+
`title: ${JSON.stringify(book.title)}`,
|
|
82
|
+
...(book.goal ? [`subtitle: ${JSON.stringify(book.goal)}`] : []),
|
|
83
|
+
'---',
|
|
84
|
+
'',
|
|
85
|
+
book.units.map((unit) => `## ${unit.section}. ${unit.title}\n\n${unit.body}`).join('\n\n'),
|
|
86
|
+
'',
|
|
87
|
+
].join('\n');
|
|
88
|
+
const outPath = join(dir, 'book.html');
|
|
89
|
+
const backupPath = join(dir, 'book.html.bak');
|
|
90
|
+
const guardPath = (path, what) => {
|
|
91
|
+
try {
|
|
92
|
+
const stat = lstatSync(path);
|
|
93
|
+
if (stat.isSymbolicLink()) die(`Refusing to overwrite symlink book ${what}: ${path}`);
|
|
94
|
+
if (!stat.isFile()) die(`Refusing to overwrite non-regular book ${what}: ${path}`);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error.code !== 'ENOENT') die(`Cannot inspect book ${what} ${path}: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
guardPath(outPath, 'output');
|
|
100
|
+
guardPath(backupPath, 'backup');
|
|
101
|
+
// Render beside book.html: a failed build must never damage the current book.
|
|
102
|
+
const tmpPath = join(dir, `.book.new-${process.pid}.html`);
|
|
103
|
+
const draftPath = join(dir, `.book.draft-${process.pid}.md`);
|
|
104
|
+
writeFileSync(draftPath, draft, { encoding: 'utf8', flag: 'wx' });
|
|
105
|
+
|
|
106
|
+
let run;
|
|
107
|
+
try {
|
|
108
|
+
// Book builds stay offline: without this the renderer may start a weekly update check.
|
|
109
|
+
run = spawnSync(process.execPath, [AM, 'render', draftPath, '-o', tmpPath, '--no-open', '--template', 'doc', '--theme', 'shadcn', '--mode', 'light', '--style', 'off'], { stdio: 'inherit', env: { ...process.env, AM_NO_UPDATE_CHECK: '1' } });
|
|
110
|
+
} finally {
|
|
111
|
+
if (existsSync(draftPath)) unlinkSync(draftPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
if (run.error) throw new Error(`Could not run am render: ${run.error.message}`);
|
|
116
|
+
if (run.status !== 0) throw new Error(`am render failed with exit ${run.status ?? 'unknown'}`);
|
|
117
|
+
|
|
118
|
+
const payload = {
|
|
119
|
+
title: book.title,
|
|
120
|
+
goal: book.goal,
|
|
121
|
+
lang: book.lang,
|
|
122
|
+
chapters: book.chapters.map(({ n, name, description, count }) => ({ n, name, description, count })),
|
|
123
|
+
units: book.units.map(({ concept, title, chapter, section, index, status }) => ({ concept, title, chapter, section, index, status })),
|
|
124
|
+
};
|
|
125
|
+
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
126
|
+
const client = readFileSync(CLIENT, 'utf8');
|
|
127
|
+
const html = readFileSync(tmpPath, 'utf8');
|
|
128
|
+
const scriptText = `\n${client}\n`;
|
|
129
|
+
const hash = createHash('sha256').update(scriptText, 'utf8').digest('base64');
|
|
130
|
+
const csp = /(<meta http-equiv="Content-Security-Policy" content="[^"]*?script-src )/;
|
|
131
|
+
if (!csp.test(html)) throw new Error('No Content-Security-Policy script-src entry found in the rendered page.');
|
|
132
|
+
const out = html
|
|
133
|
+
.replace(csp, `$1'sha256-${hash}' `)
|
|
134
|
+
.replace('</head>', `<script>${scriptText}</script>\n</head>`)
|
|
135
|
+
.replace('</body>', `<script type="application/json" id="bk-book">${json}</script>\n</body>`);
|
|
136
|
+
writeFileSync(tmpPath, out, 'utf8');
|
|
137
|
+
|
|
138
|
+
if (existsSync(outPath)) copyFileSync(outPath, backupPath);
|
|
139
|
+
renameSync(tmpPath, outPath);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
142
|
+
die(error.message);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (book.orphans.length) console.warn(`Orphan lecture files excluded (concept number is not in graph.md): ${book.orphans.join(', ')}`);
|
|
146
|
+
console.log(`book: ${outPath} (${book.units.length} sections, ${book.chapters.length} chapters)`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
build();
|
package/assets/icon-dark.svg
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="Nerd Mode">
|
|
2
|
-
<path d="M12 112 C15 100 24 94 39 90 C35 84 32 78 29 73 C26 68 28 64 32 61 L35 58 L35 18 C35 10 39 7 44 7 C49 7 53 11 53 18 L53 49 C57 43 62 42 65 46 C69 50 66 55 63 59 L59 63 C65 60 70 61 72 65 C74 69 71 74 67 78 C63 82 62 87 63 91 C65 87 68 82 69 76 C70 68 68 61 68 52 C68 42 72 34 79 28 C87 22 98 23 105 31 C111 38 114 48 112 60 C110 75 103 85 92 89 C105 93 114 101 117 112 L117 116 L12 116 Z" fill="none" stroke="#f0f6fc" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
3
|
-
<path d="M77 48 C77 45 80 43 84 43 L87 43 C91 43 93 46 93 50 L93 55 C93 59 90 62 86 62 L82 62 C79 62 77 59 77 56 Z M99 48 C99 45 102 43 106 43 L109 43 C113 43 115 46 115 50 L115 55 C115 59 112 62 108 62 L104 62 C101 62 99 59 99 56 Z" fill="#2f81f7" stroke="#f0f6fc" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
-
<path d="M93 51 L99 51" fill="none" stroke="#f0f6fc" stroke-width="4" stroke-linecap="round"/>
|
|
5
|
-
</svg>
|
package/assets/icon-light.svg
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="Nerd Mode">
|
|
2
|
-
<path d="M12 112 C15 100 24 94 39 90 C35 84 32 78 29 73 C26 68 28 64 32 61 L35 58 L35 18 C35 10 39 7 44 7 C49 7 53 11 53 18 L53 49 C57 43 62 42 65 46 C69 50 66 55 63 59 L59 63 C65 60 70 61 72 65 C74 69 71 74 67 78 C63 82 62 87 63 91 C65 87 68 82 69 76 C70 68 68 61 68 52 C68 42 72 34 79 28 C87 22 98 23 105 31 C111 38 114 48 112 60 C110 75 103 85 92 89 C105 93 114 101 117 112 L117 116 L12 116 Z" fill="none" stroke="#1f2328" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
3
|
-
<path d="M77 48 C77 45 80 43 84 43 L87 43 C91 43 93 46 93 50 L93 55 C93 59 90 62 86 62 L82 62 C79 62 77 59 77 56 Z M99 48 C99 45 102 43 106 43 L109 43 C113 43 115 46 115 50 L115 55 C115 59 112 62 108 62 L104 62 C101 62 99 59 99 56 Z" fill="#2f81f7" stroke="#1f2328" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
-
<path d="M93 51 L99 51" fill="none" stroke="#1f2328" stroke-width="4" stroke-linecap="round"/>
|
|
5
|
-
</svg>
|