dsh-nerd-mode 0.5.0 → 0.5.2
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 +1 -1
- package/README.zh.md +1 -1
- package/assets/calibration.svg +69 -69
- package/assets/nerd.svg +22 -22
- package/assets/section.md +15 -15
- package/client.js +2170 -2225
- package/cordis.patch.yml +24 -24
- package/lib/index.js +838 -286
- package/lib/study-sessions.js +283 -283
- package/package.json +70 -70
- package/skills/convert-documents-to-markdown/SKILL.md +26 -26
- package/skills/diagram/SKILL.md +43 -19
- package/skills/diagram/scripts/check-svg.mjs +184 -0
- package/skills/humanizer/.claude-plugin/marketplace.json +18 -18
- package/skills/humanizer/.claude-plugin/plugin.json +15 -15
- package/skills/humanizer/.cursor-plugin/plugin.json +12 -12
- package/skills/humanizer/.github/ISSUE_TEMPLATE/config.yml +8 -8
- package/skills/humanizer/.github/ISSUE_TEMPLATE/new-pattern.yml +24 -24
- package/skills/humanizer/.github/ISSUE_TEMPLATE/rewrite-problem.yml +44 -44
- package/skills/humanizer/.github/workflows/validate.yml +29 -29
- package/skills/humanizer/AGENTS.md +54 -54
- package/skills/humanizer/CHANGELOG.md +116 -116
- package/skills/humanizer/LICENSE +21 -21
- package/skills/humanizer/README.md +206 -206
- package/skills/humanizer/SKILL.md +397 -397
- package/skills/humanizer/agents/openai.yaml +4 -4
- package/skills/humanizer/scripts/validate-package.py +146 -146
- package/skills/study/SKILL.md +81 -78
- package/skills/study-plus/SKILL.md +246 -246
- package/skills/study-plus/scripts/book-client.js +284 -284
- package/skills/study-plus/scripts/book-data.mjs +289 -289
- package/skills/study-plus/scripts/build-book.mjs +155 -155
- package/skills/writing-beats/SKILL.md +66 -66
- package/skills/writing-beats/agents/openai.yaml +5 -5
|
@@ -1,289 +1,289 @@
|
|
|
1
|
-
const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'into', 'nor', 'of', 'on', 'or', 'over', 'the', 'to', 'up', 'via', 'with']);
|
|
2
|
-
|
|
3
|
-
function titleCase(text) {
|
|
4
|
-
if (/[\u4e00-\u9fff]/.test(text)) return text;
|
|
5
|
-
const words = text.split(' ');
|
|
6
|
-
return words.map((word, at) => {
|
|
7
|
-
const match = word.match(/^([^\w$]*)([\w$][\w$'’-]*)(.*)$/);
|
|
8
|
-
if (!match) return word;
|
|
9
|
-
const [, lead, core, tail] = match;
|
|
10
|
-
if (core.includes('$') || (core.length > 1 && core === core.toUpperCase())) return word;
|
|
11
|
-
const opens = at === 0 || /[:—–?!]$/.test(words[at - 1]);
|
|
12
|
-
if (!opens && SMALL.has(core.toLowerCase())) return lead + core.toLowerCase() + tail;
|
|
13
|
-
return lead + core[0].toUpperCase() + core.slice(1) + tail;
|
|
14
|
-
}).join(' ');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function cells(line) {
|
|
18
|
-
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function readConceptTable(text, errors) {
|
|
22
|
-
const lines = text.split(/\r?\n/);
|
|
23
|
-
const headerAt = lines.findIndex((line) => {
|
|
24
|
-
if (!line.trim().startsWith('|')) return false;
|
|
25
|
-
const headers = cells(line).map((value) => value.toLowerCase());
|
|
26
|
-
return headers.includes('#') && headers.includes('concept');
|
|
27
|
-
});
|
|
28
|
-
if (headerAt < 0) {
|
|
29
|
-
errors.push('graph.md has no concept table with # and Concept headers.');
|
|
30
|
-
return { headers: [], rows: [] };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const headers = cells(lines[headerAt]).map((value) => value.toLowerCase());
|
|
34
|
-
const headerIndexes = new Map();
|
|
35
|
-
headers.forEach((header, index) => {
|
|
36
|
-
if (headerIndexes.has(header)) errors.push(`Concept table has duplicate ${header} headers.`);
|
|
37
|
-
else headerIndexes.set(header, index);
|
|
38
|
-
});
|
|
39
|
-
const rows = [];
|
|
40
|
-
for (const line of lines.slice(headerAt + 1)) {
|
|
41
|
-
if (!line.trim().startsWith('|')) break;
|
|
42
|
-
const values = cells(line);
|
|
43
|
-
if (values.every((value) => /^:?-+:?$/.test(value))) continue;
|
|
44
|
-
const numberText = values[headerIndexes.get('#')] ?? '';
|
|
45
|
-
const number = /^\d+$/.test(numberText) ? Number(numberText) : 0;
|
|
46
|
-
if (!number) {
|
|
47
|
-
errors.push(`Concept table row ${rows.length + 1} has invalid # value "${numberText}". Use consecutive positive integers starting at 1.`);
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
rows.push({
|
|
51
|
-
number,
|
|
52
|
-
name: values[headerIndexes.get('concept')] ?? '',
|
|
53
|
-
chapterText: values[headerIndexes.get('chapter')] ?? '',
|
|
54
|
-
sectionText: values[headerIndexes.get('section')] ?? '',
|
|
55
|
-
blockText: values[headerIndexes.get('block')] ?? '',
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
if (!rows.length) errors.push('graph.md has no concept rows.');
|
|
59
|
-
|
|
60
|
-
const seen = new Set();
|
|
61
|
-
rows.forEach((row, index) => {
|
|
62
|
-
if (seen.has(row.number)) errors.push(`Concept number ${row.number} appears more than once in the concept table.`);
|
|
63
|
-
seen.add(row.number);
|
|
64
|
-
const expected = index + 1;
|
|
65
|
-
if (row.number !== expected) errors.push(`Concept table row ${index + 1} is numbered ${row.number}; concept numbers must be consecutive in learning order starting at 1.`);
|
|
66
|
-
if (!row.name) errors.push(`Concept ${row.number} has no name in the Concept column.`);
|
|
67
|
-
});
|
|
68
|
-
return { headers, rows };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function readChapterList(text, heading, errors) {
|
|
72
|
-
const headingAt = text.search(new RegExp(`^##\\s+${heading}\\s*$`, 'mi'));
|
|
73
|
-
if (headingAt < 0) return null;
|
|
74
|
-
const section = text.slice(headingAt).replace(new RegExp(`^##\\s+${heading}\\s*\\r?\\n`, 'i'), '').split(/^##\s/m)[0];
|
|
75
|
-
const chapters = [];
|
|
76
|
-
for (const line of section.split(/\r?\n/)) {
|
|
77
|
-
if (!/^\s*\d+\./.test(line)) continue;
|
|
78
|
-
const match = line.match(/^\s*(\d+)\.\s*(.+?):\s*(.+?)\s*$/);
|
|
79
|
-
if (!match) {
|
|
80
|
-
errors.push(`## ${heading} entry "${line.trim()}" is incomplete. Use "N. <title>: <scope>".`);
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
chapters.push({ n: Number(match[1]), name: match[2].trim(), description: match[3].trim() });
|
|
84
|
-
}
|
|
85
|
-
if (!chapters.length) errors.push(`## ${heading} has no chapter metadata. Add entries as "N. <title>: <scope>".`);
|
|
86
|
-
|
|
87
|
-
const seen = new Set();
|
|
88
|
-
chapters.forEach((chapter, index) => {
|
|
89
|
-
if (seen.has(chapter.n)) errors.push(`## ${heading} repeats chapter number ${chapter.n}.`);
|
|
90
|
-
seen.add(chapter.n);
|
|
91
|
-
if (chapter.n !== index + 1) errors.push(`## ${heading} entry ${index + 1} is numbered ${chapter.n}; chapter numbers must be consecutive starting at 1.`);
|
|
92
|
-
if (!chapter.name || !chapter.description) errors.push(`Chapter ${chapter.n} needs both a title and a scope description.`);
|
|
93
|
-
});
|
|
94
|
-
return chapters;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function graphMetadata(text, topicFallback) {
|
|
98
|
-
const firstHeading = text.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim() || '';
|
|
99
|
-
const headingTopic = firstHeading.replace(/^(?:Topic|Course):\s*/i, '');
|
|
100
|
-
const topic = (headingTopic.toLowerCase() === 'topic' || headingTopic.toLowerCase() === 'course' ? '' : headingTopic || topicFallback || 'Course').trim();
|
|
101
|
-
const book = (text.match(/^Book:\s*(.+?)\s*$/mi)?.[1] || '').trim();
|
|
102
|
-
const goal = (text.match(/^Goal:\s*(.*?)\s*$/mi)?.[1] || '').trim();
|
|
103
|
-
return { topic, title: book || topic, goal };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function graphError(errors) {
|
|
107
|
-
return new Error(`Invalid graph.md:\n${errors.map((error) => `- ${error}`).join('\n')}`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function parseGraph(text, topicFallback) {
|
|
111
|
-
const errors = [];
|
|
112
|
-
const metadata = graphMetadata(text, topicFallback);
|
|
113
|
-
const table = readConceptTable(text, errors);
|
|
114
|
-
const chapterHeadings = readChapterList(text, 'Chapters', errors);
|
|
115
|
-
const structureHeadings = chapterHeadings ? null : readChapterList(text, 'Structure', errors);
|
|
116
|
-
const hasChapter = table.headers.includes('chapter');
|
|
117
|
-
const hasSection = table.headers.includes('section');
|
|
118
|
-
const hasBlock = table.headers.includes('block');
|
|
119
|
-
let chapters;
|
|
120
|
-
let mappings = new Map();
|
|
121
|
-
|
|
122
|
-
if (hasChapter || hasSection) {
|
|
123
|
-
if (!hasChapter || !hasSection) errors.push('The new concept table requires both Chapter and Section columns.');
|
|
124
|
-
if (!chapterHeadings && table.rows.some((row) => /^\d+$/.test(row.chapterText) && Number(row.chapterText) > 1)) {
|
|
125
|
-
errors.push('A multi-chapter graph needs a ## Chapters list with each chapter title and scope.');
|
|
126
|
-
}
|
|
127
|
-
chapters = chapterHeadings || [{ n: 1, name: metadata.title, description: '' }];
|
|
128
|
-
const chapterByNumber = new Map(chapters.map((chapter) => [chapter.n, chapter]));
|
|
129
|
-
const positions = new Map();
|
|
130
|
-
for (const row of table.rows) {
|
|
131
|
-
if (!/^\d+$/.test(row.chapterText)) {
|
|
132
|
-
errors.push(`Concept ${row.number} has invalid or missing Chapter value "${row.chapterText}". Use a positive chapter number.`);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (!/^\d+$/.test(row.sectionText)) {
|
|
136
|
-
errors.push(`Concept ${row.number} has invalid or missing Section value "${row.sectionText}". Use a positive section number.`);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
const chapter = Number(row.chapterText);
|
|
140
|
-
const section = Number(row.sectionText);
|
|
141
|
-
if (chapter < 1) errors.push(`Concept ${row.number} has invalid Chapter ${chapter}. Chapter numbers start at 1.`);
|
|
142
|
-
if (section < 1) errors.push(`Concept ${row.number} has invalid Section ${section}. Section numbers start at 1 in each chapter.`);
|
|
143
|
-
if (!chapterByNumber.has(chapter)) {
|
|
144
|
-
errors.push(`Concept ${row.number} refers to Chapter ${chapter}, which is not listed under ## Chapters.`);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
const key = `${chapter}:${section}`;
|
|
148
|
-
if (positions.has(key)) errors.push(`Concepts ${positions.get(key)} and ${row.number} both use Chapter ${chapter}, Section ${section}.`);
|
|
149
|
-
else positions.set(key, row.number);
|
|
150
|
-
mappings.set(row.number, { chapter, section });
|
|
151
|
-
}
|
|
152
|
-
for (const chapter of chapters) {
|
|
153
|
-
const sectionNumbers = table.rows
|
|
154
|
-
.map((row) => mappings.get(row.number))
|
|
155
|
-
.filter((mapping) => mapping?.chapter === chapter.n)
|
|
156
|
-
.map((mapping) => mapping.section)
|
|
157
|
-
.sort((a, b) => a - b);
|
|
158
|
-
sectionNumbers.forEach((section, index) => {
|
|
159
|
-
if (section !== index + 1) errors.push(`Chapter ${chapter.n} has nonconsecutive Section numbering at ${section}. Sections must start at 1 with no gaps.`);
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
} else if (chapterHeadings || structureHeadings) {
|
|
163
|
-
const legacy = !chapterHeadings;
|
|
164
|
-
chapters = chapterHeadings || structureHeadings;
|
|
165
|
-
if (legacy && !hasBlock) errors.push('## Structure requires a Block column to map concepts to chapters.');
|
|
166
|
-
const chapterByNumber = new Map(chapters.map((chapter) => [chapter.n, chapter]));
|
|
167
|
-
for (const row of table.rows) {
|
|
168
|
-
const value = legacy ? row.blockText : row.chapterText;
|
|
169
|
-
const field = legacy ? 'Block' : 'Chapter';
|
|
170
|
-
if (!/^\d+$/.test(value)) {
|
|
171
|
-
errors.push(`Concept ${row.number} has invalid or missing ${field} value "${value}". Use a positive chapter number.`);
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const chapter = Number(value);
|
|
175
|
-
if (chapter < 1) errors.push(`Concept ${row.number} has invalid ${field} ${chapter}. Chapter numbers start at 1.`);
|
|
176
|
-
if (!chapterByNumber.has(chapter)) {
|
|
177
|
-
errors.push(`Concept ${row.number} refers to ${field} ${chapter}, which has no matching entry under ## ${legacy ? 'Structure' : 'Chapters'}.`);
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
const section = table.rows.filter((candidate) => {
|
|
181
|
-
const mapped = legacy ? candidate.blockText : candidate.chapterText;
|
|
182
|
-
return /^\d+$/.test(mapped) && Number(mapped) === chapter && candidate.number < row.number;
|
|
183
|
-
}).length + 1;
|
|
184
|
-
mappings.set(row.number, { chapter, section });
|
|
185
|
-
}
|
|
186
|
-
} else {
|
|
187
|
-
chapters = [{ n: 1, name: metadata.title, description: '' }];
|
|
188
|
-
table.rows.forEach((row, index) => mappings.set(row.number, { chapter: 1, section: index + 1 }));
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (errors.length) throw graphError(errors);
|
|
192
|
-
return { ...metadata, chapters, rows: table.rows, mappings };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function parseLecture(file) {
|
|
196
|
-
const filename = file.name || 'lecture.md';
|
|
197
|
-
const text = String(file.text ?? '').replace(/^\uFEFF/, '');
|
|
198
|
-
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
199
|
-
if (!frontmatter) throw new Error(`${filename}: malformed frontmatter. Start with --- and close it with --- on its own line.`);
|
|
200
|
-
const fields = new Map();
|
|
201
|
-
for (const line of frontmatter[1].split(/\r?\n/)) {
|
|
202
|
-
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
203
|
-
const match = line.match(/^([A-Za-z][\w-]*):\s*(.*?)\s*$/);
|
|
204
|
-
if (!match) throw new Error(`${filename}: malformed frontmatter line "${line}". Use key: value.`);
|
|
205
|
-
const key = match[1].toLowerCase();
|
|
206
|
-
if (fields.has(key)) throw new Error(`${filename}: duplicate frontmatter field "${key}".`);
|
|
207
|
-
fields.set(key, match[2].replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_, double, single) => double ?? single));
|
|
208
|
-
}
|
|
209
|
-
if (/^---\r?\n(?:[A-Za-z][\w-]*:.*\r?\n)+---(?:\r?\n|$)/m.test(text.slice(frontmatter[0].length))) {
|
|
210
|
-
throw new Error(`${filename}: contains a second frontmatter block.`);
|
|
211
|
-
}
|
|
212
|
-
const status = fields.get('status');
|
|
213
|
-
if (status !== undefined && status !== 'draft') throw new Error(`${filename}: frontmatter status must be "draft" or absent.`);
|
|
214
|
-
if (status === 'draft' && fields.get('date')) throw new Error(`${filename}: a draft has no teaching date.`);
|
|
215
|
-
if (status !== 'draft' && !fields.get('date')) throw new Error(`${filename}: frontmatter needs a non-empty date field.`);
|
|
216
|
-
const conceptText = fields.get('concept') || '';
|
|
217
|
-
if (!/^\d+$/.test(conceptText) || Number(conceptText) < 1) {
|
|
218
|
-
throw new Error(`${filename}: frontmatter concept must be a positive integer.`);
|
|
219
|
-
}
|
|
220
|
-
const body = text.slice(frontmatter[0].length);
|
|
221
|
-
const title = body.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim() || '';
|
|
222
|
-
if (!title) throw new Error(`${filename}: has no first-level title.`);
|
|
223
|
-
return {
|
|
224
|
-
filename,
|
|
225
|
-
concept: Number(conceptText),
|
|
226
|
-
status: status === 'draft' ? 'draft' : 'lecture',
|
|
227
|
-
title,
|
|
228
|
-
display: titleCase(title),
|
|
229
|
-
body: body.replace(/^#\s+.+?\s*$(?:\r?\n)?/m, '').replace(/^(?:\r?\n)+/, ''),
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function demote(body) {
|
|
234
|
-
return body.replace(/^(#{1,6})\s/gm, (_, marks) => '#'.repeat(Math.min(6, marks.length + 1)) + ' ');
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Parse a graph and its lecture files into ordered book chapters and sections. */
|
|
238
|
-
export function parseBookData(graphText, lectureFiles, { topicFallback = '' } = {}) {
|
|
239
|
-
const graph = parseGraph(graphText, topicFallback);
|
|
240
|
-
if (!lectureFiles.length) throw new Error('No lecture files found. Add Markdown lectures under the Course lectures/ folder.');
|
|
241
|
-
|
|
242
|
-
const lectures = lectureFiles.map(parseLecture);
|
|
243
|
-
const byConcept = new Map();
|
|
244
|
-
for (const lecture of lectures) {
|
|
245
|
-
if (byConcept.has(lecture.concept)) {
|
|
246
|
-
throw new Error(`${lecture.filename}: concept ${lecture.concept} is already mapped by ${byConcept.get(lecture.concept).filename}. Each concept must have one lecture file.`);
|
|
247
|
-
}
|
|
248
|
-
byConcept.set(lecture.concept, lecture);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const conceptNumbers = new Set(graph.rows.map((row) => row.number));
|
|
252
|
-
const orphans = lectures.filter((lecture) => !conceptNumbers.has(lecture.concept)).map((lecture) => lecture.filename);
|
|
253
|
-
const units = [];
|
|
254
|
-
const chapters = [];
|
|
255
|
-
for (const chapter of graph.chapters) {
|
|
256
|
-
const chapterUnits = graph.rows
|
|
257
|
-
.map((row) => {
|
|
258
|
-
const mapping = graph.mappings.get(row.number);
|
|
259
|
-
const lecture = byConcept.get(row.number);
|
|
260
|
-
if (!lecture || mapping?.chapter !== chapter.n) return null;
|
|
261
|
-
return {
|
|
262
|
-
concept: row.number,
|
|
263
|
-
title: row.name,
|
|
264
|
-
chapter: chapter.n,
|
|
265
|
-
section: mapping.section,
|
|
266
|
-
status: lecture.status,
|
|
267
|
-
body: demote(lecture.body).trim(),
|
|
268
|
-
sourceTitle: lecture.title,
|
|
269
|
-
};
|
|
270
|
-
})
|
|
271
|
-
.filter(Boolean)
|
|
272
|
-
.sort((a, b) => a.section - b.section);
|
|
273
|
-
if (!chapterUnits.length) continue;
|
|
274
|
-
chapters.push({ n: chapter.n, name: titleCase(chapter.name), description: chapter.description, count: chapterUnits.length });
|
|
275
|
-
units.push(...chapterUnits);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (!units.length) throw new Error(`No lecture files match concepts in graph.md.${orphans.length ? ` Orphan lecture files: ${orphans.join(', ')}.` : ''}`);
|
|
279
|
-
units.forEach((unit, index) => { unit.index = index; });
|
|
280
|
-
const cjk = units.map((unit) => `${unit.sourceTitle}${unit.body}`).join(' ').match(/[\u4e00-\u9fff]/g) || [];
|
|
281
|
-
return {
|
|
282
|
-
title: graph.title,
|
|
283
|
-
goal: graph.goal,
|
|
284
|
-
lang: cjk.length > 30 ? 'zh' : 'en',
|
|
285
|
-
chapters,
|
|
286
|
-
units,
|
|
287
|
-
orphans,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
1
|
+
const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'into', 'nor', 'of', 'on', 'or', 'over', 'the', 'to', 'up', 'via', 'with']);
|
|
2
|
+
|
|
3
|
+
function titleCase(text) {
|
|
4
|
+
if (/[\u4e00-\u9fff]/.test(text)) return text;
|
|
5
|
+
const words = text.split(' ');
|
|
6
|
+
return words.map((word, at) => {
|
|
7
|
+
const match = word.match(/^([^\w$]*)([\w$][\w$'’-]*)(.*)$/);
|
|
8
|
+
if (!match) return word;
|
|
9
|
+
const [, lead, core, tail] = match;
|
|
10
|
+
if (core.includes('$') || (core.length > 1 && core === core.toUpperCase())) return word;
|
|
11
|
+
const opens = at === 0 || /[:—–?!]$/.test(words[at - 1]);
|
|
12
|
+
if (!opens && SMALL.has(core.toLowerCase())) return lead + core.toLowerCase() + tail;
|
|
13
|
+
return lead + core[0].toUpperCase() + core.slice(1) + tail;
|
|
14
|
+
}).join(' ');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cells(line) {
|
|
18
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readConceptTable(text, errors) {
|
|
22
|
+
const lines = text.split(/\r?\n/);
|
|
23
|
+
const headerAt = lines.findIndex((line) => {
|
|
24
|
+
if (!line.trim().startsWith('|')) return false;
|
|
25
|
+
const headers = cells(line).map((value) => value.toLowerCase());
|
|
26
|
+
return headers.includes('#') && headers.includes('concept');
|
|
27
|
+
});
|
|
28
|
+
if (headerAt < 0) {
|
|
29
|
+
errors.push('graph.md has no concept table with # and Concept headers.');
|
|
30
|
+
return { headers: [], rows: [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const headers = cells(lines[headerAt]).map((value) => value.toLowerCase());
|
|
34
|
+
const headerIndexes = new Map();
|
|
35
|
+
headers.forEach((header, index) => {
|
|
36
|
+
if (headerIndexes.has(header)) errors.push(`Concept table has duplicate ${header} headers.`);
|
|
37
|
+
else headerIndexes.set(header, index);
|
|
38
|
+
});
|
|
39
|
+
const rows = [];
|
|
40
|
+
for (const line of lines.slice(headerAt + 1)) {
|
|
41
|
+
if (!line.trim().startsWith('|')) break;
|
|
42
|
+
const values = cells(line);
|
|
43
|
+
if (values.every((value) => /^:?-+:?$/.test(value))) continue;
|
|
44
|
+
const numberText = values[headerIndexes.get('#')] ?? '';
|
|
45
|
+
const number = /^\d+$/.test(numberText) ? Number(numberText) : 0;
|
|
46
|
+
if (!number) {
|
|
47
|
+
errors.push(`Concept table row ${rows.length + 1} has invalid # value "${numberText}". Use consecutive positive integers starting at 1.`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
rows.push({
|
|
51
|
+
number,
|
|
52
|
+
name: values[headerIndexes.get('concept')] ?? '',
|
|
53
|
+
chapterText: values[headerIndexes.get('chapter')] ?? '',
|
|
54
|
+
sectionText: values[headerIndexes.get('section')] ?? '',
|
|
55
|
+
blockText: values[headerIndexes.get('block')] ?? '',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!rows.length) errors.push('graph.md has no concept rows.');
|
|
59
|
+
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
rows.forEach((row, index) => {
|
|
62
|
+
if (seen.has(row.number)) errors.push(`Concept number ${row.number} appears more than once in the concept table.`);
|
|
63
|
+
seen.add(row.number);
|
|
64
|
+
const expected = index + 1;
|
|
65
|
+
if (row.number !== expected) errors.push(`Concept table row ${index + 1} is numbered ${row.number}; concept numbers must be consecutive in learning order starting at 1.`);
|
|
66
|
+
if (!row.name) errors.push(`Concept ${row.number} has no name in the Concept column.`);
|
|
67
|
+
});
|
|
68
|
+
return { headers, rows };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readChapterList(text, heading, errors) {
|
|
72
|
+
const headingAt = text.search(new RegExp(`^##\\s+${heading}\\s*$`, 'mi'));
|
|
73
|
+
if (headingAt < 0) return null;
|
|
74
|
+
const section = text.slice(headingAt).replace(new RegExp(`^##\\s+${heading}\\s*\\r?\\n`, 'i'), '').split(/^##\s/m)[0];
|
|
75
|
+
const chapters = [];
|
|
76
|
+
for (const line of section.split(/\r?\n/)) {
|
|
77
|
+
if (!/^\s*\d+\./.test(line)) continue;
|
|
78
|
+
const match = line.match(/^\s*(\d+)\.\s*(.+?):\s*(.+?)\s*$/);
|
|
79
|
+
if (!match) {
|
|
80
|
+
errors.push(`## ${heading} entry "${line.trim()}" is incomplete. Use "N. <title>: <scope>".`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
chapters.push({ n: Number(match[1]), name: match[2].trim(), description: match[3].trim() });
|
|
84
|
+
}
|
|
85
|
+
if (!chapters.length) errors.push(`## ${heading} has no chapter metadata. Add entries as "N. <title>: <scope>".`);
|
|
86
|
+
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
chapters.forEach((chapter, index) => {
|
|
89
|
+
if (seen.has(chapter.n)) errors.push(`## ${heading} repeats chapter number ${chapter.n}.`);
|
|
90
|
+
seen.add(chapter.n);
|
|
91
|
+
if (chapter.n !== index + 1) errors.push(`## ${heading} entry ${index + 1} is numbered ${chapter.n}; chapter numbers must be consecutive starting at 1.`);
|
|
92
|
+
if (!chapter.name || !chapter.description) errors.push(`Chapter ${chapter.n} needs both a title and a scope description.`);
|
|
93
|
+
});
|
|
94
|
+
return chapters;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function graphMetadata(text, topicFallback) {
|
|
98
|
+
const firstHeading = text.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim() || '';
|
|
99
|
+
const headingTopic = firstHeading.replace(/^(?:Topic|Course):\s*/i, '');
|
|
100
|
+
const topic = (headingTopic.toLowerCase() === 'topic' || headingTopic.toLowerCase() === 'course' ? '' : headingTopic || topicFallback || 'Course').trim();
|
|
101
|
+
const book = (text.match(/^Book:\s*(.+?)\s*$/mi)?.[1] || '').trim();
|
|
102
|
+
const goal = (text.match(/^Goal:\s*(.*?)\s*$/mi)?.[1] || '').trim();
|
|
103
|
+
return { topic, title: book || topic, goal };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function graphError(errors) {
|
|
107
|
+
return new Error(`Invalid graph.md:\n${errors.map((error) => `- ${error}`).join('\n')}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseGraph(text, topicFallback) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
const metadata = graphMetadata(text, topicFallback);
|
|
113
|
+
const table = readConceptTable(text, errors);
|
|
114
|
+
const chapterHeadings = readChapterList(text, 'Chapters', errors);
|
|
115
|
+
const structureHeadings = chapterHeadings ? null : readChapterList(text, 'Structure', errors);
|
|
116
|
+
const hasChapter = table.headers.includes('chapter');
|
|
117
|
+
const hasSection = table.headers.includes('section');
|
|
118
|
+
const hasBlock = table.headers.includes('block');
|
|
119
|
+
let chapters;
|
|
120
|
+
let mappings = new Map();
|
|
121
|
+
|
|
122
|
+
if (hasChapter || hasSection) {
|
|
123
|
+
if (!hasChapter || !hasSection) errors.push('The new concept table requires both Chapter and Section columns.');
|
|
124
|
+
if (!chapterHeadings && table.rows.some((row) => /^\d+$/.test(row.chapterText) && Number(row.chapterText) > 1)) {
|
|
125
|
+
errors.push('A multi-chapter graph needs a ## Chapters list with each chapter title and scope.');
|
|
126
|
+
}
|
|
127
|
+
chapters = chapterHeadings || [{ n: 1, name: metadata.title, description: '' }];
|
|
128
|
+
const chapterByNumber = new Map(chapters.map((chapter) => [chapter.n, chapter]));
|
|
129
|
+
const positions = new Map();
|
|
130
|
+
for (const row of table.rows) {
|
|
131
|
+
if (!/^\d+$/.test(row.chapterText)) {
|
|
132
|
+
errors.push(`Concept ${row.number} has invalid or missing Chapter value "${row.chapterText}". Use a positive chapter number.`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!/^\d+$/.test(row.sectionText)) {
|
|
136
|
+
errors.push(`Concept ${row.number} has invalid or missing Section value "${row.sectionText}". Use a positive section number.`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const chapter = Number(row.chapterText);
|
|
140
|
+
const section = Number(row.sectionText);
|
|
141
|
+
if (chapter < 1) errors.push(`Concept ${row.number} has invalid Chapter ${chapter}. Chapter numbers start at 1.`);
|
|
142
|
+
if (section < 1) errors.push(`Concept ${row.number} has invalid Section ${section}. Section numbers start at 1 in each chapter.`);
|
|
143
|
+
if (!chapterByNumber.has(chapter)) {
|
|
144
|
+
errors.push(`Concept ${row.number} refers to Chapter ${chapter}, which is not listed under ## Chapters.`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const key = `${chapter}:${section}`;
|
|
148
|
+
if (positions.has(key)) errors.push(`Concepts ${positions.get(key)} and ${row.number} both use Chapter ${chapter}, Section ${section}.`);
|
|
149
|
+
else positions.set(key, row.number);
|
|
150
|
+
mappings.set(row.number, { chapter, section });
|
|
151
|
+
}
|
|
152
|
+
for (const chapter of chapters) {
|
|
153
|
+
const sectionNumbers = table.rows
|
|
154
|
+
.map((row) => mappings.get(row.number))
|
|
155
|
+
.filter((mapping) => mapping?.chapter === chapter.n)
|
|
156
|
+
.map((mapping) => mapping.section)
|
|
157
|
+
.sort((a, b) => a - b);
|
|
158
|
+
sectionNumbers.forEach((section, index) => {
|
|
159
|
+
if (section !== index + 1) errors.push(`Chapter ${chapter.n} has nonconsecutive Section numbering at ${section}. Sections must start at 1 with no gaps.`);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
} else if (chapterHeadings || structureHeadings) {
|
|
163
|
+
const legacy = !chapterHeadings;
|
|
164
|
+
chapters = chapterHeadings || structureHeadings;
|
|
165
|
+
if (legacy && !hasBlock) errors.push('## Structure requires a Block column to map concepts to chapters.');
|
|
166
|
+
const chapterByNumber = new Map(chapters.map((chapter) => [chapter.n, chapter]));
|
|
167
|
+
for (const row of table.rows) {
|
|
168
|
+
const value = legacy ? row.blockText : row.chapterText;
|
|
169
|
+
const field = legacy ? 'Block' : 'Chapter';
|
|
170
|
+
if (!/^\d+$/.test(value)) {
|
|
171
|
+
errors.push(`Concept ${row.number} has invalid or missing ${field} value "${value}". Use a positive chapter number.`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const chapter = Number(value);
|
|
175
|
+
if (chapter < 1) errors.push(`Concept ${row.number} has invalid ${field} ${chapter}. Chapter numbers start at 1.`);
|
|
176
|
+
if (!chapterByNumber.has(chapter)) {
|
|
177
|
+
errors.push(`Concept ${row.number} refers to ${field} ${chapter}, which has no matching entry under ## ${legacy ? 'Structure' : 'Chapters'}.`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const section = table.rows.filter((candidate) => {
|
|
181
|
+
const mapped = legacy ? candidate.blockText : candidate.chapterText;
|
|
182
|
+
return /^\d+$/.test(mapped) && Number(mapped) === chapter && candidate.number < row.number;
|
|
183
|
+
}).length + 1;
|
|
184
|
+
mappings.set(row.number, { chapter, section });
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
chapters = [{ n: 1, name: metadata.title, description: '' }];
|
|
188
|
+
table.rows.forEach((row, index) => mappings.set(row.number, { chapter: 1, section: index + 1 }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (errors.length) throw graphError(errors);
|
|
192
|
+
return { ...metadata, chapters, rows: table.rows, mappings };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseLecture(file) {
|
|
196
|
+
const filename = file.name || 'lecture.md';
|
|
197
|
+
const text = String(file.text ?? '').replace(/^\uFEFF/, '');
|
|
198
|
+
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
199
|
+
if (!frontmatter) throw new Error(`${filename}: malformed frontmatter. Start with --- and close it with --- on its own line.`);
|
|
200
|
+
const fields = new Map();
|
|
201
|
+
for (const line of frontmatter[1].split(/\r?\n/)) {
|
|
202
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
203
|
+
const match = line.match(/^([A-Za-z][\w-]*):\s*(.*?)\s*$/);
|
|
204
|
+
if (!match) throw new Error(`${filename}: malformed frontmatter line "${line}". Use key: value.`);
|
|
205
|
+
const key = match[1].toLowerCase();
|
|
206
|
+
if (fields.has(key)) throw new Error(`${filename}: duplicate frontmatter field "${key}".`);
|
|
207
|
+
fields.set(key, match[2].replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_, double, single) => double ?? single));
|
|
208
|
+
}
|
|
209
|
+
if (/^---\r?\n(?:[A-Za-z][\w-]*:.*\r?\n)+---(?:\r?\n|$)/m.test(text.slice(frontmatter[0].length))) {
|
|
210
|
+
throw new Error(`${filename}: contains a second frontmatter block.`);
|
|
211
|
+
}
|
|
212
|
+
const status = fields.get('status');
|
|
213
|
+
if (status !== undefined && status !== 'draft') throw new Error(`${filename}: frontmatter status must be "draft" or absent.`);
|
|
214
|
+
if (status === 'draft' && fields.get('date')) throw new Error(`${filename}: a draft has no teaching date.`);
|
|
215
|
+
if (status !== 'draft' && !fields.get('date')) throw new Error(`${filename}: frontmatter needs a non-empty date field.`);
|
|
216
|
+
const conceptText = fields.get('concept') || '';
|
|
217
|
+
if (!/^\d+$/.test(conceptText) || Number(conceptText) < 1) {
|
|
218
|
+
throw new Error(`${filename}: frontmatter concept must be a positive integer.`);
|
|
219
|
+
}
|
|
220
|
+
const body = text.slice(frontmatter[0].length);
|
|
221
|
+
const title = body.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim() || '';
|
|
222
|
+
if (!title) throw new Error(`${filename}: has no first-level title.`);
|
|
223
|
+
return {
|
|
224
|
+
filename,
|
|
225
|
+
concept: Number(conceptText),
|
|
226
|
+
status: status === 'draft' ? 'draft' : 'lecture',
|
|
227
|
+
title,
|
|
228
|
+
display: titleCase(title),
|
|
229
|
+
body: body.replace(/^#\s+.+?\s*$(?:\r?\n)?/m, '').replace(/^(?:\r?\n)+/, ''),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function demote(body) {
|
|
234
|
+
return body.replace(/^(#{1,6})\s/gm, (_, marks) => '#'.repeat(Math.min(6, marks.length + 1)) + ' ');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Parse a graph and its lecture files into ordered book chapters and sections. */
|
|
238
|
+
export function parseBookData(graphText, lectureFiles, { topicFallback = '' } = {}) {
|
|
239
|
+
const graph = parseGraph(graphText, topicFallback);
|
|
240
|
+
if (!lectureFiles.length) throw new Error('No lecture files found. Add Markdown lectures under the Course lectures/ folder.');
|
|
241
|
+
|
|
242
|
+
const lectures = lectureFiles.map(parseLecture);
|
|
243
|
+
const byConcept = new Map();
|
|
244
|
+
for (const lecture of lectures) {
|
|
245
|
+
if (byConcept.has(lecture.concept)) {
|
|
246
|
+
throw new Error(`${lecture.filename}: concept ${lecture.concept} is already mapped by ${byConcept.get(lecture.concept).filename}. Each concept must have one lecture file.`);
|
|
247
|
+
}
|
|
248
|
+
byConcept.set(lecture.concept, lecture);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const conceptNumbers = new Set(graph.rows.map((row) => row.number));
|
|
252
|
+
const orphans = lectures.filter((lecture) => !conceptNumbers.has(lecture.concept)).map((lecture) => lecture.filename);
|
|
253
|
+
const units = [];
|
|
254
|
+
const chapters = [];
|
|
255
|
+
for (const chapter of graph.chapters) {
|
|
256
|
+
const chapterUnits = graph.rows
|
|
257
|
+
.map((row) => {
|
|
258
|
+
const mapping = graph.mappings.get(row.number);
|
|
259
|
+
const lecture = byConcept.get(row.number);
|
|
260
|
+
if (!lecture || mapping?.chapter !== chapter.n) return null;
|
|
261
|
+
return {
|
|
262
|
+
concept: row.number,
|
|
263
|
+
title: row.name,
|
|
264
|
+
chapter: chapter.n,
|
|
265
|
+
section: mapping.section,
|
|
266
|
+
status: lecture.status,
|
|
267
|
+
body: demote(lecture.body).trim(),
|
|
268
|
+
sourceTitle: lecture.title,
|
|
269
|
+
};
|
|
270
|
+
})
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.sort((a, b) => a.section - b.section);
|
|
273
|
+
if (!chapterUnits.length) continue;
|
|
274
|
+
chapters.push({ n: chapter.n, name: titleCase(chapter.name), description: chapter.description, count: chapterUnits.length });
|
|
275
|
+
units.push(...chapterUnits);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!units.length) throw new Error(`No lecture files match concepts in graph.md.${orphans.length ? ` Orphan lecture files: ${orphans.join(', ')}.` : ''}`);
|
|
279
|
+
units.forEach((unit, index) => { unit.index = index; });
|
|
280
|
+
const cjk = units.map((unit) => `${unit.sourceTitle}${unit.body}`).join(' ').match(/[\u4e00-\u9fff]/g) || [];
|
|
281
|
+
return {
|
|
282
|
+
title: graph.title,
|
|
283
|
+
goal: graph.goal,
|
|
284
|
+
lang: cjk.length > 30 ? 'zh' : 'en',
|
|
285
|
+
chapters,
|
|
286
|
+
units,
|
|
287
|
+
orphans,
|
|
288
|
+
};
|
|
289
|
+
}
|