devfolio-page 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +219 -0
  2. package/dist/cli/commands/init.js +282 -0
  3. package/dist/cli/commands/render.js +105 -0
  4. package/dist/cli/commands/themes.js +40 -0
  5. package/dist/cli/commands/validate.js +86 -0
  6. package/dist/cli/helpers/validate.js +99 -0
  7. package/dist/cli/index.js +51 -0
  8. package/dist/cli/postinstall.js +20 -0
  9. package/dist/cli/schemas/portfolio.schema.js +299 -0
  10. package/dist/generator/builder.js +551 -0
  11. package/dist/generator/markdown.js +57 -0
  12. package/dist/generator/themes/dark-academia/partials/education.html +15 -0
  13. package/dist/generator/themes/dark-academia/partials/experience.html +23 -0
  14. package/dist/generator/themes/dark-academia/partials/hero.html +15 -0
  15. package/dist/generator/themes/dark-academia/partials/projects.html +17 -0
  16. package/dist/generator/themes/dark-academia/partials/skills.html +11 -0
  17. package/dist/generator/themes/dark-academia/partials/writing.html +15 -0
  18. package/dist/generator/themes/dark-academia/script.js +91 -0
  19. package/dist/generator/themes/dark-academia/styles.css +913 -0
  20. package/dist/generator/themes/dark-academia/template.html +46 -0
  21. package/dist/generator/themes/dark-academia/templates/experiments-index.html +80 -0
  22. package/dist/generator/themes/dark-academia/templates/homepage.html +125 -0
  23. package/dist/generator/themes/dark-academia/templates/project.html +101 -0
  24. package/dist/generator/themes/dark-academia/templates/projects-index.html +80 -0
  25. package/dist/generator/themes/dark-academia/templates/writing-index.html +75 -0
  26. package/dist/generator/themes/modern/partials/education.html +14 -0
  27. package/dist/generator/themes/modern/partials/experience.html +21 -0
  28. package/dist/generator/themes/modern/partials/hero.html +15 -0
  29. package/dist/generator/themes/modern/partials/projects.html +17 -0
  30. package/dist/generator/themes/modern/partials/skills.html +11 -0
  31. package/dist/generator/themes/modern/partials/writing.html +14 -0
  32. package/dist/generator/themes/modern/script.js +136 -0
  33. package/dist/generator/themes/modern/styles.css +835 -0
  34. package/dist/generator/themes/modern/template.html +59 -0
  35. package/dist/generator/themes/modern/templates/experiments-index.html +78 -0
  36. package/dist/generator/themes/modern/templates/homepage.html +125 -0
  37. package/dist/generator/themes/modern/templates/project.html +98 -0
  38. package/dist/generator/themes/modern/templates/projects-index.html +79 -0
  39. package/dist/generator/themes/modern/templates/writing-index.html +73 -0
  40. package/dist/generator/themes/srcl/partials/education.html +27 -0
  41. package/dist/generator/themes/srcl/partials/experience.html +25 -0
  42. package/dist/generator/themes/srcl/partials/hero.html +22 -0
  43. package/dist/generator/themes/srcl/partials/projects.html +24 -0
  44. package/dist/generator/themes/srcl/partials/sections/code.html +8 -0
  45. package/dist/generator/themes/srcl/partials/sections/demo.html +8 -0
  46. package/dist/generator/themes/srcl/partials/sections/gallery.html +12 -0
  47. package/dist/generator/themes/srcl/partials/sections/image.html +6 -0
  48. package/dist/generator/themes/srcl/partials/sections/interactive.html +8 -0
  49. package/dist/generator/themes/srcl/partials/sections/metrics.html +10 -0
  50. package/dist/generator/themes/srcl/partials/sections/outcomes.html +5 -0
  51. package/dist/generator/themes/srcl/partials/sections/overview.html +5 -0
  52. package/dist/generator/themes/srcl/partials/sections/process.html +5 -0
  53. package/dist/generator/themes/srcl/partials/skills.html +21 -0
  54. package/dist/generator/themes/srcl/partials/writing.html +14 -0
  55. package/dist/generator/themes/srcl/script.js +354 -0
  56. package/dist/generator/themes/srcl/styles.css +1260 -0
  57. package/dist/generator/themes/srcl/template.html +46 -0
  58. package/dist/generator/themes/srcl/templates/experiments-index.html +66 -0
  59. package/dist/generator/themes/srcl/templates/homepage.html +136 -0
  60. package/dist/generator/themes/srcl/templates/project.html +96 -0
  61. package/dist/generator/themes/srcl/templates/projects-index.html +70 -0
  62. package/dist/generator/themes/srcl/templates/writing-index.html +61 -0
  63. package/dist/types/portfolio.js +4 -0
  64. package/package.json +58 -0
  65. package/src/generator/themes/dark-academia/partials/education.html +15 -0
  66. package/src/generator/themes/dark-academia/partials/experience.html +23 -0
  67. package/src/generator/themes/dark-academia/partials/hero.html +15 -0
  68. package/src/generator/themes/dark-academia/partials/projects.html +17 -0
  69. package/src/generator/themes/dark-academia/partials/skills.html +11 -0
  70. package/src/generator/themes/dark-academia/partials/writing.html +15 -0
  71. package/src/generator/themes/dark-academia/script.js +91 -0
  72. package/src/generator/themes/dark-academia/styles.css +913 -0
  73. package/src/generator/themes/dark-academia/template.html +46 -0
  74. package/src/generator/themes/dark-academia/templates/experiments-index.html +80 -0
  75. package/src/generator/themes/dark-academia/templates/homepage.html +125 -0
  76. package/src/generator/themes/dark-academia/templates/project.html +101 -0
  77. package/src/generator/themes/dark-academia/templates/projects-index.html +80 -0
  78. package/src/generator/themes/dark-academia/templates/writing-index.html +75 -0
  79. package/src/generator/themes/modern/partials/education.html +14 -0
  80. package/src/generator/themes/modern/partials/experience.html +21 -0
  81. package/src/generator/themes/modern/partials/hero.html +15 -0
  82. package/src/generator/themes/modern/partials/projects.html +17 -0
  83. package/src/generator/themes/modern/partials/skills.html +11 -0
  84. package/src/generator/themes/modern/partials/writing.html +14 -0
  85. package/src/generator/themes/modern/script.js +136 -0
  86. package/src/generator/themes/modern/styles.css +835 -0
  87. package/src/generator/themes/modern/template.html +59 -0
  88. package/src/generator/themes/modern/templates/experiments-index.html +78 -0
  89. package/src/generator/themes/modern/templates/homepage.html +125 -0
  90. package/src/generator/themes/modern/templates/project.html +98 -0
  91. package/src/generator/themes/modern/templates/projects-index.html +79 -0
  92. package/src/generator/themes/modern/templates/writing-index.html +73 -0
  93. package/src/generator/themes/srcl/partials/education.html +27 -0
  94. package/src/generator/themes/srcl/partials/experience.html +25 -0
  95. package/src/generator/themes/srcl/partials/hero.html +22 -0
  96. package/src/generator/themes/srcl/partials/projects.html +24 -0
  97. package/src/generator/themes/srcl/partials/sections/code.html +8 -0
  98. package/src/generator/themes/srcl/partials/sections/demo.html +8 -0
  99. package/src/generator/themes/srcl/partials/sections/gallery.html +12 -0
  100. package/src/generator/themes/srcl/partials/sections/image.html +6 -0
  101. package/src/generator/themes/srcl/partials/sections/interactive.html +8 -0
  102. package/src/generator/themes/srcl/partials/sections/metrics.html +10 -0
  103. package/src/generator/themes/srcl/partials/sections/outcomes.html +5 -0
  104. package/src/generator/themes/srcl/partials/sections/overview.html +5 -0
  105. package/src/generator/themes/srcl/partials/sections/process.html +5 -0
  106. package/src/generator/themes/srcl/partials/skills.html +21 -0
  107. package/src/generator/themes/srcl/partials/writing.html +14 -0
  108. package/src/generator/themes/srcl/script.js +354 -0
  109. package/src/generator/themes/srcl/styles.css +1260 -0
  110. package/src/generator/themes/srcl/template.html +46 -0
  111. package/src/generator/themes/srcl/templates/experiments-index.html +66 -0
  112. package/src/generator/themes/srcl/templates/homepage.html +136 -0
  113. package/src/generator/themes/srcl/templates/project.html +96 -0
  114. package/src/generator/themes/srcl/templates/projects-index.html +70 -0
  115. package/src/generator/themes/srcl/templates/writing-index.html +61 -0
