devfolio-page 0.2.3 → 0.2.5

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.
@@ -86,7 +86,7 @@ export function validatePortfolio(yamlPath) {
86
86
  export function getPortfolioSummary(portfolio) {
87
87
  return {
88
88
  name: portfolio.meta.name,
89
- title: portfolio.meta.title,
89
+ title: portfolio.meta.title || '',
90
90
  experienceCount: portfolio.sections.experience?.length ?? 0,
91
91
  projectCount: portfolio.sections.projects?.length ?? 0,
92
92
  skillCategoryCount: portfolio.sections.skills
@@ -95,59 +95,59 @@ const contentSectionSchema = z.discriminatedUnion('type', [
95
95
  // Simple project schema (backwards compatible)
96
96
  const simpleProjectSchema = z.object({
97
97
  name: z.string().min(1, 'Project name is required'),
98
- url: urlString.optional(),
99
- description: z.string().optional(),
100
- tags: z.array(z.string()).optional(),
101
- featured: z.boolean().optional(),
98
+ url: urlString.optional().nullable(),
99
+ description: z.string().optional().nullable(),
100
+ tags: z.array(z.string()).optional().nullable(),
101
+ featured: z.boolean().optional().nullable(),
102
102
  });
103
103
  // Rich project schema (for case studies)
104
104
  const richProjectSchema = z.object({
105
105
  id: z.string().min(1, 'Project ID (URL slug) is required'),
106
106
  title: z.string().min(1, 'Project title is required'),
107
- subtitle: z.string().optional(),
108
- featured: z.boolean().optional(),
109
- thumbnail: urlOrPath.optional(),
110
- hero: urlOrPath.optional(),
107
+ subtitle: z.string().optional().nullable(),
108
+ featured: z.boolean().optional().nullable(),
109
+ thumbnail: urlOrPath.optional().nullable(),
110
+ hero: urlOrPath.optional().nullable(),
111
111
  meta: z.object({
112
- year: z.union([z.string(), z.number()]).optional(),
113
- role: z.string().optional(),
114
- timeline: z.string().optional(),
115
- tech: z.array(z.string()).optional(),
112
+ year: z.union([z.string(), z.number()]).optional().nullable(),
113
+ role: z.string().optional().nullable(),
114
+ timeline: z.string().optional().nullable(),
115
+ tech: z.array(z.string()).optional().nullable(),
116
116
  links: z
117
117
  .object({
118
- github: urlString.optional(),
119
- demo: urlString.optional(),
120
- live: urlString.optional(),
121
- case_study: urlString.optional(),
118
+ github: urlString.optional().nullable(),
119
+ demo: urlString.optional().nullable(),
120
+ live: urlString.optional().nullable(),
121
+ case_study: urlString.optional().nullable(),
122
122
  })
123
- .optional(),
124
- }).optional(),
125
- sections: z.array(contentSectionSchema).optional(),
123
+ .optional().nullable(),
124
+ }).optional().nullable(),
125
+ sections: z.array(contentSectionSchema).optional().nullable(),
126
126
  });
127
127
  // =============================================================================
128
128
  // Experience & Education Schemas
129
129
  // =============================================================================
130
130
  const experienceSchema = z.object({
131
131
  company: z.string().min(1, 'Company name is required'),
132
- role: z.string().optional(),
132
+ role: z.string().optional().nullable(),
133
133
  date: z.object({
134
- start: dateFormat.optional(),
135
- end: dateOrPresent.optional(),
136
- }).optional(),
137
- location: z.string().optional(),
138
- description: z.string().optional(),
139
- highlights: z.array(z.string()).optional(),
134
+ start: dateFormat.optional().nullable(),
135
+ end: dateOrPresent.optional().nullable(),
136
+ }).optional().nullable(),
137
+ location: z.string().optional().nullable(),
138
+ description: z.string().optional().nullable(),
139
+ highlights: z.array(z.string()).optional().nullable(),
140
140
  });
141
141
  const educationSchema = z.object({
142
142
  institution: z.string().min(1, 'Institution name is required'),
143
- degree: z.string().optional(),
143
+ degree: z.string().optional().nullable(),
144
144
  date: z.object({
145
- start: dateFormat.optional(),
146
- end: dateFormat.optional(),
147
- }).optional(),
148
- location: z.string().optional(),
149
- description: z.string().optional(),
150
- highlights: z.array(z.string()).optional(),
145
+ start: dateFormat.optional().nullable(),
146
+ end: dateFormat.optional().nullable(),
147
+ }).optional().nullable(),
148
+ location: z.string().optional().nullable(),
149
+ description: z.string().optional().nullable(),
150
+ highlights: z.array(z.string()).optional().nullable(),
151
151
  });
152
152
  // =============================================================================
153
153
  // Skills Schemas
