resuml 1.5.1 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resuml",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Generate JSON resumes from YAML with theme support",
5
5
  "type": "module",
6
6
  "main": "./dist/api.js",
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * Theme Bundling Script
3
3
  *
4
- * Discovers jsonresume-theme packages from npm, installs them,
5
- * and bundles each into a browser-compatible ES module.
4
+ * Bundles jsonresume-theme packages for the browser with:
5
+ * - CSS extraction (separate .css files)
6
+ * - Build-time snapshots (pre-rendered HTML for instant preview)
7
+ * - Per-theme fs shim with embedded assets
6
8
  *
7
9
  * Output:
8
- * docs/themes/{name}.js — bundled theme module
9
- * docs/themes/manifest.json theme metadata registry
10
+ * docs/themes/{name}.js — bundled render module
11
+ * docs/themes/{name}.css extracted CSS (if any)
12
+ * docs/themes/{name}.snapshot.html— pre-rendered HTML with sample data
13
+ * docs/themes/manifest.json — theme metadata registry
10
14
  *
11
15
  * Usage:
12
16
  * node scripts/bundle-themes.js # Bundle popular themes
@@ -15,12 +19,14 @@
15
19
  */
16
20
 
17
21
  import { build } from 'esbuild';
18
- import { resolve, dirname } from 'path';
22
+ import { resolve, dirname, join } from 'path';
19
23
  import { fileURLToPath } from 'url';
20
24
  import { execSync } from 'child_process';
21
- import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs';
25
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync } from 'fs';
26
+ import { createRequire } from 'module';
22
27
 
23
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const require = createRequire(import.meta.url);
24
30
  const THEMES_DIR = resolve(__dirname, '../docs/themes');
25
31
 
26
32
  // Popular themes that are known to work in the browser
@@ -43,6 +49,214 @@ const POPULAR_THEMES = [
43
49
  'compact',
44
50
  ];
45
51
 
