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.
- package/README.md +219 -0
- package/dist/cli/commands/init.js +282 -0
- package/dist/cli/commands/render.js +105 -0
- package/dist/cli/commands/themes.js +40 -0
- package/dist/cli/commands/validate.js +86 -0
- package/dist/cli/helpers/validate.js +99 -0
- package/dist/cli/index.js +51 -0
- package/dist/cli/postinstall.js +20 -0
- package/dist/cli/schemas/portfolio.schema.js +299 -0
- package/dist/generator/builder.js +551 -0
- package/dist/generator/markdown.js +57 -0
- package/dist/generator/themes/dark-academia/partials/education.html +15 -0
- package/dist/generator/themes/dark-academia/partials/experience.html +23 -0
- package/dist/generator/themes/dark-academia/partials/hero.html +15 -0
- package/dist/generator/themes/dark-academia/partials/projects.html +17 -0
- package/dist/generator/themes/dark-academia/partials/skills.html +11 -0
- package/dist/generator/themes/dark-academia/partials/writing.html +15 -0
- package/dist/generator/themes/dark-academia/script.js +91 -0
- package/dist/generator/themes/dark-academia/styles.css +913 -0
- package/dist/generator/themes/dark-academia/template.html +46 -0
- package/dist/generator/themes/dark-academia/templates/experiments-index.html +80 -0
- package/dist/generator/themes/dark-academia/templates/homepage.html +125 -0
- package/dist/generator/themes/dark-academia/templates/project.html +101 -0
- package/dist/generator/themes/dark-academia/templates/projects-index.html +80 -0
- package/dist/generator/themes/dark-academia/templates/writing-index.html +75 -0
- package/dist/generator/themes/modern/partials/education.html +14 -0
- package/dist/generator/themes/modern/partials/experience.html +21 -0
- package/dist/generator/themes/modern/partials/hero.html +15 -0
- package/dist/generator/themes/modern/partials/projects.html +17 -0
- package/dist/generator/themes/modern/partials/skills.html +11 -0
- package/dist/generator/themes/modern/partials/writing.html +14 -0
- package/dist/generator/themes/modern/script.js +136 -0
- package/dist/generator/themes/modern/styles.css +835 -0
- package/dist/generator/themes/modern/template.html +59 -0
- package/dist/generator/themes/modern/templates/experiments-index.html +78 -0
- package/dist/generator/themes/modern/templates/homepage.html +125 -0
- package/dist/generator/themes/modern/templates/project.html +98 -0
- package/dist/generator/themes/modern/templates/projects-index.html +79 -0
- package/dist/generator/themes/modern/templates/writing-index.html +73 -0
- package/dist/generator/themes/srcl/partials/education.html +27 -0
- package/dist/generator/themes/srcl/partials/experience.html +25 -0
- package/dist/generator/themes/srcl/partials/hero.html +22 -0
- package/dist/generator/themes/srcl/partials/projects.html +24 -0
- package/dist/generator/themes/srcl/partials/sections/code.html +8 -0
- package/dist/generator/themes/srcl/partials/sections/demo.html +8 -0
- package/dist/generator/themes/srcl/partials/sections/gallery.html +12 -0
- package/dist/generator/themes/srcl/partials/sections/image.html +6 -0
- package/dist/generator/themes/srcl/partials/sections/interactive.html +8 -0
- package/dist/generator/themes/srcl/partials/sections/metrics.html +10 -0
- package/dist/generator/themes/srcl/partials/sections/outcomes.html +5 -0
- package/dist/generator/themes/srcl/partials/sections/overview.html +5 -0
- package/dist/generator/themes/srcl/partials/sections/process.html +5 -0
- package/dist/generator/themes/srcl/partials/skills.html +21 -0
- package/dist/generator/themes/srcl/partials/writing.html +14 -0
- package/dist/generator/themes/srcl/script.js +354 -0
- package/dist/generator/themes/srcl/styles.css +1260 -0
- package/dist/generator/themes/srcl/template.html +46 -0
- package/dist/generator/themes/srcl/templates/experiments-index.html +66 -0
- package/dist/generator/themes/srcl/templates/homepage.html +136 -0
- package/dist/generator/themes/srcl/templates/project.html +96 -0
- package/dist/generator/themes/srcl/templates/projects-index.html +70 -0
- package/dist/generator/themes/srcl/templates/writing-index.html +61 -0
- package/dist/types/portfolio.js +4 -0
- package/package.json +58 -0
- package/src/generator/themes/dark-academia/partials/education.html +15 -0
- package/src/generator/themes/dark-academia/partials/experience.html +23 -0
- package/src/generator/themes/dark-academia/partials/hero.html +15 -0
- package/src/generator/themes/dark-academia/partials/projects.html +17 -0
- package/src/generator/themes/dark-academia/partials/skills.html +11 -0
- package/src/generator/themes/dark-academia/partials/writing.html +15 -0
- package/src/generator/themes/dark-academia/script.js +91 -0
- package/src/generator/themes/dark-academia/styles.css +913 -0
- package/src/generator/themes/dark-academia/template.html +46 -0
- package/src/generator/themes/dark-academia/templates/experiments-index.html +80 -0
- package/src/generator/themes/dark-academia/templates/homepage.html +125 -0
- package/src/generator/themes/dark-academia/templates/project.html +101 -0
- package/src/generator/themes/dark-academia/templates/projects-index.html +80 -0
- package/src/generator/themes/dark-academia/templates/writing-index.html +75 -0
- package/src/generator/themes/modern/partials/education.html +14 -0
- package/src/generator/themes/modern/partials/experience.html +21 -0
- package/src/generator/themes/modern/partials/hero.html +15 -0
- package/src/generator/themes/modern/partials/projects.html +17 -0
- package/src/generator/themes/modern/partials/skills.html +11 -0
- package/src/generator/themes/modern/partials/writing.html +14 -0
- package/src/generator/themes/modern/script.js +136 -0
- package/src/generator/themes/modern/styles.css +835 -0
- package/src/generator/themes/modern/template.html +59 -0
- package/src/generator/themes/modern/templates/experiments-index.html +78 -0
- package/src/generator/themes/modern/templates/homepage.html +125 -0
- package/src/generator/themes/modern/templates/project.html +98 -0
- package/src/generator/themes/modern/templates/projects-index.html +79 -0
- package/src/generator/themes/modern/templates/writing-index.html +73 -0
- package/src/generator/themes/srcl/partials/education.html +27 -0
- package/src/generator/themes/srcl/partials/experience.html +25 -0
- package/src/generator/themes/srcl/partials/hero.html +22 -0
- package/src/generator/themes/srcl/partials/projects.html +24 -0
- package/src/generator/themes/srcl/partials/sections/code.html +8 -0
- package/src/generator/themes/srcl/partials/sections/demo.html +8 -0
- package/src/generator/themes/srcl/partials/sections/gallery.html +12 -0
- package/src/generator/themes/srcl/partials/sections/image.html +6 -0
- package/src/generator/themes/srcl/partials/sections/interactive.html +8 -0
- package/src/generator/themes/srcl/partials/sections/metrics.html +10 -0
- package/src/generator/themes/srcl/partials/sections/outcomes.html +5 -0
- package/src/generator/themes/srcl/partials/sections/overview.html +5 -0
- package/src/generator/themes/srcl/partials/sections/process.html +5 -0
- package/src/generator/themes/srcl/partials/skills.html +21 -0
- package/src/generator/themes/srcl/partials/writing.html +14 -0
- package/src/generator/themes/srcl/script.js +354 -0
- package/src/generator/themes/srcl/styles.css +1260 -0
- package/src/generator/themes/srcl/template.html +46 -0
- package/src/generator/themes/srcl/templates/experiments-index.html +66 -0
- package/src/generator/themes/srcl/templates/homepage.html +136 -0
- package/src/generator/themes/srcl/templates/project.html +96 -0
- package/src/generator/themes/srcl/templates/projects-index.html +70 -0
- 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
|
+
'&': '&',
|
|
34
|
+
'<': '<',
|
|
35
|
+
'>': '>',
|
|
36
|
+
'"': '"',
|
|
37
|
+
"'": ''',
|
|
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}} – {{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}} – {{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"> — {{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}} · {{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 →</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>
|