@@ -172,38 +172,38 @@ const richSkillsSchema = z.object({
172
172
  // =============================================================================
173
173
  const writingSchema = z.object({
174
174
  title: z.string().min(1, 'Article title is required'),
175
- url: urlString.optional(),
176
- date: dateFormat.optional(),
177
- description: z.string().optional(),
178
- excerpt: z.string().optional(),
179
- cover: urlOrPath.optional(),
180
- publication: z.string().optional(),
181
- tags: z.array(z.string()).optional(),
182
- featured: z.boolean().optional(),
175
+ url: urlString.optional().nullable(),
176
+ date: dateFormat.optional().nullable(),
177
+ description: z.string().optional().nullable(),
178
+ excerpt: z.string().optional().nullable(),
179
+ cover: urlOrPath.optional().nullable(),
180
+ publication: z.string().optional().nullable(),
181
+ tags: z.array(z.string()).optional().nullable(),
182
+ featured: z.boolean().optional().nullable(),
183
183
  });
184
184
  // =============================================================================
185
185
  // New Content Types
186
186
  // =============================================================================
187
187
  const experimentSchema = z.object({
188
188
  title: z.string().min(1, 'Experiment title is required'),
189
- description: z.string().optional(),
190
- image: urlOrPath.optional(),
191
- github: urlString.optional(),
192
- demo: urlString.optional(),
193
- tags: z.array(z.string()).optional(),
189
+ description: z.string().optional().nullable(),
190
+ image: urlOrPath.optional().nullable(),
191
+ github: urlString.optional().nullable(),
192
+ demo: urlString.optional().nullable(),
193
+ tags: z.array(z.string()).optional().nullable(),
194
194
  });
195
195
  const testimonialSchema = z.object({
196
196
  quote: z.string().min(1, 'Quote is required'),
197
- author: z.string().optional(),
198
- company: z.string().optional(),
199
- role: z.string().optional(),
200
- image: urlOrPath.optional(),
197
+ author: z.string().optional().nullable(),
198
+ company: z.string().optional().nullable(),
199
+ role: z.string().optional().nullable(),
200
+ image: urlOrPath.optional().nullable(),
201
201
  });
202
202
  const timelineItemSchema = z.object({
203
- year: z.union([z.string(), z.number()]).optional(),
203
+ year: z.union([z.string(), z.number()]).optional().nullable(),
204
204
  title: z.string().min(1, 'Timeline item title is required'),
205
- description: z.string().optional(),
206
- image: urlOrPath.optional(),
205
+ description: z.string().optional().nullable(),
206
+ image: urlOrPath.optional().nullable(),
207
207
  });
208
208
  // =============================================================================
209
209
  // Settings & Layout Schemas
@@ -227,50 +227,50 @@ export const portfolioSchema = z.object({
227
227
  // Core metadata
228
228
  meta: z.object({
229
229
  name: z.string().min(1, 'Name is required'),
230
- title: z.string().optional(),
231
- tagline: z.string().optional(),
232
- location: z.string().optional(),
233
- timezone: z.string().optional(),
234
- avatar: urlOrPath.optional(),
235
- hero_image: urlOrPath.optional(),
230
+ title: z.string().optional().nullable(),
231
+ tagline: z.string().optional().nullable(),
232
+ location: z.string().optional().nullable(),
233
+ timezone: z.string().optional().nullable(),
234
+ avatar: urlOrPath.optional().nullable(),
235
+ hero_image: urlOrPath.optional().nullable(),
236
236
  }),
237
237
  // Contact information
238
238
  contact: z.object({
239
- email: z.string().email('Must be a valid email address').optional(),
240
- website: urlString.optional(),
241
- github: z.string().optional(),
242
- linkedin: z.string().optional(),
243
- twitter: z.string().optional(),
244
- }).optional(),
239
+ email: z.string().email('Must be a valid email address').optional().nullable(),
240
+ website: urlString.optional().nullable(),
241
+ github: z.string().optional().nullable(),
242
+ linkedin: z.string().optional().nullable(),
243
+ twitter: z.string().optional().nullable(),
244
+ }).optional().nullable(),
245
245
  // Bio
246
- bio: z.string().optional(),
246
+ bio: z.string().optional().nullable(),
247
247
  // Extended about section (optional)
248
248
  about: z
249
249
  .object({
250
- short: z.string().optional(),
251
- long: z.string().optional(),
250
+ short: z.string().optional().nullable(),
251
+ long: z.string().optional().nullable(),
252
252
  })
253
- .optional(),
253
+ .optional().nullable(),
254
254
  // Sections (backwards compatible structure)
255
255
  sections: z.object({
256
- experience: z.array(experienceSchema).optional(),
257
- projects: z.array(simpleProjectSchema).optional(),
258
- skills: simpleSkillsSchema.optional(),
259
- writing: z.array(writingSchema).optional(),
260
- education: z.array(educationSchema).optional(),
261
- }).optional(),
256
+ experience: z.array(experienceSchema).optional().nullable(),
257
+ projects: z.array(simpleProjectSchema).optional().nullable(),
258
+ skills: simpleSkillsSchema.optional().nullable(),
259
+ writing: z.array(writingSchema).optional().nullable(),
260
+ education: z.array(educationSchema).optional().nullable(),
261
+ }).optional().nullable(),
262
262
  // Rich projects (new structure for case studies)
263
- projects: z.array(richProjectSchema).optional(),
263
+ projects: z.array(richProjectSchema).optional().nullable(),
264
264
  // New content types
265
- experiments: z.array(experimentSchema).optional(),
266
- testimonials: z.array(testimonialSchema).optional(),
267
- timeline: z.array(timelineItemSchema).optional(),
265
+ experiments: z.array(experimentSchema).optional().nullable(),
266
+ testimonials: z.array(testimonialSchema).optional().nullable(),
267
+ timeline: z.array(timelineItemSchema).optional().nullable(),
268
268
  // Theme selection
269
- theme: z.enum(['srcl', 'modern', 'minimal', 'dark-academia']).optional(),
269
+ theme: z.enum(['srcl', 'modern', 'minimal', 'dark-academia']).optional().nullable(),
270
270
  // Layout configuration
271
- layout: layoutSchema.optional(),
271
+ layout: layoutSchema.optional().nullable(),
272
272
  // Settings
273
- settings: settingsSchema.optional(),
273
+ settings: settingsSchema.optional().nullable(),
274
274
  });
275
275
  // =============================================================================
276
276
  // Helper Functions