52
+ // Sample resume for generating snapshots
53
+ const SAMPLE_RESUME = {
54
+ basics: {
55
+ name: 'Jane Smith',
56
+ label: 'Senior Software Engineer',
57
+ email: 'jane@example.com',
58
+ phone: '+1-555-987-6543',
59
+ url: 'https://janesmith.dev',
60
+ summary: 'Full-stack engineer with 8+ years of experience building scalable web applications. Passionate about clean architecture, performance optimization, and mentoring teams.',
61
+ location: { city: 'San Francisco', countryCode: 'US', region: 'California' },
62
+ profiles: [
63
+ { network: 'LinkedIn', username: 'janesmith', url: 'https://linkedin.com/in/janesmith' },
64
+ { network: 'GitHub', username: 'janesmith', url: 'https://github.com/janesmith' },
65
+ ],
66
+ },
67
+ work: [
68
+ {
69
+ name: 'Tech Corp',
70
+ position: 'Senior Software Engineer',
71
+ url: 'https://techcorp.com',
72
+ startDate: '2020-03-01',
73
+ summary: 'Lead engineer for the platform team, owning core API infrastructure.',
74
+ highlights: [
75
+ 'Reduced API latency by 45% through caching and query optimization',
76
+ 'Designed microservices architecture serving 2M daily requests',
77
+ 'Mentored 4 junior engineers through technical growth plans',
78
+ ],
79
+ },
80
+ {
81
+ name: 'StartupXYZ',
82
+ position: 'Full Stack Developer',
83
+ startDate: '2017-06-01',
84
+ endDate: '2020-02-28',
85
+ summary: 'Built customer-facing web applications from prototype to production.',
86
+ highlights: [
87
+ 'Built real-time dashboard used by 15,000+ daily active users',
88
+ 'Implemented CI/CD pipeline reducing deploy time from 2 hours to 8 minutes',
89
+ ],
90
+ },
91
+ ],
92
+ education: [
93
+ {
94
+ institution: 'University of Technology',
95
+ area: 'Computer Science',
96
+ studyType: 'Bachelor of Science',
97
+ startDate: '2013-09-01',
98
+ endDate: '2017-05-15',
99
+ score: '3.8',
100
+ },
101
+ ],
102
+ skills: [
103
+ { name: 'Languages', level: 'Expert', keywords: ['TypeScript', 'JavaScript', 'Python', 'Go'] },
104
+ { name: 'Frontend', level: 'Expert', keywords: ['React', 'Next.js', 'HTML/CSS', 'Tailwind'] },
105
+ { name: 'Backend & Cloud', level: 'Advanced', keywords: ['Node.js', 'PostgreSQL', 'AWS', 'Docker'] },
106
+ ],
107
+ projects: [
108
+ {
109
+ name: 'Open Source CLI Tool',
110
+ description: 'Developer productivity tool with 2,000+ GitHub stars',
111
+ url: 'https://github.com/janesmith/cli-tool',
112
+ startDate: '2022-01-01',
113
+ highlights: ['Published to npm with 5,000+ weekly downloads'],
114
+ },
115
+ ],
116
+ // Provide empty arrays for sections themes might iterate over
117
+ volunteer: [],
118
+ awards: [],
119
+ certificates: [],
120
+ publications: [],
121
+ languages: [],
122
+ interests: [],
123
+ references: [],
124
+ };
125
+
126
+ // ── Theme file collection ────────────────────────────────────────────
127
+
128
+ /** Recursively collect text files from a theme directory for the fs shim. */
129
+ function collectThemeFiles(themeDir) {
130
+ const files = {};
131
+ const dirs = {};
132
+
133
+ function walk(dir, relPrefix = '') {
134
+ let entries;
135
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
136
+ catch { return; }
137
+
138
+ const childNames = [];
139
+ for (const entry of entries) {
140
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
141
+ childNames.push(entry.name);
142
+ const full = join(dir, entry.name);
143
+ const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
144
+
145
+ if (entry.isDirectory()) {
146
+ walk(full, rel);
147
+ } else {
148
+ const ext = (entry.name.split('.').pop() || '').toLowerCase();
149
+ if (['css', 'hbs', 'html', 'json', 'txt', 'handlebars', 'mustache', 'template'].includes(ext)) {
150
+ try { files[rel] = readFileSync(full, 'utf-8'); }
151
+ catch { /* skip */ }
152
+ }
153
+ }
154
+ }
155
+ dirs[relPrefix || '.'] = childNames;
156
+ }
157
+
158
+ walk(themeDir);
159
+ return { files, dirs };
160
+ }
161
+
162
+ /** Generate an fs shim that embeds the theme's files. */
163
+ function generateThemeFsShim(themeFiles) {
164
+ const { files, dirs } = themeFiles;
165
+ const lines = [
166
+ `const __files = ${JSON.stringify(files)};`,
167
+ `const __dirs = ${JSON.stringify(dirs)};`,
168
+ '',
169
+ 'function normalizePath(p) {',
170
+ ' return p.replace(/[\\\\]+/g, "/").replace(/^\\/+/, "");',
171
+ '}',
172
+ '',
173
+ 'function matchFile(p) {',
174
+ ' var clean = normalizePath(p);',
175
+ ' if (__files[clean] !== undefined) return __files[clean];',
176
+ ' var keys = Object.keys(__files);',
177
+ ' for (var i = 0; i < keys.length; i++) {',
178
+ ' if (clean.endsWith("/" + keys[i]) || clean.endsWith(keys[i])) return __files[keys[i]];',
179
+ ' }',
180
+ ' return undefined;',
181
+ '}',
182
+ '',
183
+ 'function matchDir(p) {',
184
+ ' var clean = normalizePath(p);',
185
+ ' if (__dirs[clean] !== undefined) return __dirs[clean];',
186
+ ' var keys = Object.keys(__dirs);',
187
+ ' for (var i = 0; i < keys.length; i++) {',
188
+ ' if (clean.endsWith("/" + keys[i]) || clean.endsWith(keys[i])) return __dirs[keys[i]];',
189
+ ' }',
190
+ ' return undefined;',
191
+ '}',
192
+ '',
193
+ 'export var readFileSync = function(p) { var r = matchFile(p); return r !== undefined ? r : ""; };',
194
+ 'export var readdirSync = function(p) { var r = matchDir(p); return r !== undefined ? r : []; };',
195
+ 'export var existsSync = function(p) { return matchFile(p) !== undefined || matchDir(p) !== undefined; };',
196
+ 'export var writeFileSync = function() {};',
197
+ 'export var mkdirSync = function() {};',
198
+ 'export default { readFileSync: readFileSync, readdirSync: readdirSync, existsSync: existsSync, writeFileSync: writeFileSync, mkdirSync: mkdirSync };',
199
+ ];
200
+ return lines.join('\n');
201
+ }
202
+
203
+ // ── CSS extraction ───────────────────────────────────────────────────
204
+
205
+ /** Extract <style> blocks from rendered HTML and write to a .css file. */
206
+ function extractCss(html, shortName) {
207
+ const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
208
+ let css = '';
209
+ let match;
210
+ while ((match = styleRegex.exec(html)) !== null) {
211
+ css += match[1] + '\n';
212
+ }
213
+ if (css.trim()) {
214
+ writeFileSync(resolve(THEMES_DIR, `${shortName}.css`), css.trim());
215
+ return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ // ── Snapshot generation ──────────────────────────────────────────────
221
+
222
+ /** Render a theme with sample data in Node.js and save as snapshot. */
223
+ function generateSnapshot(shortName, packageName) {
224
+ try {
225
+ // Clear require cache to get a fresh instance
226
+ const pkgPath = require.resolve(packageName);
227
+ delete require.cache[pkgPath];
228
+
229
+ const theme = require(packageName);
230
+ const render = theme.render || (theme.default && theme.default.render);
231
+ if (typeof render !== 'function') return null;
232
+
233
+ const result = render(SAMPLE_RESUME);
234
+ // render() may return a string or a Promise
235
+ if (typeof result === 'string') {
236
+ writeFileSync(resolve(THEMES_DIR, `${shortName}.snapshot.html`), result);
237
+ extractCss(result, shortName);
238
+ return result;
239
+ }
240
+ // If it's a promise, we need to await it
241
+ if (result && typeof result.then === 'function') {
242
+ return result.then((html) => {
243
+ if (typeof html === 'string') {
244
+ writeFileSync(resolve(THEMES_DIR, `${shortName}.snapshot.html`), html);
245
+ extractCss(html, shortName);
246
+ return html;
247
+ }
248
+ return null;
249
+ }).catch(() => null);
250
+ }
251
+ return null;
252
+ } catch (e) {
253
+ console.log(` (snapshot failed: ${e.message})`);
254
+ return null;
255
+ }
256
+ }
257
+
258
+ // ── npm discovery ────────────────────────────────────────────────────
259
+
46
260
  async function discoverThemes() {
47
261
  const themes = [];
48
262
  let from = 0;
@@ -50,7 +264,6 @@ async function discoverThemes() {
50
264
 
51
265
  console.log('🔍 Discovering themes from npm...');
52
266
 
53
- // npm search API has pagination, fetch up to 1000
54
267
  for (let page = 0; page < 4; page++) {
55
268
  const url = `https://registry.npmjs.org/-/v1/search?text=jsonresume-theme&size=${size}&from=${from}`;
56
269
  const res = await fetch(url);
@@ -61,8 +274,6 @@ async function discoverThemes() {
61
274
  const name = pkg.package.name;
62
275
  if (!name.startsWith('jsonresume-theme-')) continue;
63
276
  const shortName = name.replace('jsonresume-theme-', '');
64
-
65
- // Skip themes with 0 downloads or very old
66
277
  if (pkg.score?.detail?.popularity === 0) continue;
67
278
 
68
279
  themes.push({
@@ -81,7 +292,15 @@ async function discoverThemes() {
81
292
  return themes;
82
293
  }
83
294
 
84
- async function bundleTheme(shortName, packageName) {
295
+ // ── esbuild bundling ─────────────────────────────────────────────────
296
+
297
+ async function bundleTheme(shortName, packageName, shimsDir) {
298
+ const themeDir = resolve(__dirname, `../node_modules/${packageName}`);
299
+
300
+ // Generate per-theme fs shim with embedded files
301
+ const themeFiles = collectThemeFiles(themeDir);
302
+ writeFileSync(resolve(shimsDir, 'fs.js'), generateThemeFsShim(themeFiles));
303
+
85
304
  const entryContent = `
86
305
  import * as themeNs from '${packageName}';
87
306
  const _t = themeNs.default ?? themeNs;
@@ -100,12 +319,7 @@ async function bundleTheme(shortName, packageName) {
100
319
  format: 'esm',
101
320
  target: 'es2022',
102
321
  platform: 'browser',
103
- // Use 'require' condition so packages like underscore/lodash resolve to their
104
- // CJS/UMD builds (which export a callable function) instead of their ESM builds
105
- // (which export a namespace object that breaks _(collection) call syntax).
106
322
  conditions: ['browser', 'require', 'default'],
107
- // Prefer the CJS 'main' field over the ESM 'module' field for packages
108
- // that don't use the exports map (older packages).
109
323
  mainFields: ['browser', 'main'],
110
324
  outfile: resolve(THEMES_DIR, `${shortName}.js`),
111
325
  define: {
@@ -117,27 +331,28 @@ async function bundleTheme(shortName, packageName) {
117
331
  'process.platform': '"browser"',
118
332
  'process.version': '"v18.0.0"',
119
333
  },
120
- // Polyfill Node.js built-ins as no-ops for browser
121
334
  alias: {
122
- 'path': resolve(__dirname, 'shims/path.js'),
123
- 'fs': resolve(__dirname, 'shims/fs.js'),
124
- 'url': resolve(__dirname, 'shims/url.js'),
125
- 'node:url': resolve(__dirname, 'shims/url.js'),
126
- 'node:crypto': resolve(__dirname, 'shims/crypto.js'),
127
- 'assert': resolve(__dirname, 'shims/assert.js'),
335
+ 'path': resolve(shimsDir, 'path.js'),
336
+ 'fs': resolve(shimsDir, 'fs.js'),
337
+ 'url': resolve(shimsDir, 'url.js'),
338
+ 'node:url': resolve(shimsDir, 'url.js'),
339
+ 'node:crypto': resolve(shimsDir, 'crypto.js'),
340
+ 'assert': resolve(shimsDir, 'assert.js'),
128
341
  },
129
342
  logLevel: 'silent',
130
343
  });
131
344
 
132
- // Clean up entry file
133
345
  execSync(`rm -f "${entryFile}"`);
134
346
  return true;
135
347
  } catch (e) {
348
+ console.log(` (bundle error: ${e.message})`);
136
349
  execSync(`rm -f "${entryFile}"`);
137
350
  return false;
138
351
  }
139
352
  }
140
353
 
354
+ // ── Main ─────────────────────────────────────────────────────────────
355
+
141
356
  async function main() {
142
357
  const args = process.argv.slice(2);
143
358
  const doAll = args.includes('--all');
@@ -149,19 +364,17 @@ async function main() {
149
364
  // Create shims directory
150
365
  const shimsDir = resolve(__dirname, 'shims');
151
366
  mkdirSync(shimsDir, { recursive: true });
367
+
152
368
  writeFileSync(resolve(shimsDir, 'path.js'), `
153
369
  export const join = (...parts) => parts.join('/');
154
370
  export const resolve = (...parts) => parts.join('/');
155
371
  export const dirname = (p) => p.split('/').slice(0, -1).join('/');
156
372
  export const basename = (p) => p.split('/').pop();
157
373
  export const extname = (p) => { const m = p.match(/\\.[^.]+$/); return m ? m[0] : ''; };
158
- export default { join, resolve, dirname, basename, extname };
159
- `);
160
- writeFileSync(resolve(shimsDir, 'fs.js'), `
161
- export const readFileSync = () => '';
162
- export const existsSync = () => false;
163
- export default { readFileSync, existsSync };
374
+ export const sep = '/';
375
+ export default { join, resolve, dirname, basename, extname, sep };
164
376
  `);
377
+ // fs shim is generated per-theme in bundleTheme()
165
378
  writeFileSync(resolve(shimsDir, 'url.js'), `
166
379
  export const URL = globalThis.URL;
167
380
  export const URLSearchParams = globalThis.URLSearchParams;
@@ -225,7 +438,7 @@ async function main() {
225
438
  continue;
226
439
  }
227
440
 
228
- // Try to get description from installed package
441
+ // Read package metadata
229
442
  try {
230
443
  const pkgPath = resolve(__dirname, `../node_modules/${theme.packageName}/package.json`);
231
444
  if (existsSync(pkgPath)) {
@@ -235,31 +448,60 @@ async function main() {
235
448
  }
236
449
  } catch {}
237
450
 
238
- // Bundle it
239
- const success = await bundleTheme(theme.name, theme.packageName);
451
+ // Step 1: Generate snapshot (pre-render in Node.js)
452
+ let hasSnapshot = false;
453
+ let hasCss = false;
454
+ const snapshotResult = generateSnapshot(theme.name, theme.packageName);
455
+ // Handle promise if async render
456
+ if (snapshotResult && typeof snapshotResult.then === 'function') {
457
+ const html = await snapshotResult;
458
+ hasSnapshot = !!html;
459
+ hasCss = existsSync(resolve(THEMES_DIR, `${theme.name}.css`));
460
+ } else {
461
+ hasSnapshot = !!snapshotResult;
462
+ hasCss = existsSync(resolve(THEMES_DIR, `${theme.name}.css`));
463
+ }
464
+
465
+ // Step 2: Bundle for browser
466
+ const success = await bundleTheme(theme.name, theme.packageName, shimsDir);
240
467
 
241
468
  if (success) {
242
469
  const outFile = resolve(THEMES_DIR, `${theme.name}.js`);
243
470
  const stat = existsSync(outFile) ? readFileSync(outFile).length : 0;
471
+ const cssFile = resolve(THEMES_DIR, `${theme.name}.css`);
472
+ const cssSize = hasCss && existsSync(cssFile) ? readFileSync(cssFile).length : 0;
473
+
244
474
  manifest.push({
245
475
  name: theme.name,
246
476
  displayName: theme.name.charAt(0).toUpperCase() + theme.name.slice(1).replace(/-/g, ' '),
247
477
  description: theme.description,
248
478
  version: theme.version || '',
249
479
  browserCompatible: true,
480
+ hasSnapshot,
481
+ hasCss,
250
482
  fileSize: stat,
483
+ cssSize,
251
484
  });
252
- console.log(`✅ (${(stat / 1024).toFixed(0)}KB)`);
485
+
486
+ const parts = [`✅ (${(stat / 1024).toFixed(0)}KB`];
487
+ if (hasCss) parts.push(`css:${(cssSize / 1024).toFixed(0)}KB`);
488
+ if (hasSnapshot) parts.push('snapshot');
489
+ console.log(parts.join(', ') + ')');
253
490
  } else {
491
+ // Bundle failed — still useful if we have a snapshot (server fallback)
254
492
  manifest.push({
255
493
  name: theme.name,
256
494
  displayName: theme.name.charAt(0).toUpperCase() + theme.name.slice(1).replace(/-/g, ' '),
257
495
  description: theme.description,
258
496
  version: theme.version || '',
259
497
  browserCompatible: false,
498
+ hasSnapshot,
499
+ hasCss: false,
260
500
  fileSize: 0,
501
+ cssSize: 0,
261
502
  });
262
- console.log('⚠️ browser incompatible');
503
+ const note = hasSnapshot ? '⚠️ browser incompatible (has snapshot)' : '⚠️ browser incompatible';
504
+ console.log(note);
263
505
  }
264
506
  }
265
507
 
@@ -267,7 +509,8 @@ async function main() {
267
509
  writeFileSync(resolve(THEMES_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
268
510
 
269
511
  const successful = manifest.filter(t => t.browserCompatible).length;
270
- console.log(`\n✅ Done! ${successful}/${manifest.length} themes bundled for the browser`);
512
+ const snapshots = manifest.filter(t => t.hasSnapshot).length;
513
+ console.log(`\n✅ Done! ${successful}/${manifest.length} themes bundled, ${snapshots} snapshots generated`);
271
514
  console.log(`📁 Output: docs/themes/`);
272
515
 
273
516
  // Clean up shims