resuml 1.20.1 → 2.0.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/DOCS.md +314 -0
- package/README.md +7 -2
- package/dist/{chunk-KRJMZ2RQ.js → chunk-GRIYYG45.js} +242 -2
- package/dist/chunk-GRIYYG45.js.map +1 -0
- package/dist/index.d.ts +422 -3
- package/dist/index.js +119 -54
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +4 -8
- package/dist/mcp/server.js.map +1 -1
- package/package.json +26 -52
- package/dist/api.d.ts +0 -9
- package/dist/api.js +0 -20
- package/dist/api.js.map +0 -1
- package/dist/chunk-4ZOTZUAW.js +0 -6666
- package/dist/chunk-4ZOTZUAW.js.map +0 -1
- package/dist/chunk-JP7UCR3P.js +0 -182
- package/dist/chunk-JP7UCR3P.js.map +0 -1
- package/dist/chunk-KRJMZ2RQ.js.map +0 -1
- package/dist/chunk-ZLA7NFYP.js +0 -90
- package/dist/chunk-ZLA7NFYP.js.map +0 -1
- package/dist/index-yHdKpxms.d.ts +0 -422
- package/dist/themeLoader-ZGWEGYXG.js +0 -7
- package/dist/themeLoader-ZGWEGYXG.js.map +0 -1
- package/scripts/build-builder.js +0 -25
- package/scripts/build-skills-db.js +0 -314
- package/scripts/bundle-themes.js +0 -1104
- package/scripts/dev-server.js +0 -392
- package/scripts/enrich-themes-manifest.mjs +0 -156
- package/scripts/generate-types.cjs +0 -55
- package/scripts/mcp-call.mjs +0 -99
- package/scripts/quick-bundle.cjs +0 -129
- package/scripts/render-theme-thumbs.mjs +0 -117
- package/scripts/test-mcp.mjs +0 -583
package/scripts/dev-server.js
DELETED
|
@@ -1,392 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local dev server for the resuml builder.
|
|
3
|
-
* - Watches & rebuilds the frontend with esbuild
|
|
4
|
-
* - Serves docs/ statically
|
|
5
|
-
* - Handles /api/render, /api/themes locally (same logic as Vercel functions)
|
|
6
|
-
*
|
|
7
|
-
* Usage: node scripts/dev-server.js [--port 3000]
|
|
8
|
-
*/
|
|
9
|
-
import http from 'node:http';
|
|
10
|
-
import fs from 'node:fs';
|
|
11
|
-
import os from 'node:os';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { createRequire } from 'node:module';
|
|
15
|
-
import { execFileSync } from 'node:child_process';
|
|
16
|
-
import { context } from 'esbuild';
|
|
17
|
-
|
|
18
|
-
const require = createRequire(import.meta.url);
|
|
19
|
-
|
|
20
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
const ROOT = path.resolve(__dirname, '..');
|
|
22
|
-
const DOCS_DIR = path.resolve(ROOT, 'docs');
|
|
23
|
-
const PORT = parseInt(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : '3000', 10);
|
|
24
|
-
|
|
25
|
-
// ── MIME types ──────────────────────────────────────────────────────
|
|
26
|
-
const MIME = {
|
|
27
|
-
'.html': 'text/html',
|
|
28
|
-
'.js': 'text/javascript',
|
|
29
|
-
'.mjs': 'text/javascript',
|
|
30
|
-
'.css': 'text/css',
|
|
31
|
-
'.json': 'application/json',
|
|
32
|
-
'.png': 'image/png',
|
|
33
|
-
'.jpg': 'image/jpeg',
|
|
34
|
-
'.svg': 'image/svg+xml',
|
|
35
|
-
'.ico': 'image/x-icon',
|
|
36
|
-
'.map': 'application/json',
|
|
37
|
-
'.woff': 'font/woff',
|
|
38
|
-
'.woff2': 'font/woff2',
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
// ── Theme helpers (CDN-based: fetch tarball + extract + install deps) ──
|
|
42
|
-
const SAFE_NAME = /^[a-zA-Z0-9._-]+$/;
|
|
43
|
-
const THEME_CACHE_DIR = path.join(os.tmpdir(), 'resuml-themes');
|
|
44
|
-
|
|
45
|
-
function toPackageName(theme) {
|
|
46
|
-
if (!SAFE_NAME.test(theme)) throw new Error('Invalid theme name');
|
|
47
|
-
return theme.startsWith('jsonresume-theme-') ? theme : `jsonresume-theme-${theme}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function ensureThemeInstalled(theme) {
|
|
51
|
-
const pkg = toPackageName(theme);
|
|
52
|
-
const pkgDir = path.join(THEME_CACHE_DIR, pkg);
|
|
53
|
-
|
|
54
|
-
if (fs.existsSync(path.join(pkgDir, 'package.json'))) {
|
|
55
|
-
return pkgDir;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
console.log(`📦 Downloading ${pkg} from npm registry...`);
|
|
59
|
-
|
|
60
|
-
// Fetch tarball URL
|
|
61
|
-
const metaRes = await fetch(`https://registry.npmjs.org/${pkg}/latest`);
|
|
62
|
-
if (!metaRes.ok) throw new Error(`Theme "${pkg}" not found on npm (${metaRes.status})`);
|
|
63
|
-
const meta = await metaRes.json();
|
|
64
|
-
const tarballUrl = meta.dist.tarball;
|
|
65
|
-
|
|
66
|
-
// Download and extract
|
|
67
|
-
const tarRes = await fetch(tarballUrl);
|
|
68
|
-
const buf = Buffer.from(await tarRes.arrayBuffer());
|
|
69
|
-
fs.mkdirSync(pkgDir, { recursive: true });
|
|
70
|
-
const tarPath = path.join(THEME_CACHE_DIR, `${pkg}.tgz`);
|
|
71
|
-
fs.writeFileSync(tarPath, buf);
|
|
72
|
-
execFileSync('tar', ['xzf', tarPath, '-C', pkgDir, '--strip-components=1'], { timeout: 10_000 });
|
|
73
|
-
fs.unlinkSync(tarPath);
|
|
74
|
-
|
|
75
|
-
// Install prod deps (ignore lifecycle scripts for security)
|
|
76
|
-
const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
77
|
-
if (pkgJson.dependencies && Object.keys(pkgJson.dependencies).length > 0) {
|
|
78
|
-
console.log(` Installing dependencies for ${pkg}...`);
|
|
79
|
-
execFileSync('npm', ['install', '--omit=dev', '--ignore-scripts', '--legacy-peer-deps'], {
|
|
80
|
-
timeout: 30_000,
|
|
81
|
-
stdio: 'pipe',
|
|
82
|
-
cwd: pkgDir,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Check if the main entry point exists; if not, the theme needs building
|
|
87
|
-
const mainEntry = pkgJson.main || 'index.js';
|
|
88
|
-
const mainPath = path.join(pkgDir, mainEntry);
|
|
89
|
-
if (!fs.existsSync(mainPath)) {
|
|
90
|
-
const buildScript = pkgJson.scripts?.build || pkgJson.scripts?.prepare;
|
|
91
|
-
if (buildScript) {
|
|
92
|
-
console.log(` 🔨 Theme needs building, installing all deps + running build...`);
|
|
93
|
-
// Install all deps (including devDependencies needed for the build)
|
|
94
|
-
execFileSync('npm', ['install', '--ignore-scripts'], {
|
|
95
|
-
timeout: 60_000,
|
|
96
|
-
stdio: 'pipe',
|
|
97
|
-
cwd: pkgDir,
|
|
98
|
-
});
|
|
99
|
-
execFileSync('npm', ['run', 'build'], {
|
|
100
|
-
timeout: 60_000,
|
|
101
|
-
stdio: 'pipe',
|
|
102
|
-
cwd: pkgDir,
|
|
103
|
-
env: { ...process.env, NODE_ENV: 'production' },
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
console.log(` ✅ ${pkg} ready`);
|
|
109
|
-
return pkgDir;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Returns a Proxy-wrapped array that:
|
|
114
|
-
* - Supports .map/.forEach/.filter on empty arrays (no crash)
|
|
115
|
-
* - Returns a safe empty-string object for out-of-bounds index access (prevents arr[0].prop crash)
|
|
116
|
-
*/
|
|
117
|
-
function createSafeArray(arr) {
|
|
118
|
-
return new Proxy(arr, {
|
|
119
|
-
get(target, prop, receiver) {
|
|
120
|
-
const val = Reflect.get(target, prop, receiver);
|
|
121
|
-
if (val !== undefined) return val;
|
|
122
|
-
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
|
|
123
|
-
return new Proxy({}, { get: (_, p) => typeof p === 'symbol' ? undefined : '' });
|
|
124
|
-
}
|
|
125
|
-
return val;
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Pad resume with safe defaults so themes don't crash on missing fields.
|
|
132
|
-
* All array sections are wrapped in createSafeArray so that:
|
|
133
|
-
* - theme.work.map(fn) works on empty data
|
|
134
|
-
* - theme.work[0].position returns '' instead of crashing
|
|
135
|
-
*/
|
|
136
|
-
function padResume(r) {
|
|
137
|
-
const basics = r.basics || {};
|
|
138
|
-
const location = basics.location || {};
|
|
139
|
-
const safe = { ...r };
|
|
140
|
-
safe.basics = {
|
|
141
|
-
name: '', label: '', image: '', email: '', phone: '', url: '', summary: '',
|
|
142
|
-
...basics,
|
|
143
|
-
location: { address: '', postalCode: '', city: '', countryCode: '', region: '', ...location },
|
|
144
|
-
};
|
|
145
|
-
const arraySections = ['work','volunteer','education','awards','certificates','publications','skills','languages','interests','references','projects'];
|
|
146
|
-
for (const key of arraySections) {
|
|
147
|
-
safe[key] = createSafeArray(Array.isArray(safe[key]) ? safe[key] : []);
|
|
148
|
-
}
|
|
149
|
-
safe.basics.profiles = createSafeArray(Array.isArray(safe.basics.profiles) ? safe.basics.profiles : []);
|
|
150
|
-
return safe;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function renderWithTheme(themeName, resume) {
|
|
154
|
-
const paddedResume = padResume(resume);
|
|
155
|
-
const pkgDir = await ensureThemeInstalled(themeName);
|
|
156
|
-
|
|
157
|
-
// Clear require cache for this dir so hot-reload works
|
|
158
|
-
for (const key of Object.keys(require.cache)) {
|
|
159
|
-
if (key.startsWith(pkgDir)) delete require.cache[key];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
let mod;
|
|
163
|
-
try {
|
|
164
|
-
mod = require(pkgDir);
|
|
165
|
-
} catch (err) {
|
|
166
|
-
const pkg = toPackageName(themeName);
|
|
167
|
-
const hint = err.code === 'MODULE_NOT_FOUND'
|
|
168
|
-
? `. This theme may require a build step that isn't included in the published package.`
|
|
169
|
-
: '';
|
|
170
|
-
throw new Error(`Could not load theme "${pkg}"${hint}`);
|
|
171
|
-
}
|
|
172
|
-
if (typeof mod.render !== 'function') {
|
|
173
|
-
throw new Error(`Theme "${toPackageName(themeName)}" does not export a render function`);
|
|
174
|
-
}
|
|
175
|
-
try {
|
|
176
|
-
// Temporarily change cwd to theme dir so themes that read files
|
|
177
|
-
// with relative paths (e.g. fs.readFileSync('./index.css')) work
|
|
178
|
-
const originalCwd = process.cwd();
|
|
179
|
-
process.chdir(pkgDir);
|
|
180
|
-
try {
|
|
181
|
-
const result = mod.render(paddedResume);
|
|
182
|
-
return result instanceof Promise ? await result : result;
|
|
183
|
-
} finally {
|
|
184
|
-
process.chdir(originalCwd);
|
|
185
|
-
}
|
|
186
|
-
} catch (err) {
|
|
187
|
-
throw new Error(
|
|
188
|
-
`Theme "${toPackageName(themeName)}" crashed while rendering: ${err.message}. ` +
|
|
189
|
-
`This usually means the theme expects resume fields that are missing (e.g. work, education).`
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ── Theme registry (mirrors api/_lib/themeRegistry.ts) ──────────────
|
|
195
|
-
let cachedThemes = null;
|
|
196
|
-
let cacheExpires = 0;
|
|
197
|
-
|
|
198
|
-
async function fetchThemeList() {
|
|
199
|
-
const now = Date.now();
|
|
200
|
-
if (cachedThemes && cacheExpires > now) return cachedThemes;
|
|
201
|
-
|
|
202
|
-
const res = await fetch('https://registry.npmjs.org/-/v1/search?text=jsonresume-theme-&size=250');
|
|
203
|
-
if (!res.ok) throw new Error(`npm registry returned ${res.status}`);
|
|
204
|
-
|
|
205
|
-
const data = await res.json();
|
|
206
|
-
const themes = [];
|
|
207
|
-
for (const result of data.objects || []) {
|
|
208
|
-
const pkg = result.package;
|
|
209
|
-
const name = pkg.name;
|
|
210
|
-
if (!name.startsWith('jsonresume-theme-')) continue;
|
|
211
|
-
const shortName = name.replace('jsonresume-theme-', '');
|
|
212
|
-
themes.push({
|
|
213
|
-
name: shortName,
|
|
214
|
-
displayName: shortName.charAt(0).toUpperCase() + shortName.slice(1).replace(/-/g, ' '),
|
|
215
|
-
description: pkg.description || '',
|
|
216
|
-
version: pkg.version || '0.0.0',
|
|
217
|
-
weeklyDownloads: result.score?.detail?.popularity
|
|
218
|
-
? Math.round(result.score.detail.popularity * 10000)
|
|
219
|
-
: 0,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
themes.sort((a, b) => b.weeklyDownloads - a.weeklyDownloads);
|
|
223
|
-
cachedThemes = themes;
|
|
224
|
-
cacheExpires = now + 3600_000;
|
|
225
|
-
return themes;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ── Parse JSON body ─────────────────────────────────────────────────
|
|
229
|
-
function readBody(req, maxBytes = 512 * 1024) {
|
|
230
|
-
return new Promise((resolve, reject) => {
|
|
231
|
-
const chunks = [];
|
|
232
|
-
let size = 0;
|
|
233
|
-
req.on('data', (chunk) => {
|
|
234
|
-
size += chunk.length;
|
|
235
|
-
if (size > maxBytes) { req.destroy(); reject(new Error('Body too large')); return; }
|
|
236
|
-
chunks.push(chunk);
|
|
237
|
-
});
|
|
238
|
-
req.on('end', () => {
|
|
239
|
-
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
240
|
-
catch { reject(new Error('Invalid JSON')); }
|
|
241
|
-
});
|
|
242
|
-
req.on('error', reject);
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ── API route handlers ──────────────────────────────────────────────
|
|
247
|
-
async function handleApiRender(req, res) {
|
|
248
|
-
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
|
249
|
-
if (req.method !== 'POST') { jsonError(res, 405, 'Method not allowed'); return; }
|
|
250
|
-
|
|
251
|
-
let body;
|
|
252
|
-
try { body = await readBody(req); } catch (e) { jsonError(res, 400, e.message); return; }
|
|
253
|
-
|
|
254
|
-
const { resume, theme } = body;
|
|
255
|
-
if (!resume || typeof resume !== 'object') { jsonError(res, 400, 'resume is required'); return; }
|
|
256
|
-
if (!theme || typeof theme !== 'string' || !SAFE_NAME.test(theme)) { jsonError(res, 400, 'Invalid theme name'); return; }
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
console.log(`🎨 Rendering with theme: ${theme}`);
|
|
260
|
-
const html = await renderWithTheme(theme, resume);
|
|
261
|
-
res.writeHead(200, {
|
|
262
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
263
|
-
'Access-Control-Allow-Origin': '*',
|
|
264
|
-
});
|
|
265
|
-
res.end(html);
|
|
266
|
-
} catch (e) {
|
|
267
|
-
console.error('Render error:', e.message);
|
|
268
|
-
jsonError(res, 500, e.message);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async function handleApiThemes(_req, res) {
|
|
273
|
-
try {
|
|
274
|
-
const themes = await fetchThemeList();
|
|
275
|
-
res.writeHead(200, {
|
|
276
|
-
'Content-Type': 'application/json',
|
|
277
|
-
'Access-Control-Allow-Origin': '*',
|
|
278
|
-
'Cache-Control': 'public, max-age=3600',
|
|
279
|
-
});
|
|
280
|
-
res.end(JSON.stringify(themes));
|
|
281
|
-
} catch (e) {
|
|
282
|
-
jsonError(res, 500, e.message);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function jsonError(res, status, message) {
|
|
287
|
-
res.writeHead(status, {
|
|
288
|
-
'Content-Type': 'application/json',
|
|
289
|
-
'Access-Control-Allow-Origin': '*',
|
|
290
|
-
});
|
|
291
|
-
res.end(JSON.stringify({ error: message }));
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── Static file serving ─────────────────────────────────────────────
|
|
295
|
-
function serveStatic(req, res) {
|
|
296
|
-
let filePath = path.join(DOCS_DIR, req.url === '/' ? 'index.html' : req.url);
|
|
297
|
-
|
|
298
|
-
// SPA fallback: if file doesn't exist and no extension, serve index.html
|
|
299
|
-
if (!fs.existsSync(filePath) && !path.extname(filePath)) {
|
|
300
|
-
filePath = path.join(DOCS_DIR, 'index.html');
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (!fs.existsSync(filePath)) {
|
|
304
|
-
res.writeHead(404);
|
|
305
|
-
res.end('Not found');
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const ext = path.extname(filePath);
|
|
310
|
-
const mime = MIME[ext] || 'application/octet-stream';
|
|
311
|
-
const content = fs.readFileSync(filePath);
|
|
312
|
-
|
|
313
|
-
res.writeHead(200, { 'Content-Type': mime });
|
|
314
|
-
res.end(content);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// ── esbuild watch ───────────────────────────────────────────────────
|
|
318
|
-
async function startBuildWatch() {
|
|
319
|
-
const ctx = await context({
|
|
320
|
-
entryPoints: [path.resolve(ROOT, 'src/builder/index.tsx')],
|
|
321
|
-
bundle: true,
|
|
322
|
-
sourcemap: true,
|
|
323
|
-
format: 'esm',
|
|
324
|
-
target: 'es2022',
|
|
325
|
-
outfile: path.resolve(DOCS_DIR, 'app/main.js'),
|
|
326
|
-
jsx: 'automatic',
|
|
327
|
-
define: { 'process.env.NODE_ENV': '"development"' },
|
|
328
|
-
loader: { '.ts': 'ts', '.tsx': 'tsx' },
|
|
329
|
-
logLevel: 'info',
|
|
330
|
-
// Don't minify in dev for better debugging
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
await ctx.watch();
|
|
334
|
-
console.log('👀 Watching for frontend changes...');
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// ── HTTP server ─────────────────────────────────────────────────────
|
|
338
|
-
async function main() {
|
|
339
|
-
// Build frontend first
|
|
340
|
-
await startBuildWatch();
|
|
341
|
-
|
|
342
|
-
const server = http.createServer(async (req, res) => {
|
|
343
|
-
// CORS preflight
|
|
344
|
-
if (req.method === 'OPTIONS') {
|
|
345
|
-
res.writeHead(200, {
|
|
346
|
-
'Access-Control-Allow-Origin': '*',
|
|
347
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
348
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
349
|
-
});
|
|
350
|
-
res.end();
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
if (url.pathname === '/api/render') {
|
|
358
|
-
await handleApiRender(req, res);
|
|
359
|
-
} else if (url.pathname === '/api/themes') {
|
|
360
|
-
await handleApiThemes(req, res);
|
|
361
|
-
} else {
|
|
362
|
-
serveStatic(req, res);
|
|
363
|
-
}
|
|
364
|
-
} catch (e) {
|
|
365
|
-
console.error('Server error:', e);
|
|
366
|
-
if (!res.headersSent) {
|
|
367
|
-
jsonError(res, 500, 'Internal server error');
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
server.listen(PORT, () => {
|
|
373
|
-
console.log('');
|
|
374
|
-
console.log(` 🚀 resuml builder running at:`);
|
|
375
|
-
console.log(` http://localhost:${PORT}`);
|
|
376
|
-
console.log('');
|
|
377
|
-
console.log(' API endpoints:');
|
|
378
|
-
console.log(` POST http://localhost:${PORT}/api/render`);
|
|
379
|
-
console.log(` GET http://localhost:${PORT}/api/themes`);
|
|
380
|
-
console.log('');
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
main().catch((err) => {
|
|
385
|
-
console.error('Failed to start dev server:', err);
|
|
386
|
-
process.exit(1);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// Prevent theme crashes from killing the server
|
|
390
|
-
process.on('uncaughtException', (err) => {
|
|
391
|
-
console.error('⚠️ Uncaught exception (server still running):', err.message);
|
|
392
|
-
});
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Enrich docs/themes/manifest.json with popularity + render-health signals.
|
|
4
|
-
*
|
|
5
|
-
* For each theme we add:
|
|
6
|
-
* - npmWeeklyDownloads: rough "actual usage" proxy (npm API)
|
|
7
|
-
* - renderOk: whether the bundled theme renders a sample resume without
|
|
8
|
-
* throwing (Playwright + blob worker). Themes that fail here are the
|
|
9
|
-
* ones that silently showed sample data before, surface them as broken.
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* node scripts/enrich-themes-manifest.mjs # both
|
|
13
|
-
* node scripts/enrich-themes-manifest.mjs --downloads-only
|
|
14
|
-
* node scripts/enrich-themes-manifest.mjs --render-only # needs dev server at :3010
|
|
15
|
-
*
|
|
16
|
-
* The render probe is slow (tens of seconds), keep the output committed.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { get } from 'node:https';
|
|
21
|
-
|
|
22
|
-
const MANIFEST = 'docs/themes/manifest.json';
|
|
23
|
-
const DOWNLOADS_BATCH = 'https://api.npmjs.org/downloads/point/last-week/';
|
|
24
|
-
const args = new Set(process.argv.slice(2));
|
|
25
|
-
|
|
26
|
-
function fetchJson(url) {
|
|
27
|
-
return new Promise((resolve, reject) => {
|
|
28
|
-
get(url, { headers: { 'User-Agent': 'resuml-enrich' } }, (res) => {
|
|
29
|
-
let data = '';
|
|
30
|
-
res.on('data', (c) => { data += c; });
|
|
31
|
-
res.on('end', () => {
|
|
32
|
-
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
33
|
-
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
|
34
|
-
});
|
|
35
|
-
}).on('error', reject);
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function enrichDownloads(manifest) {
|
|
40
|
-
console.log(`📦 Fetching npm download counts for ${manifest.length} themes…`);
|
|
41
|
-
// npm batch endpoint accepts up to 128 comma-separated packages
|
|
42
|
-
const CHUNK = 100;
|
|
43
|
-
for (let i = 0; i < manifest.length; i += CHUNK) {
|
|
44
|
-
const slice = manifest.slice(i, i + CHUNK);
|
|
45
|
-
const names = slice.map((t) => `jsonresume-theme-${t.name}`).join(',');
|
|
46
|
-
try {
|
|
47
|
-
const resp = await fetchJson(DOWNLOADS_BATCH + encodeURIComponent(names));
|
|
48
|
-
// Batch response shape: { pkg: { downloads, ... }, ... } or { downloads, ... } for single
|
|
49
|
-
for (const t of slice) {
|
|
50
|
-
const key = `jsonresume-theme-${t.name}`;
|
|
51
|
-
const entry = resp[key] ?? (slice.length === 1 ? resp : null);
|
|
52
|
-
t.npmWeeklyDownloads = entry?.downloads ?? 0;
|
|
53
|
-
}
|
|
54
|
-
} catch (e) {
|
|
55
|
-
console.warn(` batch ${i}-${i + slice.length} failed: ${e.message}`);
|
|
56
|
-
// Fall back per-package so one bad name doesn't kill the batch
|
|
57
|
-
for (const t of slice) {
|
|
58
|
-
try {
|
|
59
|
-
const r = await fetchJson(DOWNLOADS_BATCH + encodeURIComponent(`jsonresume-theme-${t.name}`));
|
|
60
|
-
t.npmWeeklyDownloads = r.downloads ?? 0;
|
|
61
|
-
} catch { t.npmWeeklyDownloads = 0; }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
process.stdout.write(` ${Math.min(i + CHUNK, manifest.length)}/${manifest.length}\r`);
|
|
65
|
-
}
|
|
66
|
-
console.log('');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function enrichRender(manifest) {
|
|
70
|
-
const { chromium } = await import('playwright');
|
|
71
|
-
console.log(`🎭 Probing render for ${manifest.length} themes via Playwright (dev server must be on :3010)…`);
|
|
72
|
-
|
|
73
|
-
const b = await chromium.launch();
|
|
74
|
-
const page = await b.newPage();
|
|
75
|
-
await page.goto('http://localhost:3010', { waitUntil: 'networkidle' });
|
|
76
|
-
|
|
77
|
-
// Installed once on the page; reused per theme
|
|
78
|
-
const sampleResume = {
|
|
79
|
-
basics: {
|
|
80
|
-
name: 'Test User',
|
|
81
|
-
label: 'Engineer',
|
|
82
|
-
email: 'test@example.com',
|
|
83
|
-
phone: '+1-555-0123',
|
|
84
|
-
summary: 'Sample summary for render probe.',
|
|
85
|
-
location: { city: 'City', region: 'Region', countryCode: 'US', address: '1 Way', postalCode: '10000' },
|
|
86
|
-
profiles: [{ network: 'GitHub', username: 'x', url: 'https://github.com/x' }],
|
|
87
|
-
url: 'https://example.com',
|
|
88
|
-
},
|
|
89
|
-
work: [{ name: 'Co', position: 'Engineer', startDate: '2020-01-01', endDate: '2022-01-01', summary: 'S', highlights: ['H'] }],
|
|
90
|
-
skills: [{ name: 'Cat', level: 'Expert', keywords: ['K'] }],
|
|
91
|
-
education: [{ institution: 'U', area: 'A', studyType: 'M', startDate: '2015-01-01', endDate: '2019-01-01' }],
|
|
92
|
-
projects: [{ name: 'P', description: 'D', highlights: ['H'] }],
|
|
93
|
-
languages: [{ language: 'English', fluency: 'Native' }],
|
|
94
|
-
interests: [{ name: 'I', keywords: ['k'] }],
|
|
95
|
-
references: [{ name: 'R', reference: 'Ref' }],
|
|
96
|
-
publications: [{ name: 'Pub', publisher: 'Pr', releaseDate: '2020', summary: 'S' }],
|
|
97
|
-
certificates: [{ name: 'C', date: '2020', issuer: 'Is' }],
|
|
98
|
-
awards: [{ title: 'A', date: '2020', awarder: 'Aw', summary: 'S' }],
|
|
99
|
-
volunteer: [{ organization: 'V', position: 'P', startDate: '2020', summary: 'S', highlights: ['H'] }],
|
|
100
|
-
// Include `meta.palette` so themes like material (which set
|
|
101
|
-
// meta.palette.primary without defensive checks) don't trip the probe
|
|
102
|
-
//, matches what the runtime padResume provides.
|
|
103
|
-
meta: { theme: 'test', palette: { primary: '', secondary: '' } },
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
for (let i = 0; i < manifest.length; i++) {
|
|
107
|
-
const t = manifest[i];
|
|
108
|
-
if (!t.browserCompatible) { t.renderOk = false; continue; }
|
|
109
|
-
const result = await page.evaluate(async ({ name, resume }) => {
|
|
110
|
-
try {
|
|
111
|
-
if (typeof globalThis.process === 'undefined') {
|
|
112
|
-
globalThis.process = {
|
|
113
|
-
env: { NODE_ENV: 'production' }, browser: true, platform: 'browser',
|
|
114
|
-
version: 'v20.0.0', versions: {}, stdout: { write: () => {} }, stderr: { write: () => {} },
|
|
115
|
-
cwd: () => '/', chdir: () => {}, nextTick: (fn) => Promise.resolve().then(fn),
|
|
116
|
-
argv: [], pid: 1, title: 'browser',
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
if (typeof globalThis.global === 'undefined') globalThis.global = globalThis;
|
|
120
|
-
const mod = await import(/* @vite-ignore */ '/themes/' + name + '.js');
|
|
121
|
-
const render = mod.render ?? mod.default?.render;
|
|
122
|
-
if (typeof render !== 'function') return false;
|
|
123
|
-
const out = render(resume);
|
|
124
|
-
const html = typeof out === 'string' ? out : await out;
|
|
125
|
-
return typeof html === 'string' && html.length > 200;
|
|
126
|
-
} catch { return false; }
|
|
127
|
-
}, { name: t.name, resume: sampleResume });
|
|
128
|
-
t.renderOk = !!result;
|
|
129
|
-
if ((i + 1) % 20 === 0 || i === manifest.length - 1) {
|
|
130
|
-
process.stdout.write(` ${i + 1}/${manifest.length}\r`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
console.log('');
|
|
134
|
-
await b.close();
|
|
135
|
-
|
|
136
|
-
const ok = manifest.filter((t) => t.renderOk).length;
|
|
137
|
-
const broken = manifest.filter((t) => t.browserCompatible && !t.renderOk);
|
|
138
|
-
console.log(` ${ok}/${manifest.length} render successfully. ${broken.length} marked browserCompatible but fail at render time.`);
|
|
139
|
-
if (broken.length) {
|
|
140
|
-
console.log(' Broken at render time:', broken.map((t) => t.name).slice(0, 20).join(', ') + (broken.length > 20 ? '…' : ''));
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function main() {
|
|
145
|
-
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
|
|
146
|
-
const doDownloads = !args.has('--render-only');
|
|
147
|
-
const doRender = !args.has('--downloads-only');
|
|
148
|
-
|
|
149
|
-
if (doDownloads) await enrichDownloads(manifest);
|
|
150
|
-
if (doRender) await enrichRender(manifest);
|
|
151
|
-
|
|
152
|
-
writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2));
|
|
153
|
-
console.log(`✅ Wrote ${MANIFEST}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
main().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
const { execSync } = require('child_process');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
const SCHEMA_URL = 'https://raw.githubusercontent.com/jsonresume/resume-schema/master/schema.json';
|
|
6
|
-
const SCHEMA_FILE = 'resume.schema.json';
|
|
7
|
-
const OUTPUT_DIR = path.join('src', 'types');
|
|
8
|
-
const OUTPUT_FILE = path.join(OUTPUT_DIR, 'resume.ts');
|
|
9
|
-
|
|
10
|
-
function runCommand(command, description) {
|
|
11
|
-
console.log(`⏳ ${description}...`);
|
|
12
|
-
try {
|
|
13
|
-
execSync(command, { stdio: 'inherit' });
|
|
14
|
-
console.log(`✅ ${description} successful!`);
|
|
15
|
-
} catch (error) {
|
|
16
|
-
console.error(`❌ Error during ${description}:`, error.message);
|
|
17
|
-
if (error.stderr) {
|
|
18
|
-
console.error(error.stderr.toString());
|
|
19
|
-
}
|
|
20
|
-
if (error.stdout) {
|
|
21
|
-
console.error(error.stdout.toString());
|
|
22
|
-
}
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
console.log('🚀 Starting type generation process...');
|
|
28
|
-
|
|
29
|
-
// 1. Fetch the schema
|
|
30
|
-
runCommand(`curl -s -L -o ${SCHEMA_FILE} ${SCHEMA_URL}`, 'Fetching latest JSON Resume schema');
|
|
31
|
-
|
|
32
|
-
// 2. Create output directory if it doesn't exist
|
|
33
|
-
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
34
|
-
console.log(`⏳ Creating directory ${OUTPUT_DIR}...`);
|
|
35
|
-
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
36
|
-
console.log(`✅ Directory ${OUTPUT_DIR} created.`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 3. Generate TypeScript types
|
|
40
|
-
runCommand(
|
|
41
|
-
`npx json-schema-to-typescript ${SCHEMA_FILE} -o ${OUTPUT_FILE}`,
|
|
42
|
-
'Generating TypeScript types'
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
// 4. Remove the downloaded schema file
|
|
46
|
-
console.log(`⏳ Removing temporary schema file ${SCHEMA_FILE}...`);
|
|
47
|
-
try {
|
|
48
|
-
fs.unlinkSync(SCHEMA_FILE);
|
|
49
|
-
console.log(`✅ Temporary schema file ${SCHEMA_FILE} removed.`);
|
|
50
|
-
} catch (error) {
|
|
51
|
-
console.error(`❌ Error removing temporary schema file ${SCHEMA_FILE}:`, error.message);
|
|
52
|
-
// Non-fatal, so don't exit
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
console.log('🎉 TypeScript types generated successfully from JSON Resume schema!');
|