@@ -0,0 +1,551 @@
1
+ import { parseMarkdown, highlightCode, parseBio } from './markdown.js';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import Mustache from 'mustache';
5
+ // =============================================================================
6
+ // Main Build Function
7
+ // =============================================================================
8
+ export async function buildStaticSite(portfolio, options) {
9
+ const { outputDir } = options;
10
+ const theme = options.theme || portfolio.theme || 'srcl';
11
+ const themePath = path.join(import.meta.dirname, 'themes', theme);
12
+ const files = [];
13
+ // 1. Create directory structure
14
+ await createDirectoryStructure(outputDir);
15
+ // 2. Copy theme assets (CSS, JS)
16
+ const assetFiles = await copyThemeAssets(themePath, outputDir, portfolio.settings);
17
+ files.push(...assetFiles);
18
+ // 3. Copy fonts
19
+ const fontFiles = await copyFonts(outputDir);
20
+ files.push(...fontFiles);
21
+ // 4. Generate pages based on portfolio structure
22
+ const hasRichProjects = (portfolio.projects?.length ?? 0) > 0;
23
+ if (hasRichProjects) {
24
+ // Multi-page site with project case studies
25
+ const pageFiles = await generateMultiPageSite(portfolio, themePath, outputDir, theme);
26
+ files.push(...pageFiles);
27
+ }
28
+ else {
29
+ // Single-page site (backwards compatible)
30
+ const pageFiles = await generateSinglePageSite(portfolio, themePath, outputDir, theme);
31
+ files.push(...pageFiles);
32
+ }
33
+ return { outputDir, files };
34
+ }
35
+ // =============================================================================
36
+ // Directory Structure
37
+ // =============================================================================
38
+ async function createDirectoryStructure(outputDir) {
39
+ await fs.mkdir(outputDir, { recursive: true });
40
+ await fs.mkdir(path.join(outputDir, 'assets'), { recursive: true });
41
+ await fs.mkdir(path.join(outputDir, 'assets/fonts'), { recursive: true });
42
+ await fs.mkdir(path.join(outputDir, 'projects'), { recursive: true });
43
+ await fs.mkdir(path.join(outputDir, 'images'), { recursive: true });
44
+ }
45
+ // =============================================================================
46
+ // Single-Page Site (Backwards Compatible)
47
+ // =============================================================================
48
+ async function generateSinglePageSite(portfolio, themePath, outputDir, theme) {
49
+ const files = [];
50
+ // Load template and partials
51
+ const template = await fs.readFile(path.join(themePath, 'template.html'), 'utf-8');
52
+ const partials = await loadPartials(themePath);
53
+ // Prepare template data
54
+ const templateData = prepareTemplateData(portfolio);
55
+ // Render each partial
56
+ const renderedPartials = renderPartials(partials, templateData);
57
+ // Render main template with settings
58
+ const settings = portfolio.settings || {};
59
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
60
+ const html = Mustache.render(template, {
61
+ ...templateData,
62
+ ...renderedPartials,
63
+ // Settings
64
+ colorScheme: settings.color_scheme || defaultColorScheme,
65
+ showGrid: settings.show_grid || false,
66
+ enableHotkeys: settings.enable_hotkeys !== false,
67
+ animate: settings.animate || 'subtle',
68
+ // Section existence flags for conditional nav
69
+ hasExperience: (portfolio.sections.experience?.length ?? 0) > 0,
70
+ hasProjects: (portfolio.sections.projects?.length ?? 0) > 0,
71
+ hasSkills: portfolio.sections.skills && Object.keys(portfolio.sections.skills).length > 0,
72
+ hasWriting: (portfolio.sections.writing?.length ?? 0) > 0,
73
+ hasEducation: (portfolio.sections.education?.length ?? 0) > 0,
74
+ });
75
+ // Write HTML
76
+ await fs.writeFile(path.join(outputDir, 'index.html'), html);
77
+ files.push('index.html');
78
+ return files;
79
+ }
80
+ // =============================================================================
81
+ // Multi-Page Site
82
+ // =============================================================================
83
+ async function generateMultiPageSite(portfolio, themePath, outputDir, theme) {
84
+ const files = [];
85
+ // Check if theme has multi-page templates
86
+ const hasMultiPageTemplates = await fileExists(path.join(themePath, 'templates/homepage.html'));
87
+ if (!hasMultiPageTemplates) {
88
+ // Fall back to single-page generation
89
+ return generateSinglePageSite(portfolio, themePath, outputDir, theme);
90
+ }
91
+ // Generate homepage
92
+ const homepageFiles = await generateHomepage(portfolio, themePath, outputDir, theme);
93
+ files.push(...homepageFiles);
94
+ // Generate project pages
95
+ const projectFiles = await generateProjectPages(portfolio, themePath, outputDir, theme);
96
+ files.push(...projectFiles);
97
+ // Generate projects index
98
+ const projectIndexFiles = await generateProjectsIndex(portfolio, themePath, outputDir, theme);
99
+ files.push(...projectIndexFiles);
100
+ // Generate experiments index
101
+ const experimentsIndexFiles = await generateExperimentsIndex(portfolio, themePath, outputDir, theme);
102
+ files.push(...experimentsIndexFiles);
103
+ // Generate writing index
104
+ const writingIndexFiles = await generateWritingIndex(portfolio, themePath, outputDir, theme);
105
+ files.push(...writingIndexFiles);
106
+ // Copy user images
107
+ await copyUserImages(portfolio, outputDir);
108
+ return files;
109
+ }
110
+ async function generateHomepage(portfolio, themePath, outputDir, theme) {
111
+ const templatePath = path.join(themePath, 'templates/homepage.html');
112
+ if (!(await fileExists(templatePath))) {
113
+ return [];
114
+ }
115
+ const template = await fs.readFile(templatePath, 'utf-8');
116
+ const settings = portfolio.settings || {};
117
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
118
+ const data = {
119
+ // Meta
120
+ name: portfolio.meta.name,
121
+ title: portfolio.meta.title,
122
+ location: portfolio.meta.location,
123
+ timezone: portfolio.meta.timezone,
124
+ // Contact
125
+ email: portfolio.contact.email,
126
+ website: portfolio.contact.website,
127
+ github: portfolio.contact.github,
128
+ linkedin: portfolio.contact.linkedin,
129
+ twitter: portfolio.contact.twitter,
130
+ // Bio
131
+ bio: portfolio.bio,
132
+ bio_html: parseBio(portfolio.bio),
133
+ about_short: portfolio.about?.short || '',
134
+ // Featured content
135
+ featured_projects: portfolio.projects?.filter((p) => p.featured) || [],
136
+ featured_writing: portfolio.sections.writing?.filter((w) => 'featured' in w && w.featured) || [],
137
+ // Experiments preview
138
+ show_experiments: portfolio.layout?.show_experiments,
139
+ experiments: portfolio.experiments?.slice(0, 4) || [],
140
+ // Settings
141
+ colorScheme: settings.color_scheme || defaultColorScheme,
142
+ showGrid: settings.show_grid || false,
143
+ enableHotkeys: settings.enable_hotkeys !== false,
144
+ animate: settings.animate || 'subtle',
145
+ // Navigation flags
146
+ hasProjects: (portfolio.projects?.length ?? 0) > 0,
147
+ hasExperiments: (portfolio.experiments?.length ?? 0) > 0,
148
+ hasWriting: (portfolio.sections.writing?.length ?? 0) > 0,
149
+ };
150
+ const html = Mustache.render(template, data);
151
+ await fs.writeFile(path.join(outputDir, 'index.html'), html);
152
+ return ['index.html'];
153
+ }
154
+ async function generateProjectPages(portfolio, themePath, outputDir, theme) {
155
+ if (!portfolio.projects || portfolio.projects.length === 0) {
156
+ return [];
157
+ }
158
+ const templatePath = path.join(themePath, 'templates/project.html');
159
+ if (!(await fileExists(templatePath))) {
160
+ return [];
161
+ }
162
+ const template = await fs.readFile(templatePath, 'utf-8');
163
+ const partials = await loadProjectPartials(themePath);
164
+ const settings = portfolio.settings || {};
165
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
166
+ const files = [];
167
+ for (const project of portfolio.projects) {
168
+ // Render each content section
169
+ const sectionsHtml = project.sections
170
+ .map((section) => renderContentSection(section, partials))
171
+ .join('\n');
172
+ const data = {
173
+ // Project data
174
+ ...project,
175
+ sections_html: sectionsHtml,
176
+ // Site meta
177
+ site_name: portfolio.meta.name,
178
+ // Navigation (fromSubdir=true since we're in /projects/)
179
+ nav_links: generateNavLinks(portfolio, true, 'project'),
180
+ // Settings
181
+ colorScheme: settings.color_scheme || defaultColorScheme,
182
+ showGrid: settings.show_grid || false,
183
+ enableHotkeys: settings.enable_hotkeys !== false,
184
+ };
185
+ const html = Mustache.render(template, data);
186
+ const filename = `projects/${project.id}.html`;
187
+ await fs.writeFile(path.join(outputDir, filename), html);
188
+ files.push(filename);
189
+ }
190
+ return files;
191
+ }
192
+ async function generateProjectsIndex(portfolio, themePath, outputDir, theme) {
193
+ const templatePath = path.join(themePath, 'templates/projects-index.html');
194
+ if (!(await fileExists(templatePath))) {
195
+ return [];
196
+ }
197
+ const template = await fs.readFile(templatePath, 'utf-8');
198
+ const settings = portfolio.settings || {};
199
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
200
+ const data = {
201
+ site_name: portfolio.meta.name,
202
+ projects: portfolio.projects || [],
203
+ colorScheme: settings.color_scheme || defaultColorScheme,
204
+ nav_links: generateNavLinks(portfolio, true, 'projects'),
205
+ };
206
+ const html = Mustache.render(template, data);
207
+ await fs.writeFile(path.join(outputDir, 'projects/index.html'), html);
208
+ return ['projects/index.html'];
209
+ }
210
+ async function generateExperimentsIndex(portfolio, themePath, outputDir, theme) {
211
+ // Only generate if experiments exist
212
+ if (!portfolio.experiments || portfolio.experiments.length === 0) {
213
+ return [];
214
+ }
215
+ const templatePath = path.join(themePath, 'templates/experiments-index.html');
216
+ if (!(await fileExists(templatePath))) {
217
+ return [];
218
+ }
219
+ await fs.mkdir(path.join(outputDir, 'experiments'), { recursive: true });
220
+ const template = await fs.readFile(templatePath, 'utf-8');
221
+ const settings = portfolio.settings || {};
222
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
223
+ const data = {
224
+ site_name: portfolio.meta.name,
225
+ experiments: portfolio.experiments || [],
226
+ colorScheme: settings.color_scheme || defaultColorScheme,
227
+ nav_links: generateNavLinks(portfolio, true, 'experiments'),
228
+ };
229
+ const html = Mustache.render(template, data);
230
+ await fs.writeFile(path.join(outputDir, 'experiments/index.html'), html);
231
+ return ['experiments/index.html'];
232
+ }
233
+ async function generateWritingIndex(portfolio, themePath, outputDir, theme) {
234
+ // Only generate if writing exists
235
+ if (!portfolio.sections.writing || portfolio.sections.writing.length === 0) {
236
+ return [];
237
+ }
238
+ const templatePath = path.join(themePath, 'templates/writing-index.html');
239
+ if (!(await fileExists(templatePath))) {
240
+ return [];
241
+ }
242
+ await fs.mkdir(path.join(outputDir, 'writing'), { recursive: true });
243
+ const template = await fs.readFile(templatePath, 'utf-8');
244
+ const settings = portfolio.settings || {};
245
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
246
+ const data = {
247
+ site_name: portfolio.meta.name,
248
+ writing: portfolio.sections.writing || [],
249
+ colorScheme: settings.color_scheme || defaultColorScheme,
250
+ nav_links: generateNavLinks(portfolio, true, 'writing'),
251
+ };
252
+ const html = Mustache.render(template, data);
253
+ await fs.writeFile(path.join(outputDir, 'writing/index.html'), html);
254
+ return ['writing/index.html'];
255
+ }
256
+ // =============================================================================
257
+ // Content Section Rendering
258
+ // =============================================================================
259
+ async function loadProjectPartials(themePath) {
260
+ const partialsDir = path.join(themePath, 'partials/sections');
261
+ const partialNames = [
262
+ 'overview',
263
+ 'image',
264
+ 'gallery',
265
+ 'demo',
266
+ 'code',
267
+ 'metrics',
268
+ 'outcomes',
269
+ 'process',
270
+ 'interactive',
271
+ ];
272
+ const partials = {};
273
+ for (const name of partialNames) {
274
+ const partialPath = path.join(partialsDir, `${name}.html`);
275
+ if (await fileExists(partialPath)) {
276
+ partials[name] = await fs.readFile(partialPath, 'utf-8');
277
+ }
278
+ else {
279
+ // Default fallback templates
280
+ partials[name] = getDefaultSectionTemplate(name);
281
+ }
282
+ }
283
+ return partials;
284
+ }
285
+ function getDefaultSectionTemplate(type) {
286
+ const defaults = {
287
+ overview: '<div class="section-overview">{{{content_html}}}</div>',
288
+ image: '<figure class="section-image"><img src="{{src}}" alt="{{alt}}">{{#caption}}<figcaption>{{caption}}</figcaption>{{/caption}}</figure>',
289
+ gallery: '<div class="section-gallery">{{#images}}<figure><img src="{{src}}" alt="{{alt}}">{{#caption}}<figcaption>{{caption}}</figcaption>{{/caption}}</figure>{{/images}}</div>',
290
+ demo: '<div class="section-demo">{{#title}}<h3>{{title}}</h3>{{/title}}{{{embed}}}</div>',
291
+ code: '<div class="section-code">{{#title}}<h3>{{title}}</h3>{{/title}}{{{code_html}}}</div>',
292
+ metrics: '<div class="section-metrics">{{#data}}<div class="metric"><span class="metric-value">{{value}}</span><span class="metric-label">{{label}}</span></div>{{/data}}</div>',
293
+ outcomes: '<div class="section-outcomes">{{{content_html}}}</div>',
294
+ process: '<div class="section-process">{{{content_html}}}</div>',
295
+ interactive: '<div class="section-interactive">{{#title}}<h3>{{title}}</h3>{{/title}}<iframe src="{{url}}" frameborder="0"></iframe></div>',
296
+ };
297
+ return defaults[type] || '';
298
+ }
299
+ function renderContentSection(section, partials) {
300
+ switch (section.type) {
301
+ case 'overview':
302
+ return Mustache.render(partials.overview, {
303
+ content_html: parseMarkdown(section.content),
304
+ });
305
+ case 'image':
306
+ return Mustache.render(partials.image, section);
307
+ case 'gallery':
308
+ return Mustache.render(partials.gallery, section);
309
+ case 'demo':
310
+ return Mustache.render(partials.demo, section);
311
+ case 'code':
312
+ return Mustache.render(partials.code, {
313
+ ...section,
314
+ code_html: highlightCode(section.code, section.language),
315
+ });
316
+ case 'metrics':
317
+ return Mustache.render(partials.metrics, section);
318
+ case 'outcomes':
319
+ return Mustache.render(partials.outcomes, {
320
+ content_html: parseMarkdown(section.content),
321
+ });
322
+ case 'process':
323
+ return Mustache.render(partials.process, {
324
+ content_html: parseMarkdown(section.content),
325
+ });
326
+ case 'interactive':
327
+ return Mustache.render(partials.interactive, section);
328
+ default:
329
+ return '';
330
+ }
331
+ }
332
+ function generateNavLinks(portfolio, fromSubdir = false, currentPage = 'home') {
333
+ const links = [];
334
+ const prefix = fromSubdir ? '../' : './';
335
+ // Note: "Home" is not included since the site name already links home
336
+ if ((portfolio.projects?.length ?? 0) > 0) {
337
+ links.push({
338
+ href: `${prefix}projects/`,
339
+ label: 'Projects',
340
+ active: currentPage === 'projects' || currentPage === 'project',
341
+ });
342
+ }
343
+ if ((portfolio.experiments?.length ?? 0) > 0) {
344
+ links.push({
345
+ href: `${prefix}experiments/`,
346
+ label: 'Experiments',
347
+ active: currentPage === 'experiments',
348
+ });
349
+ }
350
+ if ((portfolio.sections.writing?.length ?? 0) > 0) {
351
+ links.push({
352
+ href: `${prefix}writing/`,
353
+ label: 'Writing',
354
+ active: currentPage === 'writing',
355
+ });
356
+ }
357
+ return links;
358
+ }
359
+ // =============================================================================
360
+ // Legacy Single-Page Helpers
361
+ // =============================================================================
362
+ async function loadPartials(themePath) {
363
+ const partialsDir = path.join(themePath, 'partials');
364
+ const partialNames = ['hero', 'experience', 'projects', 'skills', 'writing', 'education'];
365
+ const partials = {};
366
+ for (const name of partialNames) {
367
+ const partialPath = path.join(partialsDir, `${name}.html`);
368
+ if (await fileExists(partialPath)) {
369
+ partials[name] = await fs.readFile(partialPath, 'utf-8');
370
+ }
371
+ else {
372
+ partials[name] = '';
373
+ }
374
+ }
375
+ return partials;
376
+ }
377
+ function prepareTemplateData(portfolio) {
378
+ // Mark first experience item for default expansion
379
+ const experiences = (portfolio.sections.experience || []).map((exp, index) => ({
380
+ ...exp,
381
+ first: index === 0,
382
+ }));
383
+ // Format skills for table display
384
+ const skillCategories = formatSkills(portfolio.sections.skills || {});
385
+ // Format education
386
+ const educations = portfolio.sections.education || [];
387
+ return {
388
+ // Meta
389
+ name: portfolio.meta.name,
390
+ title: portfolio.meta.title,
391
+ location: portfolio.meta.location,
392
+ timezone: portfolio.meta.timezone,
393
+ // Contact
394
+ email: portfolio.contact.email,
395
+ website: portfolio.contact.website,
396
+ github: portfolio.contact.github,
397
+ linkedin: portfolio.contact.linkedin,
398
+ twitter: portfolio.contact.twitter,
399
+ // Bio
400
+ bio: portfolio.bio,
401
+ bio_html: parseBio(portfolio.bio),
402
+ // Sections
403
+ experiences,
404
+ projects: portfolio.sections.projects || [],
405
+ skillCategories,
406
+ articles: portfolio.sections.writing || [],
407
+ educations,
408
+ };
409
+ }
410
+ function renderPartials(partials, templateData) {
411
+ const rendered = {};
412
+ for (const [name, template] of Object.entries(partials)) {
413
+ if (template) {
414
+ rendered[name] = Mustache.render(template, templateData);
415
+ }
416
+ else {
417
+ rendered[name] = '';
418
+ }
419
+ }
420
+ return rendered;
421
+ }
422
+ function formatSkills(skills) {
423
+ return Object.entries(skills).map(([category, techs]) => ({
424
+ category,
425
+ skills: techs.join(', '),
426
+ }));
427
+ }
428
+ // =============================================================================
429
+ // Asset Copying
430
+ // =============================================================================
431
+ async function copyThemeAssets(themePath, outputDir, settings) {
432
+ const files = [];
433
+ // Copy CSS
434
+ const cssPath = path.join(themePath, 'styles.css');
435
+ if (await fileExists(cssPath)) {
436
+ await fs.copyFile(cssPath, path.join(outputDir, 'assets/styles.css'));
437
+ files.push('assets/styles.css');
438
+ }
439
+ // Copy JS (always needed for theme persistence and interactivity)
440
+ const jsPath = path.join(themePath, 'script.js');
441
+ if (await fileExists(jsPath)) {
442
+ await fs.copyFile(jsPath, path.join(outputDir, 'assets/script.js'));
443
+ files.push('assets/script.js');
444
+ }
445
+ return files;
446
+ }
447
+ async function copyFonts(outputDir) {
448
+ const files = [];
449
+ // Try to copy fonts from www-sacred if available
450
+ const srcFontDir = '/Users/louanne/www-sacred/public/fonts';
451
+ const destFontDir = path.join(outputDir, 'assets/fonts');
452
+ try {
453
+ const fontFiles = await fs.readdir(srcFontDir);
454
+ if (fontFiles.length > 0) {
455
+ await fs.mkdir(destFontDir, { recursive: true });
456
+ for (const file of fontFiles) {
457
+ if (file.endsWith('.woff') ||
458
+ file.endsWith('.woff2') ||
459
+ file.endsWith('.ttf') ||
460
+ file.endsWith('.otf') ||
461
+ file.endsWith('.css')) {
462
+ await fs.copyFile(path.join(srcFontDir, file), path.join(destFontDir, file));
463
+ files.push(`assets/fonts/${file}`);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ catch {
469
+ // Fonts directory doesn't exist or isn't accessible - that's fine
470
+ // The CSS will fall back to system fonts
471
+ }
472
+ return files;
473
+ }
474
+ async function copyUserImages(portfolio, outputDir) {
475
+ const imagePaths = extractAllImagePaths(portfolio);
476
+ for (const imgPath of imagePaths) {
477
+ // Skip absolute URLs
478
+ if (imgPath.startsWith('http://') || imgPath.startsWith('https://')) {
479
+ continue;
480
+ }
481
+ // Skip paths that start with / (they're relative to the site root)
482
+ const srcPath = imgPath.startsWith('/')
483
+ ? path.join(process.cwd(), imgPath)
484
+ : path.join(process.cwd(), imgPath);
485
+ const destPath = path.join(outputDir, imgPath);
486
+ try {
487
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
488
+ await fs.copyFile(srcPath, destPath);
489
+ }
490
+ catch {
491
+ // Image doesn't exist - that's okay, it might be a placeholder
492
+ console.warn(`Warning: Could not copy image ${imgPath}`);
493
+ }
494
+ }
495
+ }
496
+ function extractAllImagePaths(portfolio) {
497
+ const paths = [];
498
+ // Extract from meta
499
+ const meta = portfolio.meta;
500
+ if (meta.avatar)
501
+ paths.push(meta.avatar);
502
+ if (meta.hero_image)
503
+ paths.push(meta.hero_image);
504
+ // Extract from rich projects
505
+ portfolio.projects?.forEach((project) => {
506
+ paths.push(project.thumbnail);
507
+ if (project.hero)
508
+ paths.push(project.hero);
509
+ project.sections.forEach((section) => {
510
+ if (section.type === 'image') {
511
+ paths.push(section.src);
512
+ }
513
+ else if (section.type === 'gallery') {
514
+ section.images.forEach((img) => paths.push(img.src));
515
+ }
516
+ });
517
+ });
518
+ // Extract from experiments
519
+ portfolio.experiments?.forEach((exp) => {
520
+ if (exp.image)
521
+ paths.push(exp.image);
522
+ });
523
+ // Extract from writing
524
+ portfolio.sections.writing?.forEach((post) => {
525
+ if ('cover' in post && post.cover)
526
+ paths.push(post.cover);
527
+ });
528
+ // Extract from timeline
529
+ portfolio.timeline?.forEach((item) => {
530
+ if (item.image)
531
+ paths.push(item.image);
532
+ });
533
+ // Extract from testimonials
534
+ portfolio.testimonials?.forEach((testimonial) => {
535
+ if (testimonial.image)
536
+ paths.push(testimonial.image);
537
+ });
538
+ return paths;
539
+ }
540
+ // =============================================================================
541
+ // Utilities
542
+ // =============================================================================
543
+ async function fileExists(filePath) {
544
+ try {
545
+ await fs.access(filePath);
546
+ return true;
547
+ }
548
+ catch {
549
+ return false;
550
+ }
551
+ }
@@ -0,0 +1,57 @@
1
+ import { marked } from 'marked';
2
+ // Configure marked for safe rendering
3
+ marked.setOptions({
4
+ gfm: true, // GitHub Flavored Markdown
5
+ breaks: true, // Convert \n to <br>
6
+ });
7
+ /**
8
+ * Parse markdown content to HTML
9
+ */
10
+ export function parseMarkdown(content) {
11
+ return marked.parse(content);
12
+ }
13
+ /**
14
+ * Parse inline markdown (no block elements like <p>)
15
+ */
16
+ export function parseMarkdownInline(content) {
17
+ return marked.parseInline(content);
18
+ }
19
+ /**
20
+ * Highlight code with language class for syntax highlighting
21
+ * Uses CSS classes compatible with Prism.js or highlight.js
22
+ */
23
+ export function highlightCode(code, language) {
24
+ const escapedCode = escapeHtml(code);
25
+ const langClass = language ? `language-${language}` : '';
26
+ return `<pre><code class="${langClass}">${escapedCode}</code></pre>`;
27
+ }
28
+ /**
29
+ * Escape HTML special characters to prevent XSS
30
+ */
31
+ function escapeHtml(text) {
32
+ const map = {
33
+ '&': '&amp;',
34
+ '<': '&lt;',
35
+ '>': '&gt;',
36
+ '"': '&quot;',
37
+ "'": '&#039;',
38
+ };
39
+ return text.replace(/[&<>"']/g, (m) => map[m]);
40
+ }
41
+ /**
42
+ * Parse bio text - preserves paragraph breaks
43
+ */
44
+ export function parseBio(bio) {
45
+ // Split on double newlines to create paragraphs
46
+ const paragraphs = bio
47
+ .split(/\n\n+/)
48
+ .map((p) => p.trim())
49
+ .filter((p) => p.length > 0);
50
+ return paragraphs.map((p) => `<p>${parseMarkdownInline(p)}</p>`).join('\n');
51
+ }
52
+ /**
53
+ * Parse a list of highlights into HTML list items
54
+ */
55
+ export function parseHighlights(highlights) {
56
+ return highlights.map((h) => `<li>${parseMarkdownInline(h)}</li>`).join('\n');
57
+ }
@@ -0,0 +1,15 @@
1
+ <section id="education" class="section">
2
+ <h2 class="section-title">Education</h2>
3
+ <div class="education-list">
4
+ {{#educations}}
5
+ <div class="education-item">
6
+ <div class="education-date">{{date.start}} &ndash; {{date.end}}</div>
7
+ <div class="education-details">
8
+ <div class="education-institution">{{institution}}</div>
9
+ <div class="education-degree">{{degree}}</div>
10
+ {{#description}}<div class="education-description">{{description}}</div>{{/description}}
11
+ </div>
12
+ </div>
13
+ {{/educations}}
14
+ </div>
15
+ </section>
@@ -0,0 +1,23 @@
1
+ <section id="experience" class="section">
2
+ <h2 class="section-title">Experience</h2>
3
+ <div class="experience-list">
4
+ {{#experiences}}
5
+ <div class="experience-item">
6
+ <div class="experience-date">{{date.start}} &ndash; {{date.end}}</div>
7
+ <div class="experience-details">
8
+ <div class="experience-header">
9
+ <span class="experience-company">{{company}}</span>
10
+ <span class="experience-role"> &mdash; {{role}}</span>
11
+ </div>
12
+ {{#highlights.length}}
13
+ <ul class="experience-highlights">
14
+ {{#highlights}}
15
+ <li>{{.}}</li>
16
+ {{/highlights}}
17
+ </ul>
18
+ {{/highlights.length}}
19
+ </div>
20
+ </div>
21
+ {{/experiences}}
22
+ </div>
23
+ </section>
@@ -0,0 +1,15 @@
1
+ <section class="hero">
2
+ <h1 class="hero-name">{{name}}</h1>
3
+ <p class="hero-title">{{title}}</p>
4
+ <p class="hero-location">{{location}}{{#timezone}} &middot; {{timezone}}{{/timezone}}</p>
5
+
6
+ <div class="hero-links">
7
+ {{#email}}<a href="mailto:{{email}}" class="hero-link">Email</a>{{/email}}
8
+ {{#website}}<a href="{{website}}" target="_blank" class="hero-link">Website</a>{{/website}}
9
+ {{#github}}<a href="https://github.com/{{github}}" target="_blank" class="hero-link">GitHub</a>{{/github}}
10
+ {{#linkedin}}<a href="https://linkedin.com/in/{{linkedin}}" target="_blank" class="hero-link">LinkedIn</a>{{/linkedin}}
11
+ {{#twitter}}<a href="https://twitter.com/{{twitter}}" target="_blank" class="hero-link">Twitter</a>{{/twitter}}
12
+ </div>
13
+
14
+ <p class="hero-bio">{{bio}}</p>
15
+ </section>
@@ -0,0 +1,17 @@
1
+ <section id="projects" class="section">
2
+ <h2 class="section-title">Projects</h2>
3
+ <div class="projects-list">
4
+ {{#projects}}
5
+ <div class="project-item{{#featured}} featured{{/featured}}">
6
+ <div class="project-header">
7
+ <h3 class="project-name">{{name}}</h3>
8
+ {{#url}}<a href="{{url}}" target="_blank" class="project-link">View &rarr;</a>{{/url}}
9
+ </div>
10
+ <p class="project-description">{{description}}</p>
11
+ <div class="project-tags">
12
+ {{#tags}}<span class="project-tag">{{.}}</span>{{/tags}}
13
+ </div>
14
+ </div>
15
+ {{/projects}}
16
+ </div>
17
+ </section>