learn-anything-cli 0.5.1 → 1.1.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/dist/cli/index.js +32 -4
- package/dist/core/serve.d.ts +9 -0
- package/dist/core/serve.js +99 -0
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/locales/en.js +12 -0
- package/dist/i18n/locales/zh-CN.js +12 -0
- package/dist/i18n/types.d.ts +13 -0
- package/package.json +3 -2
- package/site-dist/assets/Dashboard-EDFO7_2g.js +1 -0
- package/site-dist/assets/TopicPage-BvtKWu1I.js +1 -0
- package/site-dist/assets/index-B42o4lnG.css +1 -0
- package/site-dist/assets/index-BaQnKmab.js +49 -0
- package/site-dist/index.html +19 -0
- package/site-dist/serve.mjs +362 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Learn Anything</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link
|
|
10
|
+
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..700;1,14..32,400..700&display=swap"
|
|
11
|
+
rel="stylesheet"
|
|
12
|
+
/>
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BaQnKmab.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B42o4lnG.css">
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="app"></div>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global process, setInterval, clearInterval, setTimeout, clearTimeout, URL */
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { readFileSync, existsSync, readdirSync, statSync, watch } from 'node:fs';
|
|
5
|
+
import { join, extname, resolve, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const STATIC_DIR = __dirname;
|
|
10
|
+
const TOPICS_DIR = process.env.TOPICS_DIR || join(__dirname, '..', '..', '.learn', 'topics');
|
|
11
|
+
const PORT = parseInt(process.env.PORT || '24278', 10);
|
|
12
|
+
|
|
13
|
+
const MIME = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8',
|
|
15
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.json': 'application/json; charset=utf-8',
|
|
18
|
+
'.md': 'text/plain; charset=utf-8',
|
|
19
|
+
'.svg': 'image/svg+xml',
|
|
20
|
+
'.png': 'image/png',
|
|
21
|
+
'.ico': 'image/x-icon',
|
|
22
|
+
'.woff2': 'font/woff2',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
/* Helpers */
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
|
|
29
|
+
function json(res, data, status = 200) {
|
|
30
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
31
|
+
res.end(JSON.stringify(data));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function serveStatic(res, pathname) {
|
|
35
|
+
const rawPath = join(STATIC_DIR, pathname);
|
|
36
|
+
const filePath = resolve(rawPath);
|
|
37
|
+
if (!filePath.startsWith(resolve(STATIC_DIR))) {
|
|
38
|
+
res.writeHead(403);
|
|
39
|
+
res.end('Forbidden');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!existsSync(filePath)) {
|
|
43
|
+
res.writeHead(404);
|
|
44
|
+
res.end('Not Found');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const stat = statSync(filePath);
|
|
49
|
+
if (stat.isDirectory()) {
|
|
50
|
+
res.writeHead(404);
|
|
51
|
+
res.end('Not Found');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const ext = extname(filePath).toLowerCase();
|
|
55
|
+
const contentType = MIME[ext] || 'application/octet-stream';
|
|
56
|
+
const content = readFileSync(filePath);
|
|
57
|
+
res.writeHead(200, {
|
|
58
|
+
'Content-Type': contentType,
|
|
59
|
+
'Cache-Control': 'public, max-age=3600',
|
|
60
|
+
});
|
|
61
|
+
res.end(content);
|
|
62
|
+
} catch {
|
|
63
|
+
res.writeHead(500);
|
|
64
|
+
res.end('Internal Server Error');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeReadJson(filePath) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeReadText(filePath) {
|
|
77
|
+
try {
|
|
78
|
+
return readFileSync(filePath, 'utf-8');
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------------------ */
|
|
85
|
+
/* File watcher → SSE */
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
|
|
88
|
+
const sseClients = new Set();
|
|
89
|
+
|
|
90
|
+
function broadcastReload() {
|
|
91
|
+
for (const res of sseClients) {
|
|
92
|
+
try {
|
|
93
|
+
res.write('data: reload\n\n');
|
|
94
|
+
} catch {
|
|
95
|
+
sseClients.delete(res);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let heartbeatTimer = null;
|
|
101
|
+
|
|
102
|
+
function startHeartbeat() {
|
|
103
|
+
if (heartbeatTimer) return;
|
|
104
|
+
heartbeatTimer = setInterval(() => {
|
|
105
|
+
for (const res of sseClients) {
|
|
106
|
+
try {
|
|
107
|
+
res.write(': heartbeat\n\n');
|
|
108
|
+
} catch {
|
|
109
|
+
sseClients.delete(res);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (sseClients.size === 0) {
|
|
113
|
+
clearInterval(heartbeatTimer);
|
|
114
|
+
heartbeatTimer = null;
|
|
115
|
+
}
|
|
116
|
+
}, 15000);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let watcherReady = false;
|
|
120
|
+
|
|
121
|
+
function startWatcher() {
|
|
122
|
+
if (watcherReady) return;
|
|
123
|
+
watcherReady = true;
|
|
124
|
+
try {
|
|
125
|
+
let timer;
|
|
126
|
+
watch(TOPICS_DIR, { recursive: true }, (_event, _filename) => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
timer = setTimeout(broadcastReload, 200);
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
// topics dir may not exist yet
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ------------------------------------------------------------------ */
|
|
136
|
+
/* API: topic summaries */
|
|
137
|
+
/* ------------------------------------------------------------------ */
|
|
138
|
+
|
|
139
|
+
function buildTopicSummaries() {
|
|
140
|
+
const summaries = [];
|
|
141
|
+
if (!existsSync(TOPICS_DIR)) return summaries;
|
|
142
|
+
const entries = readdirSync(TOPICS_DIR, { withFileTypes: true });
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.isDirectory()) continue;
|
|
145
|
+
const slug = entry.name;
|
|
146
|
+
const state = safeReadJson(join(TOPICS_DIR, slug, 'state.json'));
|
|
147
|
+
if (!state) continue;
|
|
148
|
+
const allConcepts = (state.domains || []).flatMap((d) => d.concepts || []);
|
|
149
|
+
const total = allConcepts.length;
|
|
150
|
+
const mastered = allConcepts.filter((c) => c.status === 'mastered').length;
|
|
151
|
+
summaries.push({
|
|
152
|
+
slug,
|
|
153
|
+
name: state.topic || slug,
|
|
154
|
+
domainCount: (state.domains || []).length,
|
|
155
|
+
totalConcepts: total,
|
|
156
|
+
masteredCount: mastered,
|
|
157
|
+
percentage: total > 0 ? Math.round((mastered / total) * 100) : 0,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
summaries.sort((a, b) => a.name.localeCompare(b.name));
|
|
161
|
+
return summaries;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ------------------------------------------------------------------ */
|
|
165
|
+
/* API: topic data (state, knowledge-map, sessions, exercises) */
|
|
166
|
+
/* ------------------------------------------------------------------ */
|
|
167
|
+
|
|
168
|
+
function buildTopicData(slug) {
|
|
169
|
+
const topicDir = join(TOPICS_DIR, slug);
|
|
170
|
+
if (!existsSync(topicDir)) return null;
|
|
171
|
+
|
|
172
|
+
const state = safeReadJson(join(topicDir, 'state.json'));
|
|
173
|
+
const knowledgeMap = safeReadText(join(topicDir, 'knowledge-map.md')) || '';
|
|
174
|
+
|
|
175
|
+
const sessions = {};
|
|
176
|
+
const rootSessions = [];
|
|
177
|
+
const sessionsDir = join(topicDir, 'sessions');
|
|
178
|
+
if (existsSync(sessionsDir)) {
|
|
179
|
+
const sEntries = readdirSync(sessionsDir, { withFileTypes: true });
|
|
180
|
+
for (const entry of sEntries) {
|
|
181
|
+
if (entry.isDirectory()) {
|
|
182
|
+
const domainDir = join(sessionsDir, entry.name);
|
|
183
|
+
const files = readdirSync(domainDir)
|
|
184
|
+
.filter((f) => f.endsWith('.md'))
|
|
185
|
+
.map((f) => ({ filename: f, path: `/topics/${slug}/sessions/${entry.name}/${f}` }))
|
|
186
|
+
.sort((a, b) => b.filename.localeCompare(a.filename));
|
|
187
|
+
if (files.length > 0) sessions[entry.name] = files;
|
|
188
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
189
|
+
rootSessions.push({
|
|
190
|
+
filename: entry.name,
|
|
191
|
+
path: `/topics/${slug}/sessions/${entry.name}`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
rootSessions.sort((a, b) => b.filename.localeCompare(a.filename));
|
|
197
|
+
|
|
198
|
+
const exercises = [];
|
|
199
|
+
const rootExercises = [];
|
|
200
|
+
const exercisesDir = join(topicDir, 'exercises');
|
|
201
|
+
const nameMap = new Map();
|
|
202
|
+
if (state) {
|
|
203
|
+
for (const domain of state.domains || []) {
|
|
204
|
+
for (const concept of domain.concepts) {
|
|
205
|
+
nameMap.set(concept.slug, concept.name);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (existsSync(exercisesDir)) {
|
|
210
|
+
const raw = new Map();
|
|
211
|
+
const eEntries = readdirSync(exercisesDir, { withFileTypes: true });
|
|
212
|
+
for (const entry of eEntries) {
|
|
213
|
+
if (entry.isDirectory()) {
|
|
214
|
+
const conceptDir = join(exercisesDir, entry.name);
|
|
215
|
+
const files = readdirSync(conceptDir).map((f) => ({
|
|
216
|
+
name: f,
|
|
217
|
+
path: `/topics/${slug}/exercises/${entry.name}/${f}`,
|
|
218
|
+
}));
|
|
219
|
+
raw.set(entry.name, files);
|
|
220
|
+
} else if (entry.isFile()) {
|
|
221
|
+
rootExercises.push({
|
|
222
|
+
name: entry.name,
|
|
223
|
+
path: `/topics/${slug}/exercises/${entry.name}`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const [conceptSlug, files] of raw) {
|
|
228
|
+
exercises.push({
|
|
229
|
+
conceptSlug,
|
|
230
|
+
conceptName: nameMap.get(conceptSlug) || conceptSlug,
|
|
231
|
+
files,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
exercises.sort((a, b) => a.conceptName.localeCompare(b.conceptName));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { state, knowledgeMap, sessions, rootSessions, exercises, rootExercises };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ------------------------------------------------------------------ */
|
|
241
|
+
/* API: file content */
|
|
242
|
+
/* ------------------------------------------------------------------ */
|
|
243
|
+
|
|
244
|
+
function serveFileContent(res, url) {
|
|
245
|
+
const reqUrl = new URL(url, 'http://localhost');
|
|
246
|
+
let relPath = reqUrl.searchParams.get('path');
|
|
247
|
+
if (!relPath) {
|
|
248
|
+
res.writeHead(404);
|
|
249
|
+
res.end('Not Found');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// Convert API path to filesystem path
|
|
253
|
+
// API paths like: /topics/javascript/sessions/language-basics/2026-06-14.md
|
|
254
|
+
// Map to filesystem: TOPICS_DIR/javascript/sessions/language-basics/2026-06-14.md
|
|
255
|
+
const match = relPath.match(/^\/topics\/(.+)/);
|
|
256
|
+
if (!match) {
|
|
257
|
+
res.writeHead(404);
|
|
258
|
+
res.end('Not Found');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const relativePart = match[1];
|
|
262
|
+
if (relativePart.includes('..')) {
|
|
263
|
+
res.writeHead(403);
|
|
264
|
+
res.end('Forbidden');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let filePath = join(TOPICS_DIR, relativePart);
|
|
268
|
+
filePath = resolve(filePath);
|
|
269
|
+
if (!filePath.startsWith(resolve(TOPICS_DIR))) {
|
|
270
|
+
res.writeHead(403);
|
|
271
|
+
res.end('Forbidden');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!existsSync(filePath)) {
|
|
275
|
+
res.writeHead(404);
|
|
276
|
+
res.end('Not Found');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
281
|
+
const ext = extname(filePath).toLowerCase();
|
|
282
|
+
const contentType = MIME[ext] || 'text/plain; charset=utf-8';
|
|
283
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
284
|
+
res.end(content);
|
|
285
|
+
} catch {
|
|
286
|
+
res.writeHead(500);
|
|
287
|
+
res.end('Internal Server Error');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ------------------------------------------------------------------ */
|
|
292
|
+
/* HTTP Server */
|
|
293
|
+
/* ------------------------------------------------------------------ */
|
|
294
|
+
|
|
295
|
+
function handler(req, res) {
|
|
296
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
297
|
+
const pathname = url.pathname;
|
|
298
|
+
|
|
299
|
+
// API routes
|
|
300
|
+
if (pathname === '/api/topics') {
|
|
301
|
+
return json(res, buildTopicSummaries());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const topicMatch = pathname.match(/^\/api\/topics\/([^/]+)$/);
|
|
305
|
+
if (topicMatch) {
|
|
306
|
+
const data = buildTopicData(topicMatch[1]);
|
|
307
|
+
if (!data) {
|
|
308
|
+
return json(res, { error: 'Topic not found' }, 404);
|
|
309
|
+
}
|
|
310
|
+
return json(res, data);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const fileMatch = pathname.match(/^\/api\/file$/);
|
|
314
|
+
if (fileMatch) {
|
|
315
|
+
return serveFileContent(res, req.url);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// SSE
|
|
319
|
+
if (pathname === '/api/events') {
|
|
320
|
+
res.writeHead(200, {
|
|
321
|
+
'Content-Type': 'text/event-stream',
|
|
322
|
+
'Cache-Control': 'no-cache',
|
|
323
|
+
Connection: 'keep-alive',
|
|
324
|
+
'Access-Control-Allow-Origin': '*',
|
|
325
|
+
});
|
|
326
|
+
res.write('data: connected\n\n');
|
|
327
|
+
sseClients.add(res);
|
|
328
|
+
startWatcher();
|
|
329
|
+
startHeartbeat();
|
|
330
|
+
req.on('close', () => {
|
|
331
|
+
sseClients.delete(res);
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// SPA fallback: serve index.html for non-file paths
|
|
337
|
+
if (!pathname.includes('.') || pathname === '/') {
|
|
338
|
+
const indexPath = join(STATIC_DIR, 'index.html');
|
|
339
|
+
if (existsSync(indexPath)) {
|
|
340
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
341
|
+
res.end(readFileSync(indexPath, 'utf-8'));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Static files
|
|
347
|
+
const cleanPath = pathname === '/' ? '/index.html' : pathname;
|
|
348
|
+
serveStatic(res, cleanPath);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const server = createServer(handler);
|
|
352
|
+
server.listen(PORT, () => {
|
|
353
|
+
process.stdout.write(`SITE_READY|http://localhost:${PORT}\n`);
|
|
354
|
+
startWatcher();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
server.on('close', () => {
|
|
358
|
+
if (heartbeatTimer) {
|
|
359
|
+
clearInterval(heartbeatTimer);
|
|
360
|
+
heartbeatTimer = null;
|
|
361
|
+
}
|
|
362
|
+
});
|