resuml 1.5.2 → 1.7.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.2",
3
+ "version": "1.7.0",
4
4
  "description": "Generate JSON resumes from YAML with theme support",
5
5
  "type": "module",
6
6
  "main": "./dist/api.js",
@@ -28,6 +28,8 @@
28
28
  "dev:builder": "node scripts/dev-server.js",
29
29
  "prepublishOnly": "npm run generate:types && npm run build:lib",
30
30
  "generate:types": "node scripts/generate-types.cjs",
31
+ "bundle:themes": "node scripts/bundle-themes.js",
32
+ "bundle:themes:all": "node scripts/bundle-themes.js --all",
31
33
  "test": "vitest run",
32
34
  "test:watch": "vitest",
33
35
  "lint": "eslint src api --ext .ts,.tsx",
@@ -1,12 +1,17 @@
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
+ * - Per-theme fs shim with embedded assets (CSS, templates, etc.)
6
+ * - CSS extraction (separate .css files)
7
+ * - Build-time snapshots (pre-rendered HTML for instant preview)
8
+ * - Node.js built-in shims (buffer, stream, util, events, os, etc.)
6
9
  *
7
10
  * Output:
8
- * docs/themes/{name}.js — bundled theme module
9
- * docs/themes/manifest.json theme metadata registry
11
+ * docs/themes/{name}.js — bundled render module
12
+ * docs/themes/{name}.css extracted CSS (if any)
13
+ * docs/themes/{name}.snapshot.html— pre-rendered HTML with sample data
14
+ * docs/themes/manifest.json — theme metadata registry
10
15
  *
11
16
  * Usage:
12
17
  * node scripts/bundle-themes.js # Bundle popular themes
@@ -19,8 +24,10 @@ import { resolve, dirname, join } from 'path';
19
24
  import { fileURLToPath } from 'url';
20
25
  import { execSync } from 'child_process';
21
26
  import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync } from 'fs';
27
+ import { createRequire } from 'module';
22
28
 
23
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const require = createRequire(import.meta.url);
24
31
  const THEMES_DIR = resolve(__dirname, '../docs/themes');
25
32
 
26
33
  // Popular themes that are known to work in the browser
@@ -43,45 +50,113 @@ const POPULAR_THEMES = [
43
50
  'compact',
44
51
  ];
45
52
 