@@ -119,6 +119,9 @@ async function generateMultiPageSite(portfolio, themePath, outputDir, theme) {
119
119
  // Generate homepage
120
120
  const homepageFiles = await generateHomepage(portfolio, themePath, outputDir, theme);
121
121
  files.push(...homepageFiles);
122
+ // Generate about page
123
+ const aboutFiles = await generateAboutPage(portfolio, themePath, outputDir, theme);
124
+ files.push(...aboutFiles);
122
125
  // Generate project pages
123
126
  const projectFiles = await generateProjectPages(portfolio, themePath, outputDir, theme);
124
127
  files.push(...projectFiles);
@@ -150,14 +153,14 @@ async function generateHomepage(portfolio, themePath, outputDir, theme) {
150
153
  location: portfolio.meta.location,
151
154
  timezone: portfolio.meta.timezone,
152
155
  // Contact
153
- email: portfolio.contact.email,
154
- website: portfolio.contact.website,
155
- github: portfolio.contact.github,
156
- linkedin: portfolio.contact.linkedin,
157
- twitter: portfolio.contact.twitter,
156
+ email: portfolio.contact?.email,
157
+ website: portfolio.contact?.website,
158
+ github: portfolio.contact?.github,
159
+ linkedin: portfolio.contact?.linkedin,
160
+ twitter: portfolio.contact?.twitter,
158
161
  // Bio
159
- bio: portfolio.bio,
160
- bio_html: parseBio(portfolio.bio),
162
+ bio: portfolio.bio || '',
163
+ bio_html: parseBio(portfolio.bio || ''),
161
164
  about_short: portfolio.about?.short || '',
162
165
  // Featured content
163
166
  featured_projects: portfolio.projects?.filter((p) => p.featured) || [],
@@ -235,6 +238,44 @@ async function generateProjectsIndex(portfolio, themePath, outputDir, theme) {
235
238
  await fs.writeFile(path.join(outputDir, 'projects/index.html'), html);
236
239
  return ['projects/index.html'];
237
240
  }
241
+ async function generateAboutPage(portfolio, themePath, outputDir, theme) {
242
+ // Only generate if about section exists
243
+ if (!portfolio.about?.long && !portfolio.about?.short) {
244
+ return [];
245
+ }
246
+ const templatePath = path.join(themePath, 'templates/about.html');
247
+ if (!(await fileExists(templatePath))) {
248
+ return [];
249
+ }
250
+ await fs.mkdir(path.join(outputDir, 'about'), { recursive: true });
251
+ const template = await fs.readFile(templatePath, 'utf-8');
252
+ const settings = portfolio.settings || {};
253
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
254
+ // Convert skills object to array format for template
255
+ const skillsArray = portfolio.sections?.skills
256
+ ? Object.entries(portfolio.sections.skills).map(([category, items]) => ({
257
+ category,
258
+ items,
259
+ }))
260
+ : [];
261
+ const data = {
262
+ site_name: portfolio.meta.name,
263
+ tagline: portfolio.meta.tagline,
264
+ avatar: portfolio.meta.avatar,
265
+ about_content: parseMarkdown(portfolio.about?.long || portfolio.about?.short || ''),
266
+ contact: portfolio.contact,
267
+ hasContact: !!(portfolio.contact?.email || portfolio.contact?.github || portfolio.contact?.linkedin || portfolio.contact?.twitter || portfolio.contact?.website),
268
+ skills: skillsArray,
269
+ hasSkills: skillsArray.length > 0,
270
+ education: portfolio.sections?.education || [],
271
+ hasEducation: (portfolio.sections?.education?.length ?? 0) > 0,
272
+ colorScheme: settings.color_scheme || defaultColorScheme,
273
+ nav_links: generateNavLinks(portfolio, true, 'about'),
274
+ };
275
+ const html = Mustache.render(template, data);
276
+ await fs.writeFile(path.join(outputDir, 'about/index.html'), html);
277
+ return ['about/index.html'];
278
+ }
238
279
  async function generateExperimentsIndex(portfolio, themePath, outputDir, theme) {
239
280
  // Only generate if experiments exist
240
281
  if (!portfolio.experiments || portfolio.experiments.length === 0) {
@@ -360,7 +401,20 @@ function renderContentSection(section, partials) {
360
401
  function generateNavLinks(portfolio, fromSubdir = false, currentPage = 'home') {
361
402
  const links = [];
362
403
  const prefix = fromSubdir ? '../' : './';
363
- // Note: "Home" is not included since the site name already links home
404
+ // Always include Home link
405
+ links.push({
406
+ href: fromSubdir ? '../' : './',
407
+ label: 'Home',
408
+ active: currentPage === 'home',
409
+ });
410
+ // Include About link if about section exists
411
+ if (portfolio.about?.long || portfolio.about?.short) {
412
+ links.push({
413
+ href: `${prefix}about/`,
414
+ label: 'About',
415
+ active: currentPage === 'about',
416
+ });
417
+ }
364
418
  if ((portfolio.projects?.length ?? 0) > 0) {
365
419
  links.push({
366
420
  href: `${prefix}projects/`,
@@ -375,7 +429,7 @@ function generateNavLinks(portfolio, fromSubdir = false, currentPage = 'home') {
375
429
  active: currentPage === 'experiments',
376
430
  });
377
431
  }
378
- if ((portfolio.sections.writing?.length ?? 0) > 0) {
432
+ if ((portfolio.sections?.writing?.length ?? 0) > 0) {
379
433
  links.push({
380
434
  href: `${prefix}writing/`,
381
435
  label: 'Writing',
@@ -419,14 +473,14 @@ function prepareTemplateData(portfolio) {
419
473
  location: portfolio.meta.location,
420
474
  timezone: portfolio.meta.timezone,
421
475
  // Contact
422
- email: portfolio.contact.email,
423
- website: portfolio.contact.website,
424
- github: portfolio.contact.github,
425
- linkedin: portfolio.contact.linkedin,
426
- twitter: portfolio.contact.twitter,
476
+ email: portfolio.contact?.email,
477
+ website: portfolio.contact?.website,
478
+ github: portfolio.contact?.github,
479
+ linkedin: portfolio.contact?.linkedin,
480
+ twitter: portfolio.contact?.twitter,
427
481
  // Bio
428
- bio: portfolio.bio,
429
- bio_html: parseBio(portfolio.bio),
482
+ bio: portfolio.bio || '',
483
+ bio_html: parseBio(portfolio.bio || ''),
430
484
  // Sections
431
485
  experiences,
432
486
  projects: portfolio.sections.projects || [],
@@ -665,6 +719,9 @@ async function generateMultiPageSiteInMemory(portfolio, themePath, theme, files)
665
719
  // Generate homepage
666
720
  const homepageFiles = await generateHomepageInMemory(portfolio, themePath, theme, files);
667
721
  fileList.push(...homepageFiles);
722
+ // Generate about page
723
+ const aboutFiles = await generateAboutPageInMemory(portfolio, themePath, theme, files);
724
+ fileList.push(...aboutFiles);
668
725
  // Generate project pages
669
726
  const projectFiles = await generateProjectPagesInMemory(portfolio, themePath, theme, files);
670
727
  fileList.push(...projectFiles);
@@ -694,13 +751,13 @@ async function generateHomepageInMemory(portfolio, themePath, theme, files) {
694
751
  title: portfolio.meta.title,
695
752
  location: portfolio.meta.location,
696
753
  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),
754
+ email: portfolio.contact?.email,
755
+ website: portfolio.contact?.website,
756
+ github: portfolio.contact?.github,
757
+ linkedin: portfolio.contact?.linkedin,
758
+ twitter: portfolio.contact?.twitter,
759
+ bio: portfolio.bio || '',
760
+ bio_html: parseBio(portfolio.bio || ''),
704
761
  about_short: portfolio.about?.short || '',
705
762
  featured_projects: portfolio.projects?.filter((p) => p.featured) || [],
706
763
  featured_writing: portfolio.sections.writing?.filter((w) => 'featured' in w && w.featured) || [],
@@ -769,6 +826,42 @@ async function generateProjectsIndexInMemory(portfolio, themePath, theme, files)
769
826
  files.set('projects/index.html', Buffer.from(html, 'utf-8'));
770
827
  return ['projects/index.html'];
771
828
  }
829
+ async function generateAboutPageInMemory(portfolio, themePath, theme, files) {
830
+ if (!portfolio.about?.long && !portfolio.about?.short) {
831
+ return [];
832
+ }
833
+ const templatePath = path.join(themePath, 'templates/about.html');
834
+ if (!(await fileExists(templatePath))) {
835
+ return [];
836
+ }
837
+ const template = await fs.readFile(templatePath, 'utf-8');
838
+ const settings = portfolio.settings || {};
839
+ const defaultColorScheme = theme === 'dark-academia' ? 'light' : 'dark';
840
+ // Convert skills object to array format for template
841
+ const skillsArray = portfolio.sections?.skills
842
+ ? Object.entries(portfolio.sections.skills).map(([category, items]) => ({
843
+ category,
844
+ items,
845
+ }))
846
+ : [];
847
+ const data = {
848
+ site_name: portfolio.meta.name,
849
+ tagline: portfolio.meta.tagline,
850
+ avatar: portfolio.meta.avatar,
851
+ about_content: parseMarkdown(portfolio.about?.long || portfolio.about?.short || ''),
852
+ contact: portfolio.contact,
853
+ hasContact: !!(portfolio.contact?.email || portfolio.contact?.github || portfolio.contact?.linkedin || portfolio.contact?.twitter || portfolio.contact?.website),
854
+ skills: skillsArray,
855
+ hasSkills: skillsArray.length > 0,
856
+ education: portfolio.sections?.education || [],
857
+ hasEducation: (portfolio.sections?.education?.length ?? 0) > 0,
858
+ colorScheme: settings.color_scheme || defaultColorScheme,
859
+ nav_links: generateNavLinks(portfolio, true, 'about'),
860
+ };
861
+ const html = Mustache.render(template, data);
862
+ files.set('about/index.html', Buffer.from(html, 'utf-8'));
863
+ return ['about/index.html'];
864
+ }
772
865
  async function generateExperimentsIndexInMemory(portfolio, themePath, theme, files) {
773
866
  if (!portfolio.experiments || portfolio.experiments.length === 0) {
774
867
  return [];
@@ -0,0 +1,115 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>About - {{site_name}}</title>
7
+ <meta name="description" content="About {{site_name}}">
8
+ <link rel="stylesheet" href="../assets/styles.css">
9
+ <script>
10
+ (function() {
11
+ try {
12
+ var saved = localStorage.getItem('academia-theme');
13
+ if (saved) document.documentElement.dataset.theme = saved;
14
+ } catch(e) {}
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body class="academia-theme" data-theme="{{colorScheme}}">
19
+
20
+ <nav class="site-nav">
21
+ <div class="nav-content">
22
+ <a href="../" class="nav-logo">{{site_name}}</a>
23
+ <div class="nav-links">
24
+ {{#nav_links}}
25
+ <a href="{{href}}"{{#active}} class="active"{{/active}}>{{label}}</a>
26
+ {{/nav_links}}
27
+ </div>
28
+ <button class="theme-btn" title="Toggle theme" aria-label="Toggle theme">
29
+ <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
30
+ <circle cx="12" cy="12" r="5"></circle>
31
+ <line x1="12" y1="1" x2="12" y2="3"></line>
32
+ <line x1="12" y1="21" x2="12" y2="23"></line>
33
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
34
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
35
+ <line x1="1" y1="12" x2="3" y2="12"></line>
36
+ <line x1="21" y1="12" x2="23" y2="12"></line>
37
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
38
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
39
+ </svg>
40
+ <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
41
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
42
+ </svg>
43
+ </button>
44
+ </div>
45
+ </nav>
46
+
47
+ <main class="page-content">
48
+ <header class="page-header">
49
+ <div class="header-ornament"></div>
50
+ <h1>About</h1>
51
+ {{#tagline}}<p>{{tagline}}</p>{{/tagline}}
52
+ </header>
53
+
54
+ <div class="about-content">
55
+ {{#avatar}}
56
+ <div class="about-avatar">
57
+ <img src="{{avatar}}" alt="{{site_name}}" />
58
+ </div>
59
+ {{/avatar}}
60
+
61
+ <div class="about-bio">
62
+ {{{about_content}}}
63
+ </div>
64
+
65
+ {{#hasSkills}}
66
+ <section class="about-skills">
67
+ <h2>Skills & Expertise</h2>
68
+ {{#skills}}
69
+ <div class="skill-category">
70
+ <h3>{{category}}</h3>
71
+ <div class="project-tags">
72
+ {{#items}}<span class="tag">{{.}}</span>{{/items}}
73
+ </div>
74
+ </div>
75
+ {{/skills}}
76
+ </section>
77
+ {{/hasSkills}}
78
+
79
+ {{#hasEducation}}
80
+ <section class="about-education">
81
+ <h2>Education</h2>
82
+ {{#education}}
83
+ <div class="education-item">
84
+ <h3>{{institution}}</h3>
85
+ {{#degree}}<p class="degree">{{degree}}</p>{{/degree}}
86
+ {{#date}}<p class="date">{{date.start}} - {{date.end}}</p>{{/date}}
87
+ {{#description}}<p class="description">{{description}}</p>{{/description}}
88
+ </div>
89
+ {{/education}}
90
+ </section>
91
+ {{/hasEducation}}
92
+
93
+ {{#hasContact}}
94
+ <section class="about-contact">
95
+ <h2>Correspondence</h2>
96
+ <div class="contact-links">
97
+ {{#contact.email}}<a href="mailto:{{contact.email}}">{{contact.email}}</a>{{/contact.email}}
98
+ {{#contact.github}}<a href="https://github.com/{{contact.github}}" target="_blank">Archives</a>{{/contact.github}}
99
+ {{#contact.linkedin}}<a href="https://linkedin.com/in/{{contact.linkedin}}" target="_blank">LinkedIn</a>{{/contact.linkedin}}
100
+ {{#contact.twitter}}<a href="https://twitter.com/{{contact.twitter}}" target="_blank">Twitter</a>{{/contact.twitter}}
101
+ {{#contact.website}}<a href="{{contact.website}}" target="_blank">Website</a>{{/contact.website}}
102
+ </div>
103
+ </section>
104
+ {{/hasContact}}
105
+ </div>
106
+ </main>
107
+
108
+ <footer class="site-footer">
109
+ <div class="footer-ornament"></div>
110
+ <p>Crafted with <a href="https://devfolio.page" target="_blank">devfolio.page</a></p>
111
+ </footer>
112
+
113
+ <script src="../assets/script.js"></script>
114
+ </body>
115
+ </html>
@@ -0,0 +1,113 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>About - {{site_name}}</title>
7
+ <meta name="description" content="About {{site_name}}">
8
+ <link rel="stylesheet" href="../assets/styles.css">
9
+ <script>
10
+ (function() {
11
+ try {
12
+ var saved = localStorage.getItem('modern-theme');
13
+ if (saved) document.documentElement.dataset.theme = saved;
14
+ } catch(e) {}
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body class="modern-theme" data-theme="{{colorScheme}}">
19
+
20
+ <header class="site-header">
21
+ <div class="header-content">
22
+ <a href="../" class="logo">{{site_name}}</a>
23
+ <nav class="nav">
24
+ {{#nav_links}}
25
+ <a href="{{href}}"{{#active}} class="active"{{/active}}>{{label}}</a>
26
+ {{/nav_links}}
27
+ </nav>
28
+ <button class="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
29
+ <svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
30
+ <circle cx="12" cy="12" r="5"></circle>
31
+ <line x1="12" y1="1" x2="12" y2="3"></line>
32
+ <line x1="12" y1="21" x2="12" y2="23"></line>
33
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
34
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
35
+ <line x1="1" y1="12" x2="3" y2="12"></line>
36
+ <line x1="21" y1="12" x2="23" y2="12"></line>
37
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
38
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
39
+ </svg>
40
+ <svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
41
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
42
+ </svg>
43
+ </button>
44
+ </div>
45
+ </header>
46
+
47
+ <main class="main-content">
48
+ <header class="page-header">
49
+ <h1>About</h1>
50
+ {{#tagline}}<p>{{tagline}}</p>{{/tagline}}
51
+ </header>
52
+
53
+ <div class="about-content">
54
+ {{#avatar}}
55
+ <div class="about-avatar">
56
+ <img src="{{avatar}}" alt="{{site_name}}" />
57
+ </div>
58
+ {{/avatar}}
59
+
60
+ <div class="about-bio">
61
+ {{{about_content}}}
62
+ </div>
63
+
64
+ {{#hasSkills}}
65
+ <section class="about-skills">
66
+ <h2>Skills</h2>
67
+ {{#skills}}
68
+ <div class="skill-category">
69
+ <h3>{{category}}</h3>
70
+ <div class="project-tags">
71
+ {{#items}}<span class="project-tag">{{.}}</span>{{/items}}
72
+ </div>
73
+ </div>
74
+ {{/skills}}
75
+ </section>
76
+ {{/hasSkills}}
77
+
78
+ {{#hasEducation}}
79
+ <section class="about-education">
80
+ <h2>Education</h2>
81
+ {{#education}}
82
+ <div class="education-item">
83
+ <h3>{{institution}}</h3>
84
+ {{#degree}}<p class="degree">{{degree}}</p>{{/degree}}
85
+ {{#date}}<p class="date">{{date.start}} - {{date.end}}</p>{{/date}}
86
+ {{#description}}<p class="description">{{description}}</p>{{/description}}
87
+ </div>
88
+ {{/education}}
89
+ </section>
90
+ {{/hasEducation}}
91
+
92
+ {{#hasContact}}
93
+ <section class="about-contact">
94
+ <h2>Contact</h2>
95
+ <div class="contact-links">
96
+ {{#contact.email}}<a href="mailto:{{contact.email}}">{{contact.email}}</a>{{/contact.email}}
97
+ {{#contact.github}}<a href="https://github.com/{{contact.github}}" target="_blank">GitHub</a>{{/contact.github}}
98
+ {{#contact.linkedin}}<a href="https://linkedin.com/in/{{contact.linkedin}}" target="_blank">LinkedIn</a>{{/contact.linkedin}}
99
+ {{#contact.twitter}}<a href="https://twitter.com/{{contact.twitter}}" target="_blank">Twitter</a>{{/contact.twitter}}
100
+ {{#contact.website}}<a href="{{contact.website}}" target="_blank">Website</a>{{/contact.website}}
101
+ </div>
102
+ </section>
103
+ {{/hasContact}}
104
+ </div>
105
+ </main>
106
+
107
+ <footer class="site-footer">
108
+ <p>Built with <a href="https://devfolio.page" target="_blank">devfolio.page</a></p>
109
+ </footer>
110
+
111
+ <script src="../assets/script.js"></script>
112
+ </body>
113
+ </html>
@@ -0,0 +1,102 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>About - {{site_name}}</title>
7
+ <meta name="description" content="About {{site_name}}">
8
+ <link rel="stylesheet" href="../assets/styles.css">
9
+ <script>
10
+ // Load theme preference before render to prevent flash
11
+ (function() {
12
+ try {
13
+ var saved = localStorage.getItem('srcl-theme');
14
+ if (saved) document.documentElement.dataset.theme = saved;
15
+ } catch(e) {}
16
+ })();
17
+ </script>
18
+ </head>
19
+ <body class="srcl-theme" data-theme="{{colorScheme}}">
20
+
21
+ <!-- Action Bar -->
22
+ <div class="srcl-action-bar">
23
+ <nav class="primary">
24
+ <a href="../" class="nav-home">{{site_name}}</a>
25
+ {{#nav_links}}
26
+ <a href="{{href}}"{{#active}} class="active"{{/active}}>{{label}}</a>
27
+ {{/nav_links}}
28
+ </nav>
29
+ <div class="secondary">
30
+ <button class="hotkey" data-hotkey="ctrl+t" title="Toggle theme (Ctrl+T)">
31
+ <span class="key">^T</span>
32
+ </button>
33
+ </div>
34
+ </div>
35
+
36
+ <main class="content">
37
+ <header class="page-header">
38
+ <h1>About</h1>
39
+ {{#tagline}}<p class="page-description">{{tagline}}</p>{{/tagline}}
40
+ </header>
41
+
42
+ <div class="about-content">
43
+ {{#avatar}}
44
+ <div class="about-avatar">
45
+ <img src="{{avatar}}" alt="{{site_name}}" />
46
+ </div>
47
+ {{/avatar}}
48
+
49
+ <div class="about-bio">
50
+ {{{about_content}}}
51
+ </div>
52
+
53
+ {{#hasSkills}}
54
+ <section class="about-skills">
55
+ <h2>Skills</h2>
56
+ {{#skills}}
57
+ <div class="skill-category">
58
+ <h3>{{category}}</h3>
59
+ <div class="skill-tags">
60
+ {{#items}}<span class="tag">{{.}}</span>{{/items}}
61
+ </div>
62
+ </div>
63
+ {{/skills}}
64
+ </section>
65
+ {{/hasSkills}}
66
+
67
+ {{#hasEducation}}
68
+ <section class="about-education">
69
+ <h2>Education</h2>
70
+ {{#education}}
71
+ <div class="education-item">
72
+ <h3>{{institution}}</h3>
73
+ {{#degree}}<p class="degree">{{degree}}</p>{{/degree}}
74
+ {{#date}}<p class="date">{{date.start}} - {{date.end}}</p>{{/date}}
75
+ {{#description}}<p class="description">{{description}}</p>{{/description}}
76
+ </div>
77
+ {{/education}}
78
+ </section>
79
+ {{/hasEducation}}
80
+
81
+ {{#hasContact}}
82
+ <section class="about-contact">
83
+ <h2>Contact</h2>
84
+ <div class="contact-links">
85
+ {{#contact.email}}<a href="mailto:{{contact.email}}">{{contact.email}}</a>{{/contact.email}}
86
+ {{#contact.github}}<a href="https://github.com/{{contact.github}}" target="_blank">GitHub</a>{{/contact.github}}
87
+ {{#contact.linkedin}}<a href="https://linkedin.com/in/{{contact.linkedin}}" target="_blank">LinkedIn</a>{{/contact.linkedin}}
88
+ {{#contact.twitter}}<a href="https://twitter.com/{{contact.twitter}}" target="_blank">Twitter</a>{{/contact.twitter}}
89
+ {{#contact.website}}<a href="{{contact.website}}" target="_blank">Website</a>{{/contact.website}}
90
+ </div>
91
+ </section>
92
+ {{/hasContact}}
93
+ </div>
94
+ </main>
95
+
96
+ <footer class="srcl-footer">
97
+ <p>Built with <a href="https://devfolio.page" target="_blank">devfolio.page</a> using <a href="https://sacred.computer" target="_blank">SRCL</a></p>
98
+ </footer>
99
+
100
+ <script src="../assets/script.js"></script>
101
+ </body>
102
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devfolio-page",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Your portfolio as code. Version control it like software.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,115 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>About - {{site_name}}</title>
7
+ <meta name="description" content="About {{site_name}}">
8
+ <link rel="stylesheet" href="../assets/styles.css">
9
+ <script>
10
+ (function() {
11
+ try {
12
+ var saved = localStorage.getItem('academia-theme');
13
+ if (saved) document.documentElement.dataset.theme = saved;
14
+ } catch(e) {}
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body class="academia-theme" data-theme="{{colorScheme}}">
19
+
20
+ <nav class="site-nav">
21
+ <div class="nav-content">
22
+ <a href="../" class="nav-logo">{{site_name}}</a>
23
+ <div class="nav-links">
24
+ {{#nav_links}}
25
+ <a href="{{href}}"{{#active}} class="active"{{/active}}>{{label}}</a>
26
+ {{/nav_links}}
27
+ </div>
28
+ <button class="theme-btn" title="Toggle theme" aria-label="Toggle theme">
29
+ <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
30
+ <circle cx="12" cy="12" r="5"></circle>
31
+ <line x1="12" y1="1" x2="12" y2="3"></line>
32
+ <line x1="12" y1="21" x2="12" y2="23"></line>
33
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
34
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
35
+ <line x1="1" y1="12" x2="3" y2="12"></line>
36
+ <line x1="21" y1="12" x2="23" y2="12"></line>
37
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
38
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
39
+ </svg>
40
+ <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
41
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
42
+ </svg>
43
+ </button>
44
+ </div>
45
+ </nav>
46
+
47
+ <main class="page-content">
48
+ <header class="page-header">
49
+ <div class="header-ornament"></div>
50
+ <h1>About</h1>
51
+ {{#tagline}}<p>{{tagline}}</p>{{/tagline}}
52
+ </header>
53
+
54
+ <div class="about-content">
55
+ {{#avatar}}
56
+ <div class="about-avatar">
57
+ <img src="{{avatar}}" alt="{{site_name}}" />
58
+ </div>
59
+ {{/avatar}}
60
+
61
+ <div class="about-bio">
62
+ {{{about_content}}}
63
+ </div>
64
+
65
+ {{#hasSkills}}
66
+ <section class="about-skills">
67
+ <h2>Skills & Expertise</h2>
68
+ {{#skills}}
69
+ <div class="skill-category">
70
+ <h3>{{category}}</h3>
71
+ <div class="project-tags">
72
+ {{#items}}<span class="tag">{{.}}</span>{{/items}}
73
+ </div>
74
+ </div>
75
+ {{/skills}}
76
+ </section>
77
+ {{/hasSkills}}
78
+
79
+ {{#hasEducation}}
80
+ <section class="about-education">
81
+ <h2>Education</h2>
82
+ {{#education}}
83
+ <div class="education-item">
84
+ <h3>{{institution}}</h3>
85
+ {{#degree}}<p class="degree">{{degree}}</p>{{/degree}}
86
+ {{#date}}<p class="date">{{date.start}} - {{date.end}}</p>{{/date}}
87
+ {{#description}}<p class="description">{{description}}</p>{{/description}}
88
+ </div>
89
+ {{/education}}
90
+ </section>
91
+ {{/hasEducation}}
92
+
93
+ {{#hasContact}}
94
+ <section class="about-contact">
95
+ <h2>Correspondence</h2>
96
+ <div class="contact-links">
97
+ {{#contact.email}}<a href="mailto:{{contact.email}}">{{contact.email}}</a>{{/contact.email}}
98
+ {{#contact.github}}<a href="https://github.com/{{contact.github}}" target="_blank">Archives</a>{{/contact.github}}
99
+ {{#contact.linkedin}}<a href="https://linkedin.com/in/{{contact.linkedin}}" target="_blank">LinkedIn</a>{{/contact.linkedin}}
100
+ {{#contact.twitter}}<a href="https://twitter.com/{{contact.twitter}}" target="_blank">Twitter</a>{{/contact.twitter}}
101
+ {{#contact.website}}<a href="{{contact.website}}" target="_blank">Website</a>{{/contact.website}}
102
+ </div>
103
+ </section>
104
+ {{/hasContact}}
105
+ </div>
106
+ </main>
107
+
108
+ <footer class="site-footer">
109
+ <div class="footer-ornament"></div>
110
+ <p>Crafted with <a href="https://devfolio.page" target="_blank">devfolio.page</a></p>
111
+ </footer>
112
+
113
+ <script src="../assets/script.js"></script>
114
+ </body>
115
+ </html>
@@ -0,0 +1,113 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>About - {{site_name}}</title>
7
+ <meta name="description" content="About {{site_name}}">
8
+ <link rel="stylesheet" href="../assets/styles.css">
9
+ <script>
10
+ (function() {
11
+ try {
12
+ var saved = localStorage.getItem('modern-theme');
13
+ if (saved) document.documentElement.dataset.theme = saved;
14
+ } catch(e) {}
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body class="modern-theme" data-theme="{{colorScheme}}">
19
+
20
+ <header class="site-header">
21
+ <div class="header-content">
22
+ <a href="../" class="logo">{{site_name}}</a>
23
+ <nav class="nav">
24
+ {{#nav_links}}
25
+ <a href="{{href}}"{{#active}} class="active"{{/active}}>{{label}}</a>
26
+ {{/nav_links}}
27
+ </nav>
28
+ <button class="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
29
+ <svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
30
+ <circle cx="12" cy="12" r="5"></circle>
31
+ <line x1="12" y1="1" x2="12" y2="3"></line>
32
+ <line x1="12" y1="21" x2="12" y2="23"></line>
33
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
34
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
35
+ <line x1="1" y1="12" x2="3" y2="12"></line>
36
+ <line x1="21" y1="12" x2="23" y2="12"></line>
37
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
38
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
39
+ </svg>
40
+ <svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
41
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
42
+ </svg>
43
+ </button>
44
+ </div>
45
+ </header>
46
+
47
+ <main class="main-content">
48
+ <header class="page-header">
49
+ <h1>About</h1>
50
+ {{#tagline}}<p>{{tagline}}</p>{{/tagline}}
51
+ </header>
52
+
53
+ <div class="about-content">
54
+ {{#avatar}}
55
+ <div class="about-avatar">
56
+ <img src="{{avatar}}" alt="{{site_name}}" />
57
+ </div>
58
+ {{/avatar}}
59
+
60
+ <div class="about-bio">
61
+ {{{about_content}}}
62
+ </div>
63
+
64
+ {{#hasSkills}}
65
+ <section class="about-skills">
66
+ <h2>Skills</h2>
67
+ {{#skills}}
68
+ <div class="skill-category">
69
+ <h3>{{category}}</h3>
70
+ <div class="project-tags">
71
+ {{#items}}<span class="project-tag">{{.}}</span>{{/items}}
72
+ </div>
73
+ </div>
74
+ {{/skills}}
75
+ </section>
76
+ {{/hasSkills}}
77
+
78
+ {{#hasEducation}}
79
+ <section class="about-education">
80
+ <h2>Education</h2>
81
+ {{#education}}
82
+ <div class="education-item">
83
+ <h3>{{institution}}</h3>
84
+ {{#degree}}<p class="degree">{{degree}}</p>{{/degree}}
85
+ {{#date}}<p class="date">{{date.start}} - {{date.end}}</p>{{/date}}
86
+ {{#description}}<p class="description">{{description}}</p>{{/description}}
87
+ </div>
88
+ {{/education}}
89
+ </section>
90
+ {{/hasEducation}}
91
+
92
+ {{#hasContact}}
93
+ <section class="about-contact">
94
+ <h2>Contact</h2>
95
+ <div class="contact-links">
96
+ {{#contact.email}}<a href="mailto:{{contact.email}}">{{contact.email}}</a>{{/contact.email}}
97
+ {{#contact.github}}<a href="https://github.com/{{contact.github}}" target="_blank">GitHub</a>{{/contact.github}}
98
+ {{#contact.linkedin}}<a href="https://linkedin.com/in/{{contact.linkedin}}" target="_blank">LinkedIn</a>{{/contact.linkedin}}
99
+ {{#contact.twitter}}<a href="https://twitter.com/{{contact.twitter}}" target="_blank">Twitter</a>{{/contact.twitter}}
100
+ {{#contact.website}}<a href="{{contact.website}}" target="_blank">Website</a>{{/contact.website}}
101
+ </div>
102
+ </section>
103
+ {{/hasContact}}
104
+ </div>
105
+ </main>
106
+
107
+ <footer class="site-footer">
108
+ <p>Built with <a href="https://devfolio.page" target="_blank">devfolio.page</a></p>
109
+ </footer>
110
+
111
+ <script src="../assets/script.js"></script>
112
+ </body>
113
+ </html>
@@ -0,0 +1,102 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>About - {{site_name}}</title>
7
+ <meta name="description" content="About {{site_name}}">
8
+ <link rel="stylesheet" href="../assets/styles.css">
9
+ <script>
10
+ // Load theme preference before render to prevent flash
11
+ (function() {
12
+ try {
13
+ var saved = localStorage.getItem('srcl-theme');
14
+ if (saved) document.documentElement.dataset.theme = saved;
15
+ } catch(e) {}
16
+ })();
17
+ </script>
18
+ </head>
19
+ <body class="srcl-theme" data-theme="{{colorScheme}}">
20
+
21
+ <!-- Action Bar -->
22
+ <div class="srcl-action-bar">
23
+ <nav class="primary">
24
+ <a href="../" class="nav-home">{{site_name}}</a>
25
+ {{#nav_links}}
26
+ <a href="{{href}}"{{#active}} class="active"{{/active}}>{{label}}</a>
27
+ {{/nav_links}}
28
+ </nav>
29
+ <div class="secondary">
30
+ <button class="hotkey" data-hotkey="ctrl+t" title="Toggle theme (Ctrl+T)">
31
+ <span class="key">^T</span>
32
+ </button>
33
+ </div>
34
+ </div>
35
+
36
+ <main class="content">
37
+ <header class="page-header">
38
+ <h1>About</h1>
39
+ {{#tagline}}<p class="page-description">{{tagline}}</p>{{/tagline}}
40
+ </header>
41
+
42
+ <div class="about-content">
43
+ {{#avatar}}
44
+ <div class="about-avatar">
45
+ <img src="{{avatar}}" alt="{{site_name}}" />
46
+ </div>
47
+ {{/avatar}}
48
+
49
+ <div class="about-bio">
50
+ {{{about_content}}}
51
+ </div>
52
+
53
+ {{#hasSkills}}
54
+ <section class="about-skills">
55
+ <h2>Skills</h2>
56
+ {{#skills}}
57
+ <div class="skill-category">
58
+ <h3>{{category}}</h3>
59
+ <div class="skill-tags">
60
+ {{#items}}<span class="tag">{{.}}</span>{{/items}}
61
+ </div>
62
+ </div>
63
+ {{/skills}}
64
+ </section>
65
+ {{/hasSkills}}
66
+
67
+ {{#hasEducation}}
68
+ <section class="about-education">
69
+ <h2>Education</h2>
70
+ {{#education}}
71
+ <div class="education-item">
72
+ <h3>{{institution}}</h3>
73
+ {{#degree}}<p class="degree">{{degree}}</p>{{/degree}}
74
+ {{#date}}<p class="date">{{date.start}} - {{date.end}}</p>{{/date}}
75
+ {{#description}}<p class="description">{{description}}</p>{{/description}}
76
+ </div>
77
+ {{/education}}
78
+ </section>
79
+ {{/hasEducation}}
80
+
81
+ {{#hasContact}}
82
+ <section class="about-contact">
83
+ <h2>Contact</h2>
84
+ <div class="contact-links">
85
+ {{#contact.email}}<a href="mailto:{{contact.email}}">{{contact.email}}</a>{{/contact.email}}
86
+ {{#contact.github}}<a href="https://github.com/{{contact.github}}" target="_blank">GitHub</a>{{/contact.github}}
87
+ {{#contact.linkedin}}<a href="https://linkedin.com/in/{{contact.linkedin}}" target="_blank">LinkedIn</a>{{/contact.linkedin}}
88
+ {{#contact.twitter}}<a href="https://twitter.com/{{contact.twitter}}" target="_blank">Twitter</a>{{/contact.twitter}}
89
+ {{#contact.website}}<a href="{{contact.website}}" target="_blank">Website</a>{{/contact.website}}
90
+ </div>
91
+ </section>
92
+ {{/hasContact}}
93
+ </div>
94
+ </main>
95
+
96
+ <footer class="srcl-footer">
97
+ <p>Built with <a href="https://devfolio.page" target="_blank">devfolio.page</a> using <a href="https://sacred.computer" target="_blank">SRCL</a></p>
98
+ </footer>
99
+
100
+ <script src="../assets/script.js"></script>
101
+ </body>
102
+ </html>