devfolio-page 0.2.0 → 0.2.2

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.
@@ -3,10 +3,11 @@ import chokidar from 'chokidar';
3
3
  import { existsSync } from 'fs';
4
4
  import http from 'http';
5
5
  import path from 'path';
6
- import { renderCommand } from './render.js';
7
- import fs from 'fs/promises';
6
+ import { buildStaticSiteInMemory } from '../../generator/builder.js';
7
+ import { validatePortfolio } from '../helpers/validate.js';
8
8
  let isBuilding = false;
9
9
  let buildQueued = false;
10
+ let siteCache = new Map();
10
11
  async function rebuild(file, options) {
11
12
  // If already building, queue another build
12
13
  if (isBuilding) {
@@ -17,19 +18,48 @@ async function rebuild(file, options) {
17
18
  try {
18
19
  console.log();
19
20
  console.log(chalk.dim(`[${new Date().toLocaleTimeString()}]`) + ' File changed: ' + chalk.cyan(file));
20
- console.log(chalk.dim('Rebuilding...'));
21
+ console.log(chalk.dim('Rebuilding in memory...'));
21
22
  console.log();
22
- await renderCommand('portfolio.yaml', {
23
+ // Validate and build in memory
24
+ const portfolio = validatePortfolio('portfolio.yaml');
25
+ const result = await buildStaticSiteInMemory(portfolio, {
23
26
  theme: options.theme,
24
- output: options.output || './site',
25
27
  });
28
+ // Update cache
29
+ siteCache = result.files;
26
30
  console.log();
27
- console.log(chalk.green('✓') + ' Rebuild complete!');
31
+ console.log(chalk.green('✓') + ` Rebuilt ${result.fileList.length} files in memory`);
32
+ // Show available pages (HTML only)
33
+ const htmlFiles = Array.from(siteCache.keys()).filter(key => key.endsWith('.html'));
34
+ if (htmlFiles.length > 0) {
35
+ console.log(chalk.dim(' Available pages:'));
36
+ htmlFiles.forEach((key) => {
37
+ // Convert file path to route
38
+ let route = '/' + key.replace(/\.html$/, '').replace(/\/index$/, '');
39
+ if (route === '/')
40
+ route = '/ (home)';
41
+ console.log(chalk.dim(` ${route}`));
42
+ });
43
+ }
28
44
  console.log();
29
45
  }
30
46
  catch (error) {
31
47
  console.error(chalk.red('✗') + ' Build failed:');
32
- console.error(error instanceof Error ? error.message : String(error));
48
+ // Show detailed validation errors if available
49
+ if (error instanceof Error && 'errors' in error) {
50
+ const validationError = error;
51
+ console.error(chalk.red(error.message));
52
+ console.log();
53
+ validationError.errors?.forEach((err) => {
54
+ console.error(chalk.yellow(` ${err.path}`) + `: ${err.message}`);
55
+ if (err.hint) {
56
+ console.error(chalk.dim(` Example: ${err.hint}`));
57
+ }
58
+ });
59
+ }
60
+ else {
61
+ console.error(error instanceof Error ? error.message : String(error));
62
+ }
33
63
  console.log();
34
64
  }
35
65
  finally {
@@ -41,31 +71,69 @@ async function rebuild(file, options) {
41
71
  }
42
72
  }
43
73
  }
44
- function startServer(outputDir, port) {
74
+ function startServer(port) {
45
75
  const server = http.createServer(async (req, res) => {
46
76
  try {
47
77
  // Default to index.html
48
- let filePath = req.url === '/' ? '/index.html' : req.url || '/index.html';
49
- // Remove query string
50
- filePath = filePath.split('?')[0];
51
- const fullPath = path.join(outputDir, filePath);
52
- // Security: prevent directory traversal
53
- const normalizedPath = path.normalize(fullPath);
54
- if (!normalizedPath.startsWith(path.normalize(outputDir))) {
55
- res.writeHead(403);
56
- res.end('Forbidden');
57
- return;
78
+ let filePath = req.url === '/' ? 'index.html' : (req.url || 'index.html');
79
+ // Remove leading slash and query string
80
+ filePath = filePath.replace(/^\//, '').split('?')[0];
81
+ // Debug: log the request
82
+ console.log(chalk.dim(`\n Request: ${req.url} normalized: ${filePath}`));
83
+ // Try different path variations to find the file
84
+ let content;
85
+ let matchedPath;
86
+ // 1. Try exact path
87
+ const tryExact = filePath;
88
+ console.log(chalk.dim(` [1] Exact: ${tryExact} ${siteCache.has(tryExact) ? chalk.green('✓') : chalk.red('✗')}`));
89
+ if (siteCache.has(tryExact)) {
90
+ content = siteCache.get(tryExact);
91
+ matchedPath = tryExact;
92
+ }
93
+ // 2. Try adding .html extension
94
+ if (!content) {
95
+ const tryHtml = filePath + '.html';
96
+ console.log(chalk.dim(` [2] +.html: ${tryHtml} ${siteCache.has(tryHtml) ? chalk.green('✓') : chalk.red('✗')}`));
97
+ if (siteCache.has(tryHtml)) {
98
+ content = siteCache.get(tryHtml);
99
+ matchedPath = tryHtml;
100
+ }
101
+ }
102
+ // 3. Try as directory with index.html
103
+ if (!content) {
104
+ const tryDir = filePath + '/index.html';
105
+ console.log(chalk.dim(` [3] +/index.html: ${tryDir} ${siteCache.has(tryDir) ? chalk.green('✓') : chalk.red('✗')}`));
106
+ if (siteCache.has(tryDir)) {
107
+ content = siteCache.get(tryDir);
108
+ matchedPath = tryDir;
109
+ }
110
+ }
111
+ // 4. Try removing trailing slash and adding index.html
112
+ if (!content && filePath.endsWith('/')) {
113
+ const trySlash = filePath + 'index.html';
114
+ console.log(chalk.dim(` [4] trailing/: ${trySlash} ${siteCache.has(trySlash) ? chalk.green('✓') : chalk.red('✗')}`));
115
+ if (siteCache.has(trySlash)) {
116
+ content = siteCache.get(trySlash);
117
+ matchedPath = trySlash;
118
+ }
58
119
  }
59
- // Check if file exists
60
- if (!existsSync(fullPath)) {
61
- res.writeHead(404);
120
+ // If still not found, return 404
121
+ if (!content) {
122
+ console.log(chalk.dim(` 404: ${req.url}`));
123
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
62
124
  res.end('Not found');
63
125
  return;
64
126
  }
65
- // Read file
66
- const content = await fs.readFile(fullPath);
67
- // Set content type
68
- const ext = path.extname(fullPath);
127
+ // Log successful requests (excluding assets)
128
+ if (matchedPath && matchedPath.endsWith('.html')) {
129
+ console.log(chalk.dim(` 200: ${req.url}`) + chalk.green(` → ${matchedPath}`));
130
+ }
131
+ // Determine content type from original request or file path
132
+ let ext = path.extname(req.url || '');
133
+ // If no extension in URL, check if we're serving HTML
134
+ if (!ext && content) {
135
+ ext = '.html';
136
+ }
69
137
  const contentTypes = {
70
138
  '.html': 'text/html',
71
139
  '.css': 'text/css',
@@ -86,8 +154,8 @@ function startServer(outputDir, port) {
86
154
  res.end(content);
87
155
  }
88
156
  catch (error) {
89
- console.error('Server error:', error);
90
- res.writeHead(500);
157
+ console.error(chalk.red('✗') + ' Server error:', error);
158
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
91
159
  res.end('Internal server error');
92
160
  }
93
161
  });
@@ -96,7 +164,6 @@ function startServer(outputDir, port) {
96
164
  }
97
165
  export async function devCommand(file = 'portfolio.yaml', options = {}) {
98
166
  const port = options.port || 3000;
99
- const outputDir = path.resolve(options.output || './site');
100
167
  // Check if portfolio.yaml exists
101
168
  if (!existsSync(file)) {
102
169
  console.error(chalk.red('✗') + ` File not found: ${file}`);
@@ -111,26 +178,55 @@ export async function devCommand(file = 'portfolio.yaml', options = {}) {
111
178
  console.log(chalk.cyan('└─────────────────────────────────────────────────────────┘'));
112
179
  console.log();
113
180
  // Initial build
114
- console.log(chalk.bold('Building initial site...'));
181
+ console.log(chalk.bold('Building initial site in memory...'));
115
182
  console.log();
116
183
  try {
117
- await renderCommand(file, {
184
+ const portfolio = validatePortfolio(file);
185
+ const result = await buildStaticSiteInMemory(portfolio, {
118
186
  theme: options.theme,
119
- output: outputDir,
120
187
  });
188
+ siteCache = result.files;
189
+ console.log(chalk.green(' ✓') + ` Built ${result.fileList.length} files`);
190
+ console.log();
191
+ // Show available pages (HTML only)
192
+ const htmlFiles = Array.from(siteCache.keys()).filter(key => key.endsWith('.html'));
193
+ if (htmlFiles.length > 0) {
194
+ console.log(chalk.dim(' Available pages:'));
195
+ htmlFiles.forEach((key) => {
196
+ // Convert file path to route
197
+ let route = '/' + key.replace(/\.html$/, '').replace(/\/index$/, '');
198
+ if (route === '/')
199
+ route = '/ (home)';
200
+ console.log(chalk.dim(` ${route}`));
201
+ });
202
+ }
121
203
  }
122
204
  catch (error) {
123
205
  console.error(chalk.red('✗') + ' Initial build failed:');
124
- console.error(error instanceof Error ? error.message : String(error));
206
+ // Show detailed validation errors if available
207
+ if (error instanceof Error && 'errors' in error) {
208
+ const validationError = error;
209
+ console.error(chalk.red(error.message));
210
+ console.log();
211
+ validationError.errors?.forEach((err) => {
212
+ console.error(chalk.yellow(` ${err.path}`) + `: ${err.message}`);
213
+ if (err.hint) {
214
+ console.error(chalk.dim(` Example: ${err.hint}`));
215
+ }
216
+ });
217
+ }
218
+ else {
219
+ console.error(error instanceof Error ? error.message : String(error));
220
+ }
125
221
  process.exit(1);
126
222
  }
127
223
  // Start server
128
- const server = startServer(outputDir, port);
224
+ const server = startServer(port);
129
225
  console.log();
130
226
  console.log(chalk.green('✓') + ' Dev server started!');
131
227
  console.log();
132
228
  console.log(' ' + chalk.bold('Local:') + ' ' + chalk.cyan(`http://localhost:${port}`));
133
- console.log(' ' + chalk.bold('Output:') + ' ' + chalk.dim(outputDir));
229
+ console.log(' ' + chalk.bold('Mode:') + ' ' + chalk.dim('In-memory (no site/ folder)'));
134
230
  console.log();
135
231
  console.log(chalk.dim('Watching for changes...'));
136
232
  console.log(chalk.dim('Press Ctrl+C to stop'));
@@ -35,6 +35,47 @@ function question(rl, prompt) {
35
35
  });
36
36
  });
37
37
  }
38
+ function isValidEmail(email) {
39
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
40
+ return emailRegex.test(email);
41
+ }
42
+ function isValidUrl(url) {
43
+ try {
44
+ const parsed = new URL(url);
45
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ function isValidUsername(username) {
52
+ // Basic validation: alphanumeric, hyphens, underscores
53
+ const usernameRegex = /^[a-zA-Z0-9_-]+$/;
54
+ return usernameRegex.test(username);
55
+ }
56
+ async function questionWithValidation(rl, prompt, validator, optional = false) {
57
+ while (true) {
58
+ const answer = await question(rl, prompt);
59
+ // Allow empty if optional
60
+ if (!answer && optional) {
61
+ return '';
62
+ }
63
+ // Require value if not optional
64
+ if (!answer && !optional) {
65
+ console.log(chalk.red(' ✗ This field is required'));
66
+ continue;
67
+ }
68
+ // Validate if validator provided
69
+ if (answer && validator) {
70
+ const result = validator(answer);
71
+ if (!result.valid) {
72
+ console.log(chalk.red(` ✗ ${result.error}`));
73
+ continue;
74
+ }
75
+ }
76
+ return answer;
77
+ }
78
+ }
38
79
  function printHeader() {
39
80
  console.log();
40
81
  console.log(chalk.cyan('┌────────────────────────────────────────┐'));
@@ -195,26 +236,15 @@ export async function initCommand() {
195
236
  rl.close();
196
237
  process.exit(1);
197
238
  }
198
- const name = await question(rl, chalk.cyan('? ') + 'What\'s your name? ');
199
- if (!name) {
200
- console.log(chalk.red('') + ' Name is required');
201
- process.exit(1);
202
- }
203
- const title = await question(rl, chalk.cyan('? ') + 'What\'s your title/role? ');
204
- if (!title) {
205
- console.log(chalk.red('✗') + ' Title is required');
206
- process.exit(1);
207
- }
208
- const location = await question(rl, chalk.cyan('? ') + 'Where are you located? ');
209
- if (!location) {
210
- console.log(chalk.red('✗') + ' Location is required');
211
- process.exit(1);
212
- }
213
- const email = await question(rl, chalk.cyan('? ') + 'What\'s your email? ');
214
- if (!email) {
215
- console.log(chalk.red('✗') + ' Email is required');
216
- process.exit(1);
217
- }
239
+ const name = await questionWithValidation(rl, chalk.cyan('? ') + 'What\'s your name? ');
240
+ const title = await questionWithValidation(rl, chalk.cyan('? ') + 'What\'s your title/role? ');
241
+ const location = await questionWithValidation(rl, chalk.cyan('? ') + 'Where are you located? ');
242
+ const email = await questionWithValidation(rl, chalk.cyan('? ') + 'What\'s your email? ', (value) => {
243
+ if (!isValidEmail(value)) {
244
+ return { valid: false, error: 'Invalid email format (example: you@example.com)' };
245
+ }
246
+ return { valid: true };
247
+ });
218
248
  // Theme selection
219
249
  console.log();
220
250
  console.log(chalk.cyan('? ') + 'Which theme would you like?');
@@ -236,10 +266,30 @@ export async function initCommand() {
236
266
  console.log(chalk.yellow(' Invalid choice, using SRCL'));
237
267
  }
238
268
  console.log(chalk.dim('\n Optional fields (press Enter to skip)'));
239
- const github = await question(rl, chalk.cyan('? ') + 'GitHub username? ' + chalk.dim('(optional) '));
240
- const linkedin = await question(rl, chalk.cyan('? ') + 'LinkedIn username? ' + chalk.dim('(optional) '));
241
- const twitter = await question(rl, chalk.cyan('? ') + 'Twitter username? ' + chalk.dim('(optional) '));
242
- const website = await question(rl, chalk.cyan('? ') + 'Website URL? ' + chalk.dim('(optional) '));
269
+ const github = await questionWithValidation(rl, chalk.cyan('? ') + 'GitHub username? ' + chalk.dim('(optional) '), (value) => {
270
+ if (!isValidUsername(value)) {
271
+ return { valid: false, error: 'Invalid username format (letters, numbers, hyphens, underscores only)' };
272
+ }
273
+ return { valid: true };
274
+ }, true);
275
+ const linkedin = await questionWithValidation(rl, chalk.cyan('? ') + 'LinkedIn username? ' + chalk.dim('(optional) '), (value) => {
276
+ if (!isValidUsername(value)) {
277
+ return { valid: false, error: 'Invalid username format (letters, numbers, hyphens, underscores only)' };
278
+ }
279
+ return { valid: true };
280
+ }, true);
281
+ const twitter = await questionWithValidation(rl, chalk.cyan('? ') + 'Twitter username? ' + chalk.dim('(optional) '), (value) => {
282
+ if (!isValidUsername(value)) {
283
+ return { valid: false, error: 'Invalid username format (letters, numbers, hyphens, underscores only)' };
284
+ }
285
+ return { valid: true };
286
+ }, true);
287
+ const website = await questionWithValidation(rl, chalk.cyan('? ') + 'Website URL? ' + chalk.dim('(optional) '), (value) => {
288
+ if (!isValidUrl(value)) {
289
+ return { valid: false, error: 'Invalid URL format (must start with http:// or https://)' };
290
+ }
291
+ return { valid: true };
292
+ }, true);
243
293
  rl.close();
244
294
  const input = {
245
295
  name,
@@ -267,12 +317,12 @@ export async function initCommand() {
267
317
  console.log(chalk.dim(' └── ') + chalk.cyan('site/') + chalk.dim(' (generated after render)'));
268
318
  console.log();
269
319
  console.log(chalk.bold('Next steps:'));
270
- console.log(chalk.dim(' 1.') + ' Edit ' + chalk.cyan(`${folderName}/portfolio.yaml`) + ' and add your projects');
320
+ console.log(chalk.dim(' 1.') + ' Edit ' + chalk.cyan(`${folderName}/portfolio.yaml`) + ' and add your content');
271
321
  console.log(chalk.dim(' 2.') + ' Add images to ' + chalk.cyan(`${folderName}/images/`));
272
- console.log(chalk.dim(' 3.') + ' Run: ' + chalk.cyan(`cd ${folderName} && devfolio-page render`));
273
- console.log(chalk.dim(' 4.') + ' Open: ' + chalk.cyan(`${folderName}/site/index.html`) + ' to view');
322
+ console.log(chalk.dim(' 3.') + ' Run: ' + chalk.cyan(`cd ${folderName} && devfolio-page dev`));
323
+ console.log(chalk.dim(' 4.') + ' Open: ' + chalk.cyan(`http://localhost:3000`) + ' in your browser');
274
324
  console.log();
275
- console.log('Tip: Check ' + chalk.cyan('https://devfolio.page/portfolios/') + ' for examples');
325
+ console.log('Tip: Check ' + chalk.cyan('https://devfolio.page/docs') + ' for full documentation');
276
326
  console.log();
277
327
  }
278
328
  catch (err) {
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ const program = new Command();
10
10
  program
11
11
  .name('devfolio-page')
12
12
  .description('Your portfolio as code. Version control it like software.')
13
- .version('0.2.0');
13
+ .version('0.2.1');
14
14
  program
15
15
  .command('init')
16
16
  .description('Create a new portfolio.yaml with interactive prompts')
@@ -33,6 +33,34 @@ export async function buildStaticSite(portfolio, options) {
33
33
  return { outputDir, files };
34
34
  }
35
35
  // =============================================================================
36
+ // In-Memory Build Function (for dev mode)
37
+ // =============================================================================
38
+ export async function buildStaticSiteInMemory(portfolio, options) {
39
+ const theme = options.theme || portfolio.theme || 'srcl';
40
+ const themePath = path.join(import.meta.dirname, 'themes', theme);
41
+ const files = new Map();
42
+ const fileList = [];
43
+ // 1. Copy theme assets (CSS, JS)
44
+ const assetFiles = await copyThemeAssetsInMemory(themePath, portfolio.settings, files);
45
+ fileList.push(...assetFiles);
46
+ // 2. Copy fonts
47
+ const fontFiles = await copyFontsInMemory(files);
48
+ fileList.push(...fontFiles);
49
+ // 3. Generate pages based on portfolio structure
50
+ const hasRichProjects = (portfolio.projects?.length ?? 0) > 0;
51
+ if (hasRichProjects) {
52
+ // Multi-page site with project case studies
53
+ const pageFiles = await generateMultiPageSiteInMemory(portfolio, themePath, theme, files);
54
+ fileList.push(...pageFiles);
55
+ }
56
+ else {
57
+ // Single-page site (backwards compatible)
58
+ const pageFiles = await generateSinglePageSiteInMemory(portfolio, themePath, theme, files);
59
+ fileList.push(...pageFiles);
60
+ }
61
+ return { files, fileList };
62
+ }
63
+ // =============================================================================
36
64
  // Directory Structure
37
65
  // =============================================================================
38
66
  async function createDirectoryStructure(outputDir) {
@@ -554,3 +582,255 @@ async function fileExists(filePath) {
554
582
  return false;
555
583
  }
556
584
  }
585
+ // =============================================================================
586
+ // In-Memory Build Helpers
587
+ // =============================================================================
588
+ async function copyThemeAssetsInMemory(themePath, settings, files) {
589
+ const fileList = [];
590
+ // Copy CSS
591
+ const cssPath = path.join(themePath, 'styles.css');
592
+ if (await fileExists(cssPath)) {
593
+ const content = await fs.readFile(cssPath);
594
+ files.set('assets/styles.css', content);
595
+ fileList.push('assets/styles.css');
596
+ }
597
+ // Copy JS
598
+ const jsPath = path.join(themePath, 'script.js');
599
+ if (await fileExists(jsPath)) {
600
+ const content = await fs.readFile(jsPath);
601
+ files.set('assets/script.js', content);
602
+ fileList.push('assets/script.js');
603
+ }
604
+ return fileList;
605
+ }
606
+ async function copyFontsInMemory(files) {
607
+ const fileList = [];
608
+ const srcFontDir = '/Users/louanne/www-sacred/public/fonts';
609
+ try {
610
+ const fontFiles = await fs.readdir(srcFontDir);
611
+ for (const file of fontFiles) {
612
+ if (file.endsWith('.woff') ||
613
+ file.endsWith('.woff2') ||
614
+ file.endsWith('.ttf') ||
615
+ file.endsWith('.otf') ||
616
+ file.endsWith('.css')) {
617
+ const content = await fs.readFile(path.join(srcFontDir, file));
618
+ files.set(`assets/fonts/${file}`, content);
619
+ fileList.push(`assets/fonts/${file}`);
620
+ }
621
+ }
622
+ }
623
+ catch {
624
+ // Fonts directory doesn't exist or isn't accessible - that's fine
625
+ }
626
+ return fileList;
627
+ }
628
+ async function generateSinglePageSiteInMemory(portfolio, themePath, theme, files) {
629
+ const fileList = [];
630
+ // Load template and partials
631
+ const template = await fs.readFile(path.join(themePath, 'template.html'), 'utf-8');
632
+ const partials = await loadPartials(themePath);
633
+ // Prepare template data
634
+ const templateData = prepareTemplateData(portfolio);
635
+ // Render each partial
636
+ const renderedPartials = renderPartials(partials, templateData);
637
+ // Render main template with settings
638
+ const settings = portfolio.settings || {};
639
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
640
+ const html = Mustache.render(template, {
641
+ ...templateData,
642
+ ...renderedPartials,
643
+ colorScheme: settings.color_scheme || defaultColorScheme,
644
+ showGrid: settings.show_grid || false,
645
+ enableHotkeys: settings.enable_hotkeys !== false,
646
+ animate: settings.animate || 'subtle',
647
+ hasExperience: (portfolio.sections.experience?.length ?? 0) > 0,
648
+ hasProjects: ((portfolio.projects?.length ?? 0) > 0) || ((portfolio.sections.projects?.length ?? 0) > 0),
649
+ hasExperiments: (portfolio.experiments?.length ?? 0) > 0,
650
+ hasSkills: portfolio.sections.skills && Object.keys(portfolio.sections.skills).length > 0,
651
+ hasWriting: (portfolio.sections.writing?.length ?? 0) > 0,
652
+ hasEducation: (portfolio.sections.education?.length ?? 0) > 0,
653
+ });
654
+ files.set('index.html', Buffer.from(html, 'utf-8'));
655
+ fileList.push('index.html');
656
+ return fileList;
657
+ }
658
+ async function generateMultiPageSiteInMemory(portfolio, themePath, theme, files) {
659
+ const fileList = [];
660
+ // Check if theme has multi-page templates
661
+ const hasMultiPageTemplates = await fileExists(path.join(themePath, 'templates/homepage.html'));
662
+ if (!hasMultiPageTemplates) {
663
+ return generateSinglePageSiteInMemory(portfolio, themePath, theme, files);
664
+ }
665
+ // Generate homepage
666
+ const homepageFiles = await generateHomepageInMemory(portfolio, themePath, theme, files);
667
+ fileList.push(...homepageFiles);
668
+ // Generate project pages
669
+ const projectFiles = await generateProjectPagesInMemory(portfolio, themePath, theme, files);
670
+ fileList.push(...projectFiles);
671
+ // Generate projects index
672
+ const projectIndexFiles = await generateProjectsIndexInMemory(portfolio, themePath, theme, files);
673
+ fileList.push(...projectIndexFiles);
674
+ // Generate experiments index
675
+ const experimentsIndexFiles = await generateExperimentsIndexInMemory(portfolio, themePath, theme, files);
676
+ fileList.push(...experimentsIndexFiles);
677
+ // Generate writing index
678
+ const writingIndexFiles = await generateWritingIndexInMemory(portfolio, themePath, theme, files);
679
+ fileList.push(...writingIndexFiles);
680
+ // Copy user images
681
+ await copyUserImagesInMemory(portfolio, files);
682
+ return fileList;
683
+ }
684
+ async function generateHomepageInMemory(portfolio, themePath, theme, files) {
685
+ const templatePath = path.join(themePath, 'templates/homepage.html');
686
+ if (!(await fileExists(templatePath))) {
687
+ return [];
688
+ }
689
+ const template = await fs.readFile(templatePath, 'utf-8');
690
+ const settings = portfolio.settings || {};
691
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
692
+ const data = {
693
+ name: portfolio.meta.name,
694
+ title: portfolio.meta.title,
695
+ location: portfolio.meta.location,
696
+ timezone: portfolio.meta.timezone,
697
+ email: portfolio.contact.email,
698
+ website: portfolio.contact.website,
699
+ github: portfolio.contact.github,
700
+ linkedin: portfolio.contact.linkedin,
701
+ twitter: portfolio.contact.twitter,
702
+ bio: portfolio.bio,
703
+ bio_html: parseBio(portfolio.bio),
704
+ about_short: portfolio.about?.short || '',
705
+ featured_projects: portfolio.projects?.filter((p) => p.featured) || [],
706
+ featured_writing: portfolio.sections.writing?.filter((w) => 'featured' in w && w.featured) || [],
707
+ show_experiments: portfolio.layout?.show_experiments,
708
+ experiments: portfolio.experiments?.slice(0, 4) || [],
709
+ colorScheme: settings.color_scheme || defaultColorScheme,
710
+ showGrid: settings.show_grid || false,
711
+ enableHotkeys: settings.enable_hotkeys !== false,
712
+ animate: settings.animate || 'subtle',
713
+ hasProjects: (portfolio.projects?.length ?? 0) > 0,
714
+ hasExperiments: (portfolio.experiments?.length ?? 0) > 0,
715
+ hasWriting: (portfolio.sections.writing?.length ?? 0) > 0,
716
+ };
717
+ const html = Mustache.render(template, data);
718
+ files.set('index.html', Buffer.from(html, 'utf-8'));
719
+ return ['index.html'];
720
+ }
721
+ async function generateProjectPagesInMemory(portfolio, themePath, theme, files) {
722
+ if (!portfolio.projects || portfolio.projects.length === 0) {
723
+ return [];
724
+ }
725
+ const templatePath = path.join(themePath, 'templates/project.html');
726
+ if (!(await fileExists(templatePath))) {
727
+ return [];
728
+ }
729
+ const template = await fs.readFile(templatePath, 'utf-8');
730
+ const partials = await loadProjectPartials(themePath);
731
+ const settings = portfolio.settings || {};
732
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
733
+ const fileList = [];
734
+ for (const project of portfolio.projects) {
735
+ const sectionsHtml = project.sections
736
+ .map((section) => renderContentSection(section, partials))
737
+ .join('\n');
738
+ const data = {
739
+ ...project,
740
+ sections_html: sectionsHtml,
741
+ site_name: portfolio.meta.name,
742
+ nav_links: generateNavLinks(portfolio, true, 'project'),
743
+ colorScheme: settings.color_scheme || defaultColorScheme,
744
+ showGrid: settings.show_grid || false,
745
+ enableHotkeys: settings.enable_hotkeys !== false,
746
+ };
747
+ const html = Mustache.render(template, data);
748
+ const filename = `projects/${project.id}.html`;
749
+ files.set(filename, Buffer.from(html, 'utf-8'));
750
+ fileList.push(filename);
751
+ }
752
+ return fileList;
753
+ }
754
+ async function generateProjectsIndexInMemory(portfolio, themePath, theme, files) {
755
+ const templatePath = path.join(themePath, 'templates/projects-index.html');
756
+ if (!(await fileExists(templatePath))) {
757
+ return [];
758
+ }
759
+ const template = await fs.readFile(templatePath, 'utf-8');
760
+ const settings = portfolio.settings || {};
761
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
762
+ const data = {
763
+ site_name: portfolio.meta.name,
764
+ projects: portfolio.projects || [],
765
+ colorScheme: settings.color_scheme || defaultColorScheme,
766
+ nav_links: generateNavLinks(portfolio, true, 'projects'),
767
+ };
768
+ const html = Mustache.render(template, data);
769
+ files.set('projects/index.html', Buffer.from(html, 'utf-8'));
770
+ return ['projects/index.html'];
771
+ }
772
+ async function generateExperimentsIndexInMemory(portfolio, themePath, theme, files) {
773
+ if (!portfolio.experiments || portfolio.experiments.length === 0) {
774
+ return [];
775
+ }
776
+ const templatePath = path.join(themePath, 'templates/experiments-index.html');
777
+ if (!(await fileExists(templatePath))) {
778
+ return [];
779
+ }
780
+ const template = await fs.readFile(templatePath, 'utf-8');
781
+ const settings = portfolio.settings || {};
782
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
783
+ const data = {
784
+ site_name: portfolio.meta.name,
785
+ experiments: portfolio.experiments || [],
786
+ colorScheme: settings.color_scheme || defaultColorScheme,
787
+ nav_links: generateNavLinks(portfolio, true, 'experiments'),
788
+ };
789
+ const html = Mustache.render(template, data);
790
+ files.set('experiments/index.html', Buffer.from(html, 'utf-8'));
791
+ return ['experiments/index.html'];
792
+ }
793
+ async function generateWritingIndexInMemory(portfolio, themePath, theme, files) {
794
+ if (!portfolio.sections.writing || portfolio.sections.writing.length === 0) {
795
+ return [];
796
+ }
797
+ const templatePath = path.join(themePath, 'templates/writing-index.html');
798
+ if (!(await fileExists(templatePath))) {
799
+ return [];
800
+ }
801
+ const template = await fs.readFile(templatePath, 'utf-8');
802
+ const settings = portfolio.settings || {};
803
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
804
+ const data = {
805
+ site_name: portfolio.meta.name,
806
+ writing: portfolio.sections.writing || [],
807
+ colorScheme: settings.color_scheme || defaultColorScheme,
808
+ nav_links: generateNavLinks(portfolio, true, 'writing'),
809
+ };
810
+ const html = Mustache.render(template, data);
811
+ files.set('writing/index.html', Buffer.from(html, 'utf-8'));
812
+ return ['writing/index.html'];
813
+ }
814
+ async function copyUserImagesInMemory(portfolio, files) {
815
+ const imagePaths = extractAllImagePaths(portfolio);
816
+ for (const imgPath of imagePaths) {
817
+ if (!imgPath) {
818
+ continue;
819
+ }
820
+ // Skip absolute URLs
821
+ if (imgPath.startsWith('http://') || imgPath.startsWith('https://')) {
822
+ continue;
823
+ }
824
+ const srcPath = imgPath.startsWith('/')
825
+ ? path.join(process.cwd(), imgPath)
826
+ : path.join(process.cwd(), imgPath);
827
+ try {
828
+ const content = await fs.readFile(srcPath);
829
+ files.set(imgPath, content);
830
+ }
831
+ catch {
832
+ // Image doesn't exist - that's okay, it might be a placeholder
833
+ console.warn(`Warning: Could not copy image ${imgPath}`);
834
+ }
835
+ }
836
+ }
@@ -1,5 +1,5 @@
1
1
  /* ============================================
2
- Dark Academia Theme for dev.page
2
+ Dark Academia Theme for devfolio.page
3
3
  Scholarly, vintage, warm aesthetic
4
4
  ============================================ */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /* ============================================
2
- Modern Theme for dev.page
2
+ Modern Theme for devfolio.page
3
3
  Clean, contemporary, minimalist design
4
4
  ============================================ */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /* ============================================
2
- SRCL Theme for dev.page
2
+ SRCL Theme for devfolio.page
3
3
  Adapted from sacred.computer components
4
4
  ============================================ */
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devfolio-page",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Your portfolio as code. Version control it like software.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  /* ============================================
2
- Dark Academia Theme for dev.page
2
+ Dark Academia Theme for devfolio.page
3
3
  Scholarly, vintage, warm aesthetic
4
4
  ============================================ */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /* ============================================
2
- Modern Theme for dev.page
2
+ Modern Theme for devfolio.page
3
3
  Clean, contemporary, minimalist design
4
4
  ============================================ */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /* ============================================
2
- SRCL Theme for dev.page
2
+ SRCL Theme for devfolio.page
3
3
  Adapted from sacred.computer components
4
4
  ============================================ */
5
5