46
- async function discoverThemes() {
47
- const themes = [];
48
- let from = 0;
49
- const size = 250;
50
-
51
- console.log('🔍 Discovering themes from npm...');
52
-
53
- // npm search API has pagination, fetch up to 1000
54
- for (let page = 0; page < 4; page++) {
55
- const url = `https://registry.npmjs.org/-/v1/search?text=jsonresume-theme&size=${size}&from=${from}`;
56
- const res = await fetch(url);
57
- if (!res.ok) break;
58
- const data = await res.json();
59
-
60
- for (const pkg of data.objects) {
61
- const name = pkg.package.name;
62
- if (!name.startsWith('jsonresume-theme-')) continue;
63
- const shortName = name.replace('jsonresume-theme-', '');
64
-
65
- // Skip themes with 0 downloads or very old
66
- if (pkg.score?.detail?.popularity === 0) continue;
67
-
68
- themes.push({
69
- name: shortName,
70
- packageName: name,
71
- description: pkg.package.description || '',
72
- version: pkg.package.version,
73
- });
74
- }
53
+ // File extensions to embed in the fs shim
54
+ const TEXT_EXTS = new Set([
55
+ 'css', 'hbs', 'html', 'json', 'txt', 'handlebars', 'mustache', 'template',
56
+ 'pug', 'jade', 'ejs', 'svg', 'less', 'scss',
57
+ ]);
58
+
59
+ // Sample resume for generating snapshots
60
+ const SAMPLE_RESUME = {
61
+ basics: {
62
+ name: 'Jane Smith',
63
+ label: 'Senior Software Engineer',
64
+ image: '',
65
+ email: 'jane@example.com',
66
+ phone: '+1-555-987-6543',
67
+ url: 'https://janesmith.dev',
68
+ summary: 'Full-stack engineer with 8+ years of experience building scalable web applications. Passionate about clean architecture, performance optimization, and mentoring teams.',
69
+ location: { address: '', postalCode: '', city: 'San Francisco', countryCode: 'US', region: 'California' },
70
+ profiles: [
71
+ { network: 'LinkedIn', username: 'janesmith', url: 'https://linkedin.com/in/janesmith' },
72
+ { network: 'GitHub', username: 'janesmith', url: 'https://github.com/janesmith' },
73
+ ],
74
+ },
75
+ work: [
76
+ {
77
+ name: 'Tech Corp',
78
+ position: 'Senior Software Engineer',
79
+ url: 'https://techcorp.com',
80
+ startDate: '2020-03-01',
81
+ summary: 'Lead engineer for the platform team, owning core API infrastructure.',
82
+ highlights: [
83
+ 'Reduced API latency by 45% through caching and query optimization',
84
+ 'Designed microservices architecture serving 2M daily requests',
85
+ 'Mentored 4 junior engineers through technical growth plans',
86
+ ],
87
+ },
88
+ {
89
+ name: 'StartupXYZ',
90
+ position: 'Full Stack Developer',
91
+ startDate: '2017-06-01',
92
+ endDate: '2020-02-28',
93
+ summary: 'Built customer-facing web applications from prototype to production.',
94
+ highlights: [
95
+ 'Built real-time dashboard used by 15,000+ daily active users',
96
+ 'Implemented CI/CD pipeline reducing deploy time from 2 hours to 8 minutes',
97
+ ],
98
+ },
99
+ ],
100
+ education: [
101
+ {
102
+ institution: 'University of Technology',
103
+ area: 'Computer Science',
104
+ studyType: 'Bachelor of Science',
105
+ startDate: '2013-09-01',
106
+ endDate: '2017-05-15',
107
+ score: '3.8',
108
+ },
109
+ ],
110
+ skills: [
111
+ { name: 'Languages', level: 'Expert', keywords: ['TypeScript', 'JavaScript', 'Python', 'Go'] },
112
+ { name: 'Frontend', level: 'Expert', keywords: ['React', 'Next.js', 'HTML/CSS', 'Tailwind'] },
113
+ { name: 'Backend & Cloud', level: 'Advanced', keywords: ['Node.js', 'PostgreSQL', 'AWS', 'Docker'] },
114
+ ],
115
+ projects: [
116
+ {
117
+ name: 'Open Source CLI Tool',
118
+ description: 'Developer productivity tool with 2,000+ GitHub stars',
119
+ url: 'https://github.com/janesmith/cli-tool',
120
+ startDate: '2022-01-01',
121
+ highlights: ['Published to npm with 5,000+ weekly downloads'],
122
+ },
123
+ ],
124
+ volunteer: [],
125
+ awards: [],
126
+ certificates: [],
127
+ publications: [],
128
+ languages: [],
129
+ interests: [],
130
+ references: [],
131
+ };
75
132
 
76
- if (data.objects.length < size) break;
77
- from += size;
133
+ /**
134
+ * Pad resume with safe defaults so themes don't crash on missing fields.
135
+ * Ported from api/_lib/themeInstaller.ts.
136
+ */
137
+ function padResume(r) {
138
+ const basics = r.basics ?? {};
139
+ const location = basics.location ?? {};
140
+ const safe = {
141
+ ...r,
142
+ basics: {
143
+ name: '', label: '', image: '', email: '', phone: '', url: '', summary: '',
144
+ ...basics,
145
+ location: { address: '', postalCode: '', city: '', countryCode: '', region: '', ...location },
146
+ },
147
+ };
148
+ const arraySections = ['work','volunteer','education','awards','certificates','publications','skills','languages','interests','references','projects'];
149
+ for (const key of arraySections) {
150
+ safe[key] = Array.isArray(safe[key]) ? safe[key] : [];
78
151
  }
79
-
80
- console.log(` Found ${themes.length} themes`);
81
- return themes;
152
+ const safeBasics = safe.basics;
153
+ safeBasics.profiles = Array.isArray(safeBasics.profiles) ? safeBasics.profiles : [];
154
+ return safe;
82
155
  }
83
156
 
84
- /** Recursively collect text files from a theme directory for embedding in the fs shim. */
157
+ // ── Theme file collection ────────────────────────────────────────────
158
+
159
+ /** Recursively collect text files from a theme directory for the fs shim. */
85
160
  function collectThemeFiles(themeDir) {
86
161
  const files = {};
87
162
  const dirs = {};
@@ -102,9 +177,9 @@ function collectThemeFiles(themeDir) {
102
177
  walk(full, rel);
103
178
  } else {
104
179
  const ext = (entry.name.split('.').pop() || '').toLowerCase();
105
- if (['css', 'hbs', 'html', 'json', 'txt', 'handlebars', 'mustache'].includes(ext)) {
180
+ if (TEXT_EXTS.has(ext)) {
106
181
  try { files[rel] = readFileSync(full, 'utf-8'); }
107
- catch { /* skip unreadable */ }
182
+ catch { /* skip */ }
108
183
  }
109
184
  }
110
185
  }
@@ -115,53 +190,168 @@ function collectThemeFiles(themeDir) {
115
190
  return { files, dirs };
116
191
  }
117
192
 
118
- /** Generate an fs shim that embeds the theme's files so readFileSync/readdirSync work at runtime. */
193
+ /** Generate an fs shim that embeds the theme's files. */
119
194
  function generateThemeFsShim(themeFiles) {
120
195
  const { files, dirs } = themeFiles;
121
- return `
122
- const __files = ${JSON.stringify(files)};
123
- const __dirs = ${JSON.stringify(dirs)};
124
-
125
- function matchFile(path) {
126
- const clean = path.replace(/\\/+/g, '/').replace(/^\\/+/, '');
127
- if (__files[clean] !== undefined) return __files[clean];
128
- for (const key of Object.keys(__files)) {
129
- if (clean.endsWith('/' + key) || clean.endsWith(key)) return __files[key];
196
+ const lines = [
197
+ `var __files = ${JSON.stringify(files)};`,
198
+ `var __dirs = ${JSON.stringify(dirs)};`,
199
+ '',
200
+ 'function normalizePath(p) {',
201
+ ' return String(p).replace(/[\\\\]+/g, "/").replace(/^\\/+/, "");',
202
+ '}',
203
+ '',
204
+ 'function matchFile(p) {',
205
+ ' var clean = normalizePath(p);',
206
+ ' if (__files[clean] !== undefined) return __files[clean];',
207
+ ' var keys = Object.keys(__files);',
208
+ ' for (var i = 0; i < keys.length; i++) {',
209
+ ' if (clean.endsWith("/" + keys[i]) || clean === keys[i]) return __files[keys[i]];',
210
+ ' }',
211
+ ' return undefined;',
212
+ '}',
213
+ '',
214
+ 'function matchDir(p) {',
215
+ ' var clean = normalizePath(p);',
216
+ ' if (__dirs[clean] !== undefined) return __dirs[clean];',
217
+ ' var keys = Object.keys(__dirs);',
218
+ ' for (var i = 0; i < keys.length; i++) {',
219
+ ' if (clean.endsWith("/" + keys[i]) || clean === keys[i]) return __dirs[keys[i]];',
220
+ ' }',
221
+ ' return undefined;',
222
+ '}',
223
+ '',
224
+ 'export var readFileSync = function(p, encoding) {',
225
+ ' var r = matchFile(p);',
226
+ ' if (r !== undefined) return r;',
227
+ ' return typeof encoding === "string" ? "" : "";',
228
+ '};',
229
+ '',
230
+ 'export var readdirSync = function(p, opts) {',
231
+ ' var r = matchDir(p);',
232
+ ' if (r === undefined) r = [];',
233
+ ' if (opts && opts.withFileTypes) {',
234
+ ' return r.map(function(name) {',
235
+ ' var childPath = normalizePath(p) + "/" + name;',
236
+ ' var isDir = matchDir(childPath) !== undefined;',
237
+ ' return { name: name, isFile: function() { return !isDir; }, isDirectory: function() { return isDir; } };',
238
+ ' });',
239
+ ' }',
240
+ ' return r;',
241
+ '};',
242
+ '',
243
+ 'export var existsSync = function(p) { return matchFile(p) !== undefined || matchDir(p) !== undefined; };',
244
+ 'export var writeFileSync = function() {};',
245
+ 'export var mkdirSync = function() {};',
246
+ 'export var statSync = function() { return { isFile: function() { return true; }, isDirectory: function() { return false; }, size: 0, mtime: new Date() }; };',
247
+ 'export var lstatSync = statSync;',
248
+ 'export var unlinkSync = function() {};',
249
+ 'export var rmdirSync = function() {};',
250
+ 'export var createReadStream = function() { return { pipe: function(d) { return d; }, on: function() { return this; } }; };',
251
+ 'export var createWriteStream = function() { return { write: function() {}, end: function() {}, on: function() { return this; } }; };',
252
+ 'export default { readFileSync: readFileSync, readdirSync: readdirSync, existsSync: existsSync, writeFileSync: writeFileSync, mkdirSync: mkdirSync, statSync: statSync, lstatSync: lstatSync, unlinkSync: unlinkSync, rmdirSync: rmdirSync, createReadStream: createReadStream, createWriteStream: createWriteStream };',
253
+ ];
254
+ return lines.join('\n');
255
+ }
256
+
257
+ // ── CSS extraction ───────────────────────────────────────────────────
258
+
259
+ /** Extract <style> blocks from rendered HTML and write to a .css file. */
260
+ function extractCss(html, shortName) {
261
+ const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
262
+ let css = '';
263
+ let match;
264
+ while ((match = styleRegex.exec(html)) !== null) {
265
+ css += match[1] + '\n';
266
+ }
267
+ if (css.trim()) {
268
+ writeFileSync(resolve(THEMES_DIR, `${shortName}.css`), css.trim());
269
+ return true;
130
270
  }
131
- return undefined;
271
+ return false;
132
272
  }
133
273
 
134
- function matchDir(path) {
135
- const clean = path.replace(/\\/+/g, '/').replace(/^\\/+/, '');
136
- if (__dirs[clean] !== undefined) return __dirs[clean];
137
- for (const key of Object.keys(__dirs)) {
138
- if (clean.endsWith('/' + key) || clean.endsWith(key)) return __dirs[key];
274
+ // ── Snapshot generation ──────────────────────────────────────────────
275
+
276
+ /** Render a theme with sample data in Node.js and save as snapshot. */
277
+ function generateSnapshot(shortName, packageName) {
278
+ try {
279
+ // Clear require cache to get a fresh instance
280
+ const pkgPath = require.resolve(packageName);
281
+ delete require.cache[pkgPath];
282
+
283
+ const theme = require(packageName);
284
+ const render = theme.render || (theme.default && theme.default.render);
285
+ if (typeof render !== 'function') return null;
286
+
287
+ const paddedResume = padResume(SAMPLE_RESUME);
288
+ const result = render(paddedResume);
289
+ // render() may return a string or a Promise
290
+ if (typeof result === 'string') {
291
+ writeFileSync(resolve(THEMES_DIR, `${shortName}.snapshot.html`), result);
292
+ extractCss(result, shortName);
293
+ return result;
294
+ }
295
+ if (result && typeof result.then === 'function') {
296
+ return result.then((html) => {
297
+ if (typeof html === 'string') {
298
+ writeFileSync(resolve(THEMES_DIR, `${shortName}.snapshot.html`), html);
299
+ extractCss(html, shortName);
300
+ return html;
301
+ }
302
+ return null;
303
+ }).catch(() => null);
304
+ }
305
+ return null;
306
+ } catch (e) {
307
+ console.log(` (snapshot failed: ${e.message})`);
308
+ return null;
139
309
  }
140
- return undefined;
141
310
  }
142
311
 
143
- export const readFileSync = (path, encoding) => {
144
- const r = matchFile(path);
145
- return r !== undefined ? r : '';
146
- };
312
+ // ── npm discovery ────────────────────────────────────────────────────
147
313
 
148
- export const readdirSync = (path) => {
149
- const r = matchDir(path);
150
- return r !== undefined ? r : [];
151
- };
314
+ async function discoverThemes() {
315
+ const themes = [];
316
+ let from = 0;
317
+ const size = 250;
152
318
 
153
- export const existsSync = (path) => {
154
- return matchFile(path) !== undefined || matchDir(path) !== undefined;
155
- };
319
+ console.log('🔍 Discovering themes from npm...');
156
320
 
157
- export default { readFileSync, readdirSync, existsSync };
158
- `;
321
+ for (let page = 0; page < 4; page++) {
322
+ const url = `https://registry.npmjs.org/-/v1/search?text=jsonresume-theme&size=${size}&from=${from}`;
323
+ const res = await fetch(url);
324
+ if (!res.ok) break;
325
+ const data = await res.json();
326
+
327
+ for (const pkg of data.objects) {
328
+ const name = pkg.package.name;
329
+ if (!name.startsWith('jsonresume-theme-')) continue;
330
+ const shortName = name.replace('jsonresume-theme-', '');
331
+ if (pkg.score?.detail?.popularity === 0) continue;
332
+
333
+ themes.push({
334
+ name: shortName,
335
+ packageName: name,
336
+ description: pkg.package.description || '',
337
+ version: pkg.package.version,
338
+ });
339
+ }
340
+
341
+ if (data.objects.length < size) break;
342
+ from += size;
343
+ }
344
+
345
+ console.log(` Found ${themes.length} themes`);
346
+ return themes;
159
347
  }
160
348
 
349
+ // ── esbuild bundling ─────────────────────────────────────────────────
350
+
161
351
  async function bundleTheme(shortName, packageName, shimsDir) {
162
- // Generate a theme-specific fs shim with embedded file contents so that
163
- // readFileSync / readdirSync return real CSS, templates, and partials at runtime.
164
352
  const themeDir = resolve(__dirname, `../node_modules/${packageName}`);
353
+
354
+ // Generate per-theme fs shim with embedded files
165
355
  const themeFiles = collectThemeFiles(themeDir);
166
356
  writeFileSync(resolve(shimsDir, 'fs.js'), generateThemeFsShim(themeFiles));
167
357
 
@@ -183,44 +373,264 @@ async function bundleTheme(shortName, packageName, shimsDir) {
183
373
  format: 'esm',
184
374
  target: 'es2022',
185
375
  platform: 'browser',
186
- // Use 'require' condition so packages like underscore/lodash resolve to their
187
- // CJS/UMD builds (which export a callable function) instead of their ESM builds
188
- // (which export a namespace object that breaks _(collection) call syntax).
189
376
  conditions: ['browser', 'require', 'default'],
190
- // Prefer the CJS 'main' field over the ESM 'module' field for packages
191
- // that don't use the exports map (older packages).
192
377
  mainFields: ['browser', 'main'],
193
378
  outfile: resolve(THEMES_DIR, `${shortName}.js`),
194
379
  define: {
195
380
  'process.env.NODE_ENV': '"production"',
381
+ 'process.env.LANG': '""',
196
382
  'global': 'globalThis',
197
383
  '__dirname': '"/"',
198
384
  '__filename': '"/index.js"',
199
385
  'process.browser': 'true',
200
386
  'process.platform': '"browser"',
201
- 'process.version': '"v18.0.0"',
387
+ 'process.version': '"v20.0.0"',
388
+ 'process.versions': '{}',
389
+ 'process.stdout': 'false',
390
+ 'process.stderr': 'false',
202
391
  },
203
- // Polyfill Node.js built-ins as no-ops for browser
204
392
  alias: {
205
- 'path': resolve(__dirname, 'shims/path.js'),
206
- 'fs': resolve(__dirname, 'shims/fs.js'),
207
- 'url': resolve(__dirname, 'shims/url.js'),
208
- 'node:url': resolve(__dirname, 'shims/url.js'),
209
- 'node:crypto': resolve(__dirname, 'shims/crypto.js'),
210
- 'assert': resolve(__dirname, 'shims/assert.js'),
393
+ 'path': resolve(shimsDir, 'path.js'),
394
+ 'fs': resolve(shimsDir, 'fs.js'),
395
+ 'url': resolve(shimsDir, 'url.js'),
396
+ 'assert': resolve(shimsDir, 'assert.js'),
397
+ 'buffer': resolve(shimsDir, 'buffer.js'),
398
+ 'stream': resolve(shimsDir, 'stream.js'),
399
+ 'util': resolve(shimsDir, 'util.js'),
400
+ 'events': resolve(shimsDir, 'events.js'),
401
+ 'os': resolve(shimsDir, 'os.js'),
402
+ // node: prefixed variants
403
+ 'node:fs': resolve(shimsDir, 'fs.js'),
404
+ 'node:path': resolve(shimsDir, 'path.js'),
405
+ 'node:url': resolve(shimsDir, 'url.js'),
406
+ 'node:crypto': resolve(shimsDir, 'crypto.js'),
407
+ 'node:buffer': resolve(shimsDir, 'buffer.js'),
408
+ 'node:stream': resolve(shimsDir, 'stream.js'),
409
+ 'node:util': resolve(shimsDir, 'util.js'),
410
+ 'node:events': resolve(shimsDir, 'events.js'),
411
+ 'node:os': resolve(shimsDir, 'os.js'),
412
+ 'node:assert': resolve(shimsDir, 'assert.js'),
211
413
  },
212
414
  logLevel: 'silent',
213
415
  });
214
416
 
215
- // Clean up entry file
216
417
  execSync(`rm -f "${entryFile}"`);
217
418
  return true;
218
419
  } catch (e) {
420
+ console.log(` (bundle error: ${e.message})`);
219
421
  execSync(`rm -f "${entryFile}"`);
220
422
  return false;
221
423
  }
222
424
  }
223
425
 
426
+ // ── Shim file writers ────────────────────────────────────────────────
427
+
428
+ function writeShims(shimsDir) {
429
+ writeFileSync(resolve(shimsDir, 'path.js'), [
430
+ 'export var join = function() { return [].slice.call(arguments).join("/"); };',
431
+ 'export var resolve = function() { return [].slice.call(arguments).join("/"); };',
432
+ 'export var dirname = function(p) { return p.split("/").slice(0, -1).join("/"); };',
433
+ 'export var basename = function(p, ext) { var b = p.split("/").pop() || ""; return ext && b.endsWith(ext) ? b.slice(0, -ext.length) : b; };',
434
+ 'export var extname = function(p) { var m = p.match(/\\.[^.]+$/); return m ? m[0] : ""; };',
435
+ 'export var sep = "/";',
436
+ 'export var isAbsolute = function(p) { return p.charAt(0) === "/"; };',
437
+ 'export var normalize = function(p) { return p; };',
438
+ 'export var relative = function(from, to) { return to; };',
439
+ 'export var parse = function(p) { return { root: "", dir: dirname(p), base: basename(p), ext: extname(p), name: basename(p, extname(p)) }; };',
440
+ 'export default { join: join, resolve: resolve, dirname: dirname, basename: basename, extname: extname, sep: sep, isAbsolute: isAbsolute, normalize: normalize, relative: relative, parse: parse };',
441
+ ].join('\n'));
442
+
443
+ // fs shim is generated per-theme in bundleTheme()
444
+
445
+ writeFileSync(resolve(shimsDir, 'url.js'), [
446
+ 'export var URL = globalThis.URL;',
447
+ 'export var URLSearchParams = globalThis.URLSearchParams;',
448
+ 'export var fileURLToPath = function(u) { return u.replace(/^file:\\/\\//, ""); };',
449
+ 'export var pathToFileURL = function(p) { return new globalThis.URL("file://" + p); };',
450
+ 'export var format = function(u) { return typeof u === "string" ? u : u.href; };',
451
+ 'export var parse = function(u) { return new globalThis.URL(u); };',
452
+ 'export default { URL: URL, URLSearchParams: URLSearchParams, fileURLToPath: fileURLToPath, pathToFileURL: pathToFileURL, format: format, parse: parse };',
453
+ ].join('\n'));
454
+
455
+ writeFileSync(resolve(shimsDir, 'crypto.js'), [
456
+ 'export var createHash = function() { return { update: function() { return this; }, digest: function() { return ""; } }; };',
457
+ 'export var randomBytes = function(n) { return new Uint8Array(n); };',
458
+ 'export var createHmac = function() { return { update: function() { return this; }, digest: function() { return ""; } }; };',
459
+ 'export default { createHash: createHash, randomBytes: randomBytes, createHmac: createHmac };',
460
+ ].join('\n'));
461
+
462
+ writeFileSync(resolve(shimsDir, 'assert.js'), [
463
+ 'var assert = function(v, msg) { if (!v) throw new Error(msg || "Assertion failed"); };',
464
+ 'assert.ok = assert;',
465
+ 'assert.strictEqual = function(a, b) { if (a !== b) throw new Error("Not equal"); };',
466
+ 'assert.deepStrictEqual = function() {};',
467
+ 'assert.fail = function(msg) { throw new Error(msg); };',
468
+ 'export default assert;',
469
+ 'export var ok = assert;',
470
+ 'export var strictEqual = assert.strictEqual;',
471
+ ].join('\n'));
472
+
473
+ writeFileSync(resolve(shimsDir, 'buffer.js'), [
474
+ 'var _enc = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;',
475
+ 'var _dec = typeof TextDecoder !== "undefined" ? new TextDecoder() : null;',
476
+ '',
477
+ 'function BufferShim(arg) {',
478
+ ' if (typeof arg === "number") return new Uint8Array(arg);',
479
+ ' if (arg instanceof Uint8Array) return arg;',
480
+ ' if (arg instanceof ArrayBuffer) return new Uint8Array(arg);',
481
+ ' return new Uint8Array(0);',
482
+ '}',
483
+ '',
484
+ 'BufferShim.from = function(data, encoding) {',
485
+ ' if (typeof data === "string") {',
486
+ ' if (encoding === "base64") {',
487
+ ' var bin = atob(data);',
488
+ ' var bytes = new Uint8Array(bin.length);',
489
+ ' for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);',
490
+ ' return bytes;',
491
+ ' }',
492
+ ' return _enc ? _enc.encode(data) : new Uint8Array(0);',
493
+ ' }',
494
+ ' if (Array.isArray(data)) return new Uint8Array(data);',
495
+ ' if (data instanceof ArrayBuffer) return new Uint8Array(data);',
496
+ ' if (data instanceof Uint8Array) return new Uint8Array(data);',
497
+ ' return new Uint8Array(0);',
498
+ '};',
499
+ 'BufferShim.alloc = function(size) { return new Uint8Array(size); };',
500
+ 'BufferShim.allocUnsafe = function(size) { return new Uint8Array(size); };',
501
+ 'BufferShim.isBuffer = function(obj) { return obj instanceof Uint8Array; };',
502
+ 'BufferShim.isEncoding = function() { return true; };',
503
+ 'BufferShim.byteLength = function(str) { return _enc ? _enc.encode(str).length : str.length; };',
504
+ 'BufferShim.concat = function(list) {',
505
+ ' var total = 0;',
506
+ ' for (var i = 0; i < list.length; i++) total += list[i].length;',
507
+ ' var result = new Uint8Array(total);',
508
+ ' var offset = 0;',
509
+ ' for (var j = 0; j < list.length; j++) { result.set(list[j], offset); offset += list[j].length; }',
510
+ ' return result;',
511
+ '};',
512
+ '',
513
+ 'export var Buffer = BufferShim;',
514
+ 'export var SlowBuffer = BufferShim;',
515
+ 'export var INSPECT_MAX_BYTES = 50;',
516
+ 'export var kMaxLength = 2147483647;',
517
+ 'export default { Buffer: BufferShim, SlowBuffer: BufferShim, INSPECT_MAX_BYTES: 50, kMaxLength: 2147483647 };',
518
+ ].join('\n'));
519
+
520
+ writeFileSync(resolve(shimsDir, 'stream.js'), [
521
+ 'function EventBase() { this._e = {}; }',
522
+ 'EventBase.prototype.on = function(ev, fn) { (this._e[ev] = this._e[ev] || []).push(fn); return this; };',
523
+ 'EventBase.prototype.addListener = EventBase.prototype.on;',
524
+ 'EventBase.prototype.once = function(ev, fn) { var self = this; var w = function() { self.removeListener(ev, w); fn.apply(null, arguments); }; return this.on(ev, w); };',
525
+ 'EventBase.prototype.emit = function(ev) { var args = [].slice.call(arguments, 1); (this._e[ev] || []).forEach(function(fn) { fn.apply(null, args); }); return true; };',
526
+ 'EventBase.prototype.removeListener = function(ev, fn) { var l = this._e[ev]; if (l) this._e[ev] = l.filter(function(f) { return f !== fn; }); return this; };',
527
+ 'EventBase.prototype.off = EventBase.prototype.removeListener;',
528
+ 'EventBase.prototype.removeAllListeners = function(ev) { if (ev) delete this._e[ev]; else this._e = {}; return this; };',
529
+ 'EventBase.prototype.setMaxListeners = function() { return this; };',
530
+ 'EventBase.prototype.listeners = function(ev) { return this._e[ev] || []; };',
531
+ '',
532
+ 'function Readable() { EventBase.call(this); } Readable.prototype = Object.create(EventBase.prototype);',
533
+ 'Readable.prototype.read = function() { return null; };',
534
+ 'Readable.prototype.pipe = function(d) { return d; };',
535
+ 'Readable.prototype.unpipe = function() { return this; };',
536
+ 'Readable.prototype.destroy = function() { return this; };',
537
+ '',
538
+ 'function Writable() { EventBase.call(this); } Writable.prototype = Object.create(EventBase.prototype);',
539
+ 'Writable.prototype.write = function() { return true; };',
540
+ 'Writable.prototype.end = function() { return this; };',
541
+ 'Writable.prototype.destroy = function() { return this; };',
542
+ '',
543
+ 'function Transform() { EventBase.call(this); } Transform.prototype = Object.create(EventBase.prototype);',
544
+ 'Transform.prototype.write = function() { return true; };',
545
+ 'Transform.prototype.end = function() { return this; };',
546
+ 'Transform.prototype.pipe = function(d) { return d; };',
547
+ 'Transform.prototype.destroy = function() { return this; };',
548
+ '',
549
+ 'function PassThrough() { Transform.call(this); } PassThrough.prototype = Object.create(Transform.prototype);',
550
+ 'function Duplex() { EventBase.call(this); } Duplex.prototype = Object.create(EventBase.prototype);',
551
+ 'Duplex.prototype.write = function() { return true; };',
552
+ 'Duplex.prototype.end = function() { return this; };',
553
+ 'Duplex.prototype.read = function() { return null; };',
554
+ 'Duplex.prototype.pipe = function(d) { return d; };',
555
+ 'Duplex.prototype.destroy = function() { return this; };',
556
+ '',
557
+ 'function Stream() { EventBase.call(this); } Stream.prototype = Object.create(EventBase.prototype);',
558
+ 'Stream.prototype.pipe = function(d) { return d; };',
559
+ 'Stream.Readable = Readable; Stream.Writable = Writable; Stream.Transform = Transform;',
560
+ 'Stream.PassThrough = PassThrough; Stream.Duplex = Duplex; Stream.Stream = Stream;',
561
+ '',
562
+ 'export { Readable, Writable, Transform, PassThrough, Duplex, Stream };',
563
+ 'export default Stream;',
564
+ ].join('\n'));
565
+
566
+ writeFileSync(resolve(shimsDir, 'util.js'), [
567
+ 'export var inherits = function(ctor, superCtor) { ctor.super_ = superCtor; Object.setPrototypeOf(ctor.prototype, superCtor.prototype); };',
568
+ 'export var deprecate = function(fn) { return fn; };',
569
+ 'export var promisify = function(fn) { return function() { var args = [].slice.call(arguments); return new Promise(function(ok, fail) { args.push(function(err, res) { err ? fail(err) : ok(res); }); fn.apply(null, args); }); }; };',
570
+ 'export var inspect = function(obj) { try { return JSON.stringify(obj); } catch(e) { return String(obj); } };',
571
+ 'export var format = function(f) { if (typeof f !== "string") return [].map.call(arguments, function(a) { return inspect(a); }).join(" "); var i = 1; var args = arguments; return f.replace(/%[sdj%]/g, function(m) { if (m === "%%") return "%"; if (i >= args.length) return m; var v = args[i++]; if (m === "%s") return String(v); if (m === "%d") return Number(v); if (m === "%j") try { return JSON.stringify(v); } catch(e) { return "[Circular]"; } return m; }); };',
572
+ 'export var debuglog = function() { return function() {}; };',
573
+ 'export var isArray = Array.isArray;',
574
+ 'export var isBoolean = function(v) { return typeof v === "boolean"; };',
575
+ 'export var isNull = function(v) { return v === null; };',
576
+ 'export var isNumber = function(v) { return typeof v === "number"; };',
577
+ 'export var isString = function(v) { return typeof v === "string"; };',
578
+ 'export var isUndefined = function(v) { return v === undefined; };',
579
+ 'export var isObject = function(v) { return typeof v === "object" && v !== null; };',
580
+ 'export var isFunction = function(v) { return typeof v === "function"; };',
581
+ 'export var isRegExp = function(v) { return v instanceof RegExp; };',
582
+ 'export var isDate = function(v) { return v instanceof Date; };',
583
+ 'export var isError = function(v) { return v instanceof Error; };',
584
+ 'export var isPrimitive = function(v) { return v === null || typeof v !== "object" && typeof v !== "function"; };',
585
+ 'export var TextEncoder = globalThis.TextEncoder;',
586
+ 'export var TextDecoder = globalThis.TextDecoder;',
587
+ 'export var types = { isAnyArrayBuffer: function() { return false; }, isTypedArray: function(v) { return ArrayBuffer.isView(v); } };',
588
+ 'export default { inherits: inherits, deprecate: deprecate, promisify: promisify, inspect: inspect, format: format, debuglog: debuglog, isArray: isArray, isBoolean: isBoolean, isNull: isNull, isNumber: isNumber, isString: isString, isUndefined: isUndefined, isObject: isObject, isFunction: isFunction, isRegExp: isRegExp, isDate: isDate, isError: isError, isPrimitive: isPrimitive, TextEncoder: TextEncoder, TextDecoder: TextDecoder, types: types };',
589
+ ].join('\n'));
590
+
591
+ writeFileSync(resolve(shimsDir, 'events.js'), [
592
+ 'function EventEmitter() { this._events = {}; this._maxListeners = 10; }',
593
+ 'EventEmitter.prototype.on = function(ev, fn) { (this._events[ev] = this._events[ev] || []).push(fn); return this; };',
594
+ 'EventEmitter.prototype.addListener = EventEmitter.prototype.on;',
595
+ 'EventEmitter.prototype.once = function(ev, fn) { var self = this; var w = function() { self.removeListener(ev, w); fn.apply(null, arguments); }; return this.on(ev, w); };',
596
+ 'EventEmitter.prototype.emit = function(ev) { var args = [].slice.call(arguments, 1); (this._events[ev] || []).forEach(function(fn) { fn.apply(null, args); }); return true; };',
597
+ 'EventEmitter.prototype.removeListener = function(ev, fn) { var l = this._events[ev]; if (l) this._events[ev] = l.filter(function(f) { return f !== fn; }); return this; };',
598
+ 'EventEmitter.prototype.off = EventEmitter.prototype.removeListener;',
599
+ 'EventEmitter.prototype.removeAllListeners = function(ev) { if (ev) delete this._events[ev]; else this._events = {}; return this; };',
600
+ 'EventEmitter.prototype.setMaxListeners = function(n) { this._maxListeners = n; return this; };',
601
+ 'EventEmitter.prototype.getMaxListeners = function() { return this._maxListeners; };',
602
+ 'EventEmitter.prototype.listeners = function(ev) { return this._events[ev] || []; };',
603
+ 'EventEmitter.prototype.listenerCount = function(ev) { return (this._events[ev] || []).length; };',
604
+ 'EventEmitter.prototype.prependListener = EventEmitter.prototype.on;',
605
+ 'EventEmitter.prototype.prependOnceListener = EventEmitter.prototype.once;',
606
+ 'EventEmitter.prototype.eventNames = function() { return Object.keys(this._events); };',
607
+ 'EventEmitter.EventEmitter = EventEmitter;',
608
+ 'EventEmitter.defaultMaxListeners = 10;',
609
+ 'export { EventEmitter };',
610
+ 'export default EventEmitter;',
611
+ ].join('\n'));
612
+
613
+ writeFileSync(resolve(shimsDir, 'os.js'), [
614
+ 'export var platform = function() { return "browser"; };',
615
+ 'export var tmpdir = function() { return "/tmp"; };',
616
+ 'export var homedir = function() { return "/"; };',
617
+ 'export var hostname = function() { return "localhost"; };',
618
+ 'export var type = function() { return "Browser"; };',
619
+ 'export var arch = function() { return "wasm"; };',
620
+ 'export var release = function() { return "0.0.0"; };',
621
+ 'export var EOL = "\\n";',
622
+ 'export var endianness = function() { return "LE"; };',
623
+ 'export var cpus = function() { return []; };',
624
+ 'export var totalmem = function() { return 0; };',
625
+ 'export var freemem = function() { return 0; };',
626
+ 'export var networkInterfaces = function() { return {}; };',
627
+ 'export var userInfo = function() { return { username: "browser", uid: 0, gid: 0, shell: "", homedir: "/" }; };',
628
+ 'export default { platform: platform, tmpdir: tmpdir, homedir: homedir, hostname: hostname, type: type, arch: arch, release: release, EOL: EOL, endianness: endianness, cpus: cpus, totalmem: totalmem, freemem: freemem, networkInterfaces: networkInterfaces, userInfo: userInfo };',
629
+ ].join('\n'));
630
+ }
631
+
632
+ // ── Main ─────────────────────────────────────────────────────────────
633
+
224
634
  async function main() {
225
635
  const args = process.argv.slice(2);
226
636
  const doAll = args.includes('--all');
@@ -229,43 +639,10 @@ async function main() {
229
639
 
230
640
  mkdirSync(THEMES_DIR, { recursive: true });
231
641
 
232
- // Create shims directory
642
+ // Create shims directory and write all shims
233
643
  const shimsDir = resolve(__dirname, 'shims');
234
644
  mkdirSync(shimsDir, { recursive: true });
235
- writeFileSync(resolve(shimsDir, 'path.js'), `
236
- export const join = (...parts) => parts.join('/');
237
- export const resolve = (...parts) => parts.join('/');
238
- export const dirname = (p) => p.split('/').slice(0, -1).join('/');
239
- export const basename = (p) => p.split('/').pop();
240
- export const extname = (p) => { const m = p.match(/\\.[^.]+$/); return m ? m[0] : ''; };
241
- export default { join, resolve, dirname, basename, extname };
242
- `);
243
- // fs shim is generated per-theme in bundleTheme() with embedded file contents
244
- writeFileSync(resolve(shimsDir, 'url.js'), `
245
- export const URL = globalThis.URL;
246
- export const URLSearchParams = globalThis.URLSearchParams;
247
- export const fileURLToPath = (u) => u.replace(/^file:\\/\\//, '');
248
- export const pathToFileURL = (p) => new globalThis.URL('file://' + p);
249
- export const format = (u) => (typeof u === 'string' ? u : u.href);
250
- export const parse = (u) => new globalThis.URL(u);
251
- export default { URL, URLSearchParams, fileURLToPath, pathToFileURL, format, parse };
252
- `);
253
- writeFileSync(resolve(shimsDir, 'crypto.js'), `
254
- export const createHash = () => ({ update: function() { return this; }, digest: () => '' });
255
- export const randomBytes = (n) => new Uint8Array(n);
256
- export const createHmac = () => ({ update: function() { return this; }, digest: () => '' });
257
- export default { createHash, randomBytes, createHmac };
258
- `);
259
- writeFileSync(resolve(shimsDir, 'assert.js'), `
260
- const assert = (v, msg) => { if (!v) throw new Error(msg || 'Assertion failed'); };
261
- assert.ok = assert;
262
- assert.strictEqual = (a, b) => { if (a !== b) throw new Error('Not equal'); };
263
- assert.deepStrictEqual = () => {};
264
- assert.fail = (msg) => { throw new Error(msg); };
265
- export default assert;
266
- export const ok = assert;
267
- export const strictEqual = assert.strictEqual;
268
- `);
645
+ writeShims(shimsDir);
269
646
 
270
647
  let themes;
271
648
  if (specificThemes) {
@@ -295,16 +672,16 @@ async function main() {
295
672
 
296
673
  // Install the theme
297
674
  try {
298
- execSync(`npm install --no-save ${theme.packageName} 2>/dev/null`, {
675
+ execSync(`npm install --no-save --prefer-offline ${theme.packageName} 2>/dev/null`, {
299
676
  cwd: resolve(__dirname, '..'),
300
- timeout: 30000,
677
+ timeout: 60000,
301
678
  });
302
679
  } catch {
303
680
  console.log('❌ install failed');
304
681
  continue;
305
682
  }
306
683
 
307
- // Try to get description from installed package
684
+ // Read package metadata
308
685
  try {
309
686
  const pkgPath = resolve(__dirname, `../node_modules/${theme.packageName}/package.json`);
310
687
  if (existsSync(pkgPath)) {
@@ -314,21 +691,44 @@ async function main() {
314
691
  }
315
692
  } catch {}
316
693
 
317
- // Bundle it
694
+ // Step 1: Generate snapshot (pre-render in Node.js)
695
+ let hasSnapshot = false;
696
+ let hasCss = false;
697
+ const snapshotResult = generateSnapshot(theme.name, theme.packageName);
698
+ if (snapshotResult && typeof snapshotResult.then === 'function') {
699
+ const html = await snapshotResult;
700
+ hasSnapshot = !!html;
701
+ hasCss = existsSync(resolve(THEMES_DIR, `${theme.name}.css`));
702
+ } else {
703
+ hasSnapshot = !!snapshotResult;
704
+ hasCss = existsSync(resolve(THEMES_DIR, `${theme.name}.css`));
705
+ }
706
+
707
+ // Step 2: Bundle for browser
318
708
  const success = await bundleTheme(theme.name, theme.packageName, shimsDir);
319
709
 
320
710
  if (success) {
321
711
  const outFile = resolve(THEMES_DIR, `${theme.name}.js`);
322
712
  const stat = existsSync(outFile) ? readFileSync(outFile).length : 0;
713
+ const cssFile = resolve(THEMES_DIR, `${theme.name}.css`);
714
+ const cssSize = hasCss && existsSync(cssFile) ? readFileSync(cssFile).length : 0;
715
+
323
716
  manifest.push({
324
717
  name: theme.name,
325
718
  displayName: theme.name.charAt(0).toUpperCase() + theme.name.slice(1).replace(/-/g, ' '),
326
719
  description: theme.description,
327
720
  version: theme.version || '',
328
721
  browserCompatible: true,
722
+ hasSnapshot,
723
+ hasCss,
329
724
  fileSize: stat,
725
+ cssSize,
330
726
  });
331
- console.log(`✅ (${(stat / 1024).toFixed(0)}KB)`);
727
+
728
+ const parts = [`✅ (${(stat / 1024).toFixed(0)}KB`];
729
+ if (hasCss) parts.push(`css:${(cssSize / 1024).toFixed(0)}KB`);
730
+ if (hasSnapshot) parts.push('snapshot');
731
+ console.log(parts.join(', ') + ')');
332
732
  } else {
333
733
  manifest.push({
334
734
  name: theme.name,
@@ -336,9 +736,13 @@ async function main() {
336
736
  description: theme.description,
337
737
  version: theme.version || '',
338
738
  browserCompatible: false,
739
+ hasSnapshot,
740
+ hasCss: false,
339
741
  fileSize: 0,
742
+ cssSize: 0,
340
743
  });
341
- console.log('⚠️ browser incompatible');
744
+ const note = hasSnapshot ? '⚠️ browser incompatible (has snapshot)' : '⚠️ browser incompatible';
745
+ console.log(note);
342
746
  }
343
747
  }
344
748
 
@@ -346,7 +750,8 @@ async function main() {
346
750
  writeFileSync(resolve(THEMES_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
347
751
 
348
752
  const successful = manifest.filter(t => t.browserCompatible).length;
349
- console.log(`\n✅ Done! ${successful}/${manifest.length} themes bundled for the browser`);
753
+ const snapshots = manifest.filter(t => t.hasSnapshot).length;
754
+ console.log(`\n✅ Done! ${successful}/${manifest.length} themes bundled, ${snapshots} snapshots generated`);
350
755
  console.log(`📁 Output: docs/themes/`);
351
756
 
352
757
  // Clean up shims