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,86 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { validatePortfolio, getPortfolioSummary, ValidationError, FileError, ParseError, } from '../helpers/validate.js';
|
|
3
|
+
export async function validateCommand(file) {
|
|
4
|
+
console.log(chalk.dim(`Validating ${file}...\n`));
|
|
5
|
+
try {
|
|
6
|
+
const portfolio = validatePortfolio(file);
|
|
7
|
+
const summary = getPortfolioSummary(portfolio);
|
|
8
|
+
console.log(chalk.green('✓') + ` ${chalk.bold(file)} is valid\n`);
|
|
9
|
+
// Show summary
|
|
10
|
+
console.log(` ${chalk.dim('Meta:')} ${summary.name}, ${summary.title}`);
|
|
11
|
+
// Build sections summary
|
|
12
|
+
const sections = [];
|
|
13
|
+
if (summary.experienceCount > 0) {
|
|
14
|
+
sections.push(`${summary.experienceCount} experience`);
|
|
15
|
+
}
|
|
16
|
+
if (summary.projectCount > 0) {
|
|
17
|
+
sections.push(`${summary.projectCount} project${summary.projectCount > 1 ? 's' : ''}`);
|
|
18
|
+
}
|
|
19
|
+
if (summary.skillCategoryCount > 0) {
|
|
20
|
+
sections.push(`${summary.skillCategoryCount} skill categor${summary.skillCategoryCount > 1 ? 'ies' : 'y'}`);
|
|
21
|
+
}
|
|
22
|
+
if (summary.writingCount > 0) {
|
|
23
|
+
sections.push(`${summary.writingCount} article${summary.writingCount > 1 ? 's' : ''}`);
|
|
24
|
+
}
|
|
25
|
+
if (summary.educationCount > 0) {
|
|
26
|
+
sections.push(`${summary.educationCount} education`);
|
|
27
|
+
}
|
|
28
|
+
if (sections.length > 0) {
|
|
29
|
+
console.log(` ${chalk.dim('Sections:')} ${sections.join(', ')}`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(` ${chalk.dim('Sections:')} ${chalk.yellow('none (add some content!)')}`);
|
|
33
|
+
}
|
|
34
|
+
console.log(` ${chalk.dim('Theme:')} ${summary.theme}`);
|
|
35
|
+
console.log();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err instanceof ValidationError) {
|
|
39
|
+
console.log(chalk.red('✗') + ' Validation failed\n');
|
|
40
|
+
// Group errors by whether they have hints
|
|
41
|
+
const errorsWithHints = [];
|
|
42
|
+
const errorsWithoutHints = [];
|
|
43
|
+
for (const error of err.errors) {
|
|
44
|
+
if (error.hint) {
|
|
45
|
+
errorsWithHints.push(error);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
errorsWithoutHints.push(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Show all errors
|
|
52
|
+
for (const error of err.errors) {
|
|
53
|
+
console.log(` ${chalk.red('•')} ${chalk.yellow(error.path)} - ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
// Show hints for first error that has one
|
|
56
|
+
const firstHint = errorsWithHints[0];
|
|
57
|
+
if (firstHint?.hint) {
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(chalk.dim(' Expected format:'));
|
|
60
|
+
const hintLines = firstHint.hint.split('\n');
|
|
61
|
+
for (const line of hintLines) {
|
|
62
|
+
console.log(chalk.dim(` ${line}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
console.log();
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
if (err instanceof FileError) {
|
|
69
|
+
console.log(chalk.red('✗') + ` ${err.message}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (err instanceof ParseError) {
|
|
73
|
+
console.log(chalk.red('✗') + ' YAML syntax error\n');
|
|
74
|
+
if (err.line !== undefined) {
|
|
75
|
+
console.log(` ${chalk.dim('Line')} ${err.line + 1}: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
console.log(` ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
console.log();
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
// Unknown error
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
import { portfolioSchema } from '../schemas/portfolio.schema.js';
|
|
4
|
+
export class ValidationError extends Error {
|
|
5
|
+
errors;
|
|
6
|
+
constructor(message, errors) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.errors = errors;
|
|
9
|
+
this.name = 'ValidationError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class FileError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'FileError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class ParseError extends Error {
|
|
19
|
+
line;
|
|
20
|
+
constructor(message, line) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.line = line;
|
|
23
|
+
this.name = 'ParseError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function getHintForError(path, message) {
|
|
27
|
+
if (path.includes('date') && message.includes('YYYY-MM')) {
|
|
28
|
+
return `date:
|
|
29
|
+
start: 2024-01
|
|
30
|
+
end: present`;
|
|
31
|
+
}
|
|
32
|
+
if (path.includes('email')) {
|
|
33
|
+
return 'email: you@example.com';
|
|
34
|
+
}
|
|
35
|
+
if (path.includes('url') || message.includes('URL')) {
|
|
36
|
+
return 'url: https://example.com';
|
|
37
|
+
}
|
|
38
|
+
if (path.includes('theme')) {
|
|
39
|
+
return "theme: srcl # Options: srcl, modern, minimal, dark-academia";
|
|
40
|
+
}
|
|
41
|
+
if (path.includes('highlights') && message.includes('at least')) {
|
|
42
|
+
return `highlights:
|
|
43
|
+
- Your key achievement or responsibility`;
|
|
44
|
+
}
|
|
45
|
+
if (path.includes('tags') && message.includes('at least')) {
|
|
46
|
+
return 'tags: [TypeScript, React]';
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
export function validatePortfolio(yamlPath) {
|
|
51
|
+
// Read file
|
|
52
|
+
let content;
|
|
53
|
+
try {
|
|
54
|
+
content = readFileSync(yamlPath, 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
58
|
+
throw new FileError(`File not found: ${yamlPath}`);
|
|
59
|
+
}
|
|
60
|
+
throw new FileError(`Cannot read file: ${yamlPath}`);
|
|
61
|
+
}
|
|
62
|
+
// Parse YAML
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
data = yaml.load(content);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof yaml.YAMLException) {
|
|
69
|
+
throw new ParseError(`YAML syntax error: ${err.reason}`, err.mark?.line);
|
|
70
|
+
}
|
|
71
|
+
throw new ParseError('Failed to parse YAML');
|
|
72
|
+
}
|
|
73
|
+
// Validate against schema
|
|
74
|
+
const result = portfolioSchema.safeParse(data);
|
|
75
|
+
if (!result.success) {
|
|
76
|
+
const errors = result.error.issues.map((issue) => {
|
|
77
|
+
const path = issue.path.join('.');
|
|
78
|
+
const message = issue.message;
|
|
79
|
+
const hint = getHintForError(path, message);
|
|
80
|
+
return { path, message, hint };
|
|
81
|
+
});
|
|
82
|
+
throw new ValidationError('Portfolio validation failed', errors);
|
|
83
|
+
}
|
|
84
|
+
return result.data;
|
|
85
|
+
}
|
|
86
|
+
export function getPortfolioSummary(portfolio) {
|
|
87
|
+
return {
|
|
88
|
+
name: portfolio.meta.name,
|
|
89
|
+
title: portfolio.meta.title,
|
|
90
|
+
experienceCount: portfolio.sections.experience?.length ?? 0,
|
|
91
|
+
projectCount: portfolio.sections.projects?.length ?? 0,
|
|
92
|
+
skillCategoryCount: portfolio.sections.skills
|
|
93
|
+
? Object.keys(portfolio.sections.skills).length
|
|
94
|
+
: 0,
|
|
95
|
+
writingCount: portfolio.sections.writing?.length ?? 0,
|
|
96
|
+
educationCount: portfolio.sections.education?.length ?? 0,
|
|
97
|
+
theme: portfolio.theme ?? 'srcl',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { initCommand } from './commands/init.js';
|
|
5
|
+
import { validateCommand } from './commands/validate.js';
|
|
6
|
+
import { renderCommand } from './commands/render.js';
|
|
7
|
+
import { themesCommand } from './commands/themes.js';
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name('devfolio')
|
|
11
|
+
.description('Your portfolio as code. Version control it like software.')
|
|
12
|
+
.version('0.1.0');
|
|
13
|
+
program
|
|
14
|
+
.command('init')
|
|
15
|
+
.description('Create a new portfolio.yaml with interactive prompts')
|
|
16
|
+
.action(initCommand);
|
|
17
|
+
program
|
|
18
|
+
.command('validate <file>')
|
|
19
|
+
.description('Validate a portfolio YAML file')
|
|
20
|
+
.action(validateCommand);
|
|
21
|
+
program
|
|
22
|
+
.command('render [file]')
|
|
23
|
+
.description('Generate static site from portfolio YAML')
|
|
24
|
+
.option('-t, --theme <theme>', 'Theme to use (defaults to YAML theme or srcl)')
|
|
25
|
+
.option('-o, --output <dir>', 'Output directory', './site')
|
|
26
|
+
.action((file, options) => renderCommand(file || 'portfolio.yaml', options));
|
|
27
|
+
program
|
|
28
|
+
.command('themes')
|
|
29
|
+
.description('List available themes with examples')
|
|
30
|
+
.action(themesCommand);
|
|
31
|
+
// Show welcome message if no arguments provided
|
|
32
|
+
if (process.argv.length === 2) {
|
|
33
|
+
console.log();
|
|
34
|
+
console.log(chalk.cyan('┌─────────────────────────────────────────────────────────┐'));
|
|
35
|
+
console.log(chalk.cyan('│') + ' ' + chalk.bold('devfolio.page') + ' - Your portfolio as code ' + chalk.cyan('│'));
|
|
36
|
+
console.log(chalk.cyan('└─────────────────────────────────────────────────────────┘'));
|
|
37
|
+
console.log();
|
|
38
|
+
console.log(chalk.bold('Commands:'));
|
|
39
|
+
console.log();
|
|
40
|
+
console.log(' ' + chalk.cyan('devfolio init') + ' Create a new portfolio folder');
|
|
41
|
+
console.log(' ' + chalk.cyan('devfolio render') + ' Generate site (uses portfolio.yaml)');
|
|
42
|
+
console.log(' ' + chalk.cyan('devfolio themes') + ' List available themes');
|
|
43
|
+
console.log(' ' + chalk.cyan('devfolio validate') + ' <file> Validate a portfolio YAML file');
|
|
44
|
+
console.log(' ' + chalk.cyan('devfolio --help') + ' Show all options');
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(chalk.dim('Get started:'));
|
|
47
|
+
console.log(chalk.dim(' $ ') + 'devfolio init');
|
|
48
|
+
console.log();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
program.parse();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
// Use stderr so output isn't suppressed during global installs
|
|
4
|
+
const log = (msg = '') => process.stderr.write(msg + '\n');
|
|
5
|
+
log();
|
|
6
|
+
log(chalk.cyan('┌─────────────────────────────────────────────────────────┐'));
|
|
7
|
+
log(chalk.cyan('│') + ' ' + chalk.bold('devfolio.page') + ' installed successfully! ' + chalk.cyan('│'));
|
|
8
|
+
log(chalk.cyan('│') + ' Your portfolio as code. ' + chalk.cyan('│'));
|
|
9
|
+
log(chalk.cyan('└─────────────────────────────────────────────────────────┘'));
|
|
10
|
+
log();
|
|
11
|
+
log(chalk.bold('Available commands:'));
|
|
12
|
+
log();
|
|
13
|
+
log(' ' + chalk.cyan('devfolio init') + ' Create a new portfolio folder');
|
|
14
|
+
log(' ' + chalk.cyan('devfolio render') + ' Generate static site from YAML');
|
|
15
|
+
log(' ' + chalk.cyan('devfolio validate') + ' <file> Validate a portfolio YAML file');
|
|
16
|
+
log(' ' + chalk.cyan('devfolio --help') + ' Show all options');
|
|
17
|
+
log();
|
|
18
|
+
log(chalk.dim('Get started:'));
|
|
19
|
+
log(chalk.dim(' $ ') + 'devfolio init');
|
|
20
|
+
log();
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// Common Validators
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// Custom date format validator (YYYY-MM or YYYY-MM-DD)
|
|
6
|
+
const dateFormat = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^\d{4}-\d{2}(-\d{2})?$/, 'Date must be in YYYY-MM or YYYY-MM-DD format');
|
|
9
|
+
// Date that can also be "present"
|
|
10
|
+
const dateOrPresent = z.union([dateFormat, z.literal('present')]);
|
|
11
|
+
// URL validation with auto-fix for missing protocol
|
|
12
|
+
const urlString = z
|
|
13
|
+
.string()
|
|
14
|
+
.transform((val) => {
|
|
15
|
+
// Auto-add https:// if no protocol specified
|
|
16
|
+
if (val && !val.match(/^https?:\/\//)) {
|
|
17
|
+
return `https://${val}`;
|
|
18
|
+
}
|
|
19
|
+
return val;
|
|
20
|
+
})
|
|
21
|
+
.pipe(z.string().url('Must be a valid URL'));
|
|
22
|
+
// Optional URL that's less strict (for image paths, etc.)
|
|
23
|
+
const urlOrPath = z.string().min(1, 'Path or URL is required');
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Content Section Schemas (for rich project pages)
|
|
26
|
+
// =============================================================================
|
|
27
|
+
const overviewSectionSchema = z.object({
|
|
28
|
+
type: z.literal('overview'),
|
|
29
|
+
content: z.string().min(1, 'Overview content is required'),
|
|
30
|
+
});
|
|
31
|
+
const imageSectionSchema = z.object({
|
|
32
|
+
type: z.literal('image'),
|
|
33
|
+
src: urlOrPath,
|
|
34
|
+
caption: z.string().optional(),
|
|
35
|
+
alt: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
const gallerySectionSchema = z.object({
|
|
38
|
+
type: z.literal('gallery'),
|
|
39
|
+
images: z
|
|
40
|
+
.array(z.object({
|
|
41
|
+
src: urlOrPath,
|
|
42
|
+
caption: z.string().optional(),
|
|
43
|
+
alt: z.string().optional(),
|
|
44
|
+
}))
|
|
45
|
+
.min(1, 'Gallery must have at least one image'),
|
|
46
|
+
});
|
|
47
|
+
const demoSectionSchema = z.object({
|
|
48
|
+
type: z.literal('demo'),
|
|
49
|
+
title: z.string().optional(),
|
|
50
|
+
embed: z.string().min(1, 'Demo embed code is required'),
|
|
51
|
+
});
|
|
52
|
+
const codeSectionSchema = z.object({
|
|
53
|
+
type: z.literal('code'),
|
|
54
|
+
title: z.string().optional(),
|
|
55
|
+
language: z.string().min(1, 'Code language is required'),
|
|
56
|
+
code: z.string().min(1, 'Code content is required'),
|
|
57
|
+
});
|
|
58
|
+
const metricsSectionSchema = z.object({
|
|
59
|
+
type: z.literal('metrics'),
|
|
60
|
+
data: z
|
|
61
|
+
.array(z.object({
|
|
62
|
+
label: z.string().min(1, 'Metric label is required'),
|
|
63
|
+
value: z.string().min(1, 'Metric value is required'),
|
|
64
|
+
}))
|
|
65
|
+
.min(1, 'At least one metric is required'),
|
|
66
|
+
});
|
|
67
|
+
const outcomesSectionSchema = z.object({
|
|
68
|
+
type: z.literal('outcomes'),
|
|
69
|
+
content: z.string().min(1, 'Outcomes content is required'),
|
|
70
|
+
});
|
|
71
|
+
const processSectionSchema = z.object({
|
|
72
|
+
type: z.literal('process'),
|
|
73
|
+
content: z.string().min(1, 'Process content is required'),
|
|
74
|
+
});
|
|
75
|
+
const interactiveSectionSchema = z.object({
|
|
76
|
+
type: z.literal('interactive'),
|
|
77
|
+
title: z.string().optional(),
|
|
78
|
+
embed_type: z.enum(['iframe', 'embed']),
|
|
79
|
+
url: urlString,
|
|
80
|
+
});
|
|
81
|
+
const contentSectionSchema = z.discriminatedUnion('type', [
|
|
82
|
+
overviewSectionSchema,
|
|
83
|
+
imageSectionSchema,
|
|
84
|
+
gallerySectionSchema,
|
|
85
|
+
demoSectionSchema,
|
|
86
|
+
codeSectionSchema,
|
|
87
|
+
metricsSectionSchema,
|
|
88
|
+
outcomesSectionSchema,
|
|
89
|
+
processSectionSchema,
|
|
90
|
+
interactiveSectionSchema,
|
|
91
|
+
]);
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// Project Schemas
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// Simple project schema (backwards compatible)
|
|
96
|
+
const simpleProjectSchema = z.object({
|
|
97
|
+
name: z.string().min(1, 'Project name is required'),
|
|
98
|
+
url: urlString.optional(),
|
|
99
|
+
description: z.string().min(1, 'Project description is required'),
|
|
100
|
+
tags: z.array(z.string()).min(1, 'At least one tag is required'),
|
|
101
|
+
featured: z.boolean().optional(),
|
|
102
|
+
});
|
|
103
|
+
// Rich project schema (for case studies)
|
|
104
|
+
const richProjectSchema = z.object({
|
|
105
|
+
id: z.string().min(1, 'Project ID (URL slug) is required'),
|
|
106
|
+
title: z.string().min(1, 'Project title is required'),
|
|
107
|
+
subtitle: z.string().optional(),
|
|
108
|
+
featured: z.boolean().optional(),
|
|
109
|
+
thumbnail: urlOrPath,
|
|
110
|
+
hero: urlOrPath.optional(),
|
|
111
|
+
meta: z.object({
|
|
112
|
+
year: z.union([z.string(), z.number()]),
|
|
113
|
+
role: z.string().min(1, 'Role is required'),
|
|
114
|
+
timeline: z.string().optional(),
|
|
115
|
+
tech: z.array(z.string()).min(1, 'At least one technology is required'),
|
|
116
|
+
links: z
|
|
117
|
+
.object({
|
|
118
|
+
github: urlString.optional(),
|
|
119
|
+
demo: urlString.optional(),
|
|
120
|
+
live: urlString.optional(),
|
|
121
|
+
case_study: urlString.optional(),
|
|
122
|
+
})
|
|
123
|
+
.optional(),
|
|
124
|
+
}),
|
|
125
|
+
sections: z.array(contentSectionSchema).min(1, 'At least one section is required'),
|
|
126
|
+
});
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// Experience & Education Schemas
|
|
129
|
+
// =============================================================================
|
|
130
|
+
const experienceSchema = z.object({
|
|
131
|
+
company: z.string().min(1, 'Company name is required'),
|
|
132
|
+
role: z.string().min(1, 'Role is required'),
|
|
133
|
+
date: z.object({
|
|
134
|
+
start: dateFormat,
|
|
135
|
+
end: dateOrPresent,
|
|
136
|
+
}),
|
|
137
|
+
location: z.string().optional(),
|
|
138
|
+
description: z.string().optional(),
|
|
139
|
+
highlights: z.array(z.string()).min(1, 'At least one highlight is required'),
|
|
140
|
+
});
|
|
141
|
+
const educationSchema = z.object({
|
|
142
|
+
institution: z.string().min(1, 'Institution name is required'),
|
|
143
|
+
degree: z.string().min(1, 'Degree is required'),
|
|
144
|
+
date: z.object({
|
|
145
|
+
start: dateFormat,
|
|
146
|
+
end: dateFormat,
|
|
147
|
+
}),
|
|
148
|
+
location: z.string().optional(),
|
|
149
|
+
description: z.string().optional(),
|
|
150
|
+
highlights: z.array(z.string()).optional(),
|
|
151
|
+
});
|
|
152
|
+
// =============================================================================
|
|
153
|
+
// Skills Schemas
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// Simple skills schema (backwards compatible) - dynamic categories
|
|
156
|
+
const simpleSkillsSchema = z.record(z.string(), z.array(z.string()).min(1, 'Each skill category needs at least one skill'));
|
|
157
|
+
// Rich skills schema with details
|
|
158
|
+
const richSkillsSchema = z.object({
|
|
159
|
+
categories: z.array(z.object({
|
|
160
|
+
name: z.string().min(1, 'Category name is required'),
|
|
161
|
+
level: z.enum(['beginner', 'proficient', 'expert']).optional(),
|
|
162
|
+
items: z.array(z.object({
|
|
163
|
+
name: z.string().min(1, 'Skill name is required'),
|
|
164
|
+
years: z.number().optional(),
|
|
165
|
+
icon: z.string().optional(),
|
|
166
|
+
note: z.string().optional(),
|
|
167
|
+
})),
|
|
168
|
+
})),
|
|
169
|
+
});
|
|
170
|
+
// =============================================================================
|
|
171
|
+
// Writing Schema
|
|
172
|
+
// =============================================================================
|
|
173
|
+
const writingSchema = z.object({
|
|
174
|
+
title: z.string().min(1, 'Article title is required'),
|
|
175
|
+
url: urlString,
|
|
176
|
+
date: dateFormat,
|
|
177
|
+
description: z.string().optional(), // Backwards compatible
|
|
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(),
|
|
183
|
+
});
|
|
184
|
+
// =============================================================================
|
|
185
|
+
// New Content Types
|
|
186
|
+
// =============================================================================
|
|
187
|
+
const experimentSchema = z.object({
|
|
188
|
+
title: z.string().min(1, 'Experiment title is required'),
|
|
189
|
+
description: z.string().min(1, 'Experiment description is required'),
|
|
190
|
+
image: urlOrPath.optional(),
|
|
191
|
+
github: urlString.optional(),
|
|
192
|
+
demo: urlString.optional(),
|
|
193
|
+
tags: z.array(z.string()).min(1, 'At least one tag is required'),
|
|
194
|
+
});
|
|
195
|
+
const testimonialSchema = z.object({
|
|
196
|
+
quote: z.string().min(1, 'Quote is required'),
|
|
197
|
+
author: z.string().min(1, 'Author name is required'),
|
|
198
|
+
company: z.string().optional(),
|
|
199
|
+
role: z.string().optional(),
|
|
200
|
+
image: urlOrPath.optional(),
|
|
201
|
+
});
|
|
202
|
+
const timelineItemSchema = z.object({
|
|
203
|
+
year: z.union([z.string(), z.number()]),
|
|
204
|
+
title: z.string().min(1, 'Timeline item title is required'),
|
|
205
|
+
description: z.string().min(1, 'Timeline item description is required'),
|
|
206
|
+
image: urlOrPath.optional(),
|
|
207
|
+
});
|
|
208
|
+
// =============================================================================
|
|
209
|
+
// Settings & Layout Schemas
|
|
210
|
+
// =============================================================================
|
|
211
|
+
const settingsSchema = z.object({
|
|
212
|
+
show_grid: z.boolean().optional(),
|
|
213
|
+
enable_hotkeys: z.boolean().optional(),
|
|
214
|
+
color_scheme: z.enum(['dark', 'light']).optional(),
|
|
215
|
+
animate: z.enum(['none', 'subtle', 'full']).optional(),
|
|
216
|
+
});
|
|
217
|
+
const layoutSchema = z.object({
|
|
218
|
+
homepage_style: z.enum(['hero', 'grid', 'minimal']).optional(),
|
|
219
|
+
project_layout: z.enum(['case-study', 'grid', 'list']).optional(),
|
|
220
|
+
show_experiments: z.boolean().optional(),
|
|
221
|
+
show_timeline: z.boolean().optional(),
|
|
222
|
+
});
|
|
223
|
+
// =============================================================================
|
|
224
|
+
// Main Portfolio Schema
|
|
225
|
+
// =============================================================================
|
|
226
|
+
export const portfolioSchema = z.object({
|
|
227
|
+
// Core metadata
|
|
228
|
+
meta: z.object({
|
|
229
|
+
name: z.string().min(1, 'Name is required'),
|
|
230
|
+
title: z.string().min(1, 'Professional title is required'),
|
|
231
|
+
tagline: z.string().optional(),
|
|
232
|
+
location: z.string().min(1, 'Location is required'),
|
|
233
|
+
timezone: z.string().optional(),
|
|
234
|
+
avatar: urlOrPath.optional(),
|
|
235
|
+
hero_image: urlOrPath.optional(),
|
|
236
|
+
}),
|
|
237
|
+
// Contact information
|
|
238
|
+
contact: z.object({
|
|
239
|
+
email: z.string().email('Must be a valid email address'),
|
|
240
|
+
website: urlString.optional(),
|
|
241
|
+
github: z.string().optional(),
|
|
242
|
+
linkedin: z.string().optional(),
|
|
243
|
+
twitter: z.string().optional(),
|
|
244
|
+
}),
|
|
245
|
+
// Bio (required for backwards compatibility)
|
|
246
|
+
bio: z.string().min(10, 'Bio should be at least 10 characters'),
|
|
247
|
+
// Extended about section (optional)
|
|
248
|
+
about: z
|
|
249
|
+
.object({
|
|
250
|
+
short: z.string().min(1, 'Short bio is required'),
|
|
251
|
+
long: z.string().min(1, 'Long bio is required'),
|
|
252
|
+
})
|
|
253
|
+
.optional(),
|
|
254
|
+
// Sections (backwards compatible structure)
|
|
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
|
+
}),
|
|
262
|
+
// Rich projects (new structure for case studies)
|
|
263
|
+
projects: z.array(richProjectSchema).optional(),
|
|
264
|
+
// New content types
|
|
265
|
+
experiments: z.array(experimentSchema).optional(),
|
|
266
|
+
testimonials: z.array(testimonialSchema).optional(),
|
|
267
|
+
timeline: z.array(timelineItemSchema).optional(),
|
|
268
|
+
// Theme selection
|
|
269
|
+
theme: z.enum(['srcl', 'modern', 'minimal', 'dark-academia']).optional(),
|
|
270
|
+
// Layout configuration
|
|
271
|
+
layout: layoutSchema.optional(),
|
|
272
|
+
// Settings
|
|
273
|
+
settings: settingsSchema.optional(),
|
|
274
|
+
});
|
|
275
|
+
// =============================================================================
|
|
276
|
+
// Helper Functions
|
|
277
|
+
// =============================================================================
|
|
278
|
+
// Format Zod errors nicely
|
|
279
|
+
export function formatValidationErrors(error) {
|
|
280
|
+
return error.issues.map((issue) => {
|
|
281
|
+
const path = issue.path.join('.');
|
|
282
|
+
return `${path}: ${issue.message}`;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// Get a user-friendly name for a section type
|
|
286
|
+
export function getSectionTypeName(type) {
|
|
287
|
+
const names = {
|
|
288
|
+
overview: 'Overview',
|
|
289
|
+
image: 'Image',
|
|
290
|
+
gallery: 'Gallery',
|
|
291
|
+
demo: 'Demo',
|
|
292
|
+
code: 'Code Block',
|
|
293
|
+
metrics: 'Metrics',
|
|
294
|
+
outcomes: 'Outcomes',
|
|
295
|
+
process: 'Process',
|
|
296
|
+
interactive: 'Interactive Embed',
|
|
297
|
+
};
|
|
298
|
+
return names[type] || type;
|
|
299
|
+
}
|