-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.js
More file actions
157 lines (139 loc) · 6.77 KB
/
Copy pathbuild.js
File metadata and controls
157 lines (139 loc) · 6.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
#!/usr/bin/env node
/**
* build.js — NextCourse deck assembler
*
* 读取 courses/<name>/course.meta.md + courses/<name>/slides/*.html
* 组装成 courses/<name>/deck.html (零依赖离线 Reveal.js 课件)
*
* Usage: node build.js <course-name>
* node build.js openclaw_2
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { pkg, courseDir, assetBase, requireCourse } = require('./paths');
// ─── 参数 ────────────────────────────────────────────────────────────────────
const [,, courseName] = process.argv;
if (!courseName) {
console.error('Usage: node build.js <course-name>');
process.exit(1);
}
requireCourse(courseName, 'build');
const COURSE_DIR = courseDir(courseName);
const META_PATH = path.join(COURSE_DIR, 'course.meta.md');
const SLIDES_DIR = path.join(COURSE_DIR, 'slides');
const DECK_PATH = path.join(COURSE_DIR, 'deck.html');
const TMPL_PATH = pkg('templates', 'master_template.html');
// ─── 校验 ────────────────────────────────────────────────────────────────────
for (const [label, p] of [
['course.meta.md', META_PATH],
['slides/', SLIDES_DIR],
['master_template', TMPL_PATH],
]) {
if (!fs.existsSync(p)) {
console.error(`ERROR: ${label} not found at ${p}`);
process.exit(1);
}
}
// ─── 解析 frontmatter ────────────────────────────────────────────────────────
function parseFrontmatter(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) return {};
const fm = {};
for (const line of m[1].split(/\r?\n/)) {
const colon = line.indexOf(':');
if (colon < 1) continue;
const key = line.slice(0, colon).trim();
let val = line.slice(colon + 1).trim();
// 去掉成对的包裹引号: title: "xxx" → xxx
if (/^".*"$/.test(val) || /^'.*'$/.test(val)) val = val.slice(1, -1);
if (key && val) fm[key] = val;
}
return fm;
}
// 配色 → 默认字体集。字体与配色是两根正交的轴,但每套配色有个「原配」搭档;
// course.meta.md 写 fontset: 可以覆盖(例如给严肃课程配 editorial-serif)。
// 这份映射与各配色文件头注释里的「默认字体集」保持一致。
const DEFAULT_FONT_SET = {
'bold-signal': 'impact-sans',
'creative-voltage': 'voltage-sans',
'dark-botanical': 'garamond-serif',
'notebook-tabs': 'didone-serif',
'swiss-modern': 'grotesk-sans',
'dark-ocean': 'modern-sans',
'warm-sand': 'modern-sans',
'standard-default': 'modern-sans',
};
const meta = parseFrontmatter(fs.readFileSync(META_PATH, 'utf8'));
const title = meta.title || courseName;
const template = meta.template || 'standard';
const theme = meta.theme || 'standard-default';
const fontSet = meta.fontset || DEFAULT_FONT_SET[theme] || 'modern-sans';
// theme / template / fontset 必须对应实际存在的 CSS 文件,否则 deck 会静默无样式
const themeCss = pkg('shared_styles', 'color-schemes', `${theme}.css`);
const templateCss = pkg('shared_styles', 'themes', `${template}.css`);
const fontSetCss = pkg('shared_styles', 'font-sets', `${fontSet}.css`);
if (!fs.existsSync(themeCss)) {
const available = fs.readdirSync(pkg('shared_styles', 'color-schemes'))
.filter(f => f.endsWith('.css')).map(f => f.replace(/\.css$/, '')).join(', ');
console.error(`ERROR: theme "${theme}" 不存在 (course.meta.md)`);
console.error(` 可选: ${available}`);
process.exit(1);
}
if (!fs.existsSync(templateCss)) {
console.error(`ERROR: template "${template}" 不存在 (course.meta.md), 可选: standard`);
process.exit(1);
}
if (!fs.existsSync(fontSetCss)) {
const available = fs.readdirSync(pkg('shared_styles', 'font-sets'))
.filter(f => f.endsWith('.css')).map(f => f.replace(/\.css$/, '')).join(', ');
console.error(`ERROR: fontset "${fontSet}" 不存在 (course.meta.md)`);
console.error(` 可选: ${available}`);
process.exit(1);
}
function escapeHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
// ─── 收集 slides ────────────────────────────────────────────────────────────
// 按文件名里的数字排序, 不能用默认的字典序 —— 超过 99 页时
// 'slide-100.html' 会排到 'slide-20.html' 前面, 整份 deck 的顺序就乱了
const slideFiles = fs.readdirSync(SLIDES_DIR)
.filter(f => f.endsWith('.html'))
.sort((a, b) => {
const na = (a.match(/\d+/) || [])[0];
const nb = (b.match(/\d+/) || [])[0];
if (na !== undefined && nb !== undefined && Number(na) !== Number(nb)) {
return Number(na) - Number(nb);
}
return a.localeCompare(b);
});
if (slideFiles.length === 0) {
console.error(`ERROR: no .html files found in ${SLIDES_DIR}`);
process.exit(1);
}
const slidesContent = slideFiles
.map(f => {
const src = fs.readFileSync(path.join(SLIDES_DIR, f), 'utf8').trim();
// 原样嵌入, 不要给每行补缩进 —— <pre> 会把补进去的空格当内容渲染,
// 代码块/提示词块的第二行起会整体右移一截 (2026-09-09 修)
return ` <!-- ${f} -->\n${src}`;
})
.join('\n\n');
// ─── 组装 ────────────────────────────────────────────────────────────────────
const tmpl = fs.readFileSync(TMPL_PATH, 'utf8');
const deck = tmpl
.replace(/\{\{ASSET_BASE\}\}/g, assetBase(courseName))
.replace(/\{\{COURSE_TITLE\}\}/g, escapeHtml(title))
.replace(/\{\{COLOR_SCHEME\}\}/g, theme)
.replace(/\{\{FONT_SET\}\}/g, fontSet)
.replace(/\{\{TEMPLATE\}\}/g, template)
.replace(/\{\{SLIDES_CONTENT\}\}/g, () => slidesContent);
fs.writeFileSync(DECK_PATH, deck, 'utf8');
console.log(`\nNextCourse Build — ${courseName}`);
console.log(`${'─'.repeat(40)}`);
console.log(` Theme : ${theme}`);
console.log(` Font set : ${fontSet}${meta.fontset ? '' : ' (配色默认)'}`);
console.log(` Template : ${template}`);
console.log(` Slides : ${slideFiles.length}`);
console.log(` Output : ${DECK_PATH}`);
console.log(`\n ✓ Build complete. Open deck.html in your browser.\n`);