feat: define validated multilingual portfolio content

This commit is contained in:
2026-09-15 21:47:39 +03:30
parent 638b6a2ea0
commit 8bfe35f6b9
6 changed files with 322 additions and 0 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400" viewBox="0 0 640 400"><rect width="640" height="400" fill="#e9eef4"/><circle cx="320" cy="156" r="55" fill="#8ba4bd"/><path d="M210 305a110 110 0 0 1 220 0" fill="#8ba4bd"/><path d="M70 340h500" stroke="#d0dae4" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 302 B

80
src/content/contracts.ts Normal file
View File

@@ -0,0 +1,80 @@
import { z } from 'zod';
export const sectionKeys = ['hero', 'about', 'projects', 'experience', 'skills', 'education', 'milestones', 'footer'] as const;
export const entryKinds = ['projects', 'experience', 'skills', 'education', 'milestones'] as const;
const text = z.string().max(20000);
const title = z.string().min(1).max(300);
export const slugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(100);
export const linkSchema = z.string().max(2048).refine(value => {
if (!value) return true;
if (/^\/media\/[a-zA-Z0-9.-]+$/.test(value)) return true;
try { return ['https:', 'http:', 'mailto:', 'tel:'].includes(new URL(value).protocol); } catch { return false; }
}, 'Use an https, http, mailto, tel, or uploaded-media URL');
export const imageSchema = z.object({
src: z.string().regex(/^\/(?:images|media)\/[a-zA-Z0-9/_().-]+$/).refine(value => !value.includes('..'), 'Invalid image path'),
width: z.number().int().min(1).max(20000), height: z.number().int().min(1).max(20000),
}).strict();
const strings = z.array(text).max(200);
const gallery = z.array(z.object({ image: imageSchema, alt: text, caption: text }).strict()).max(100);
export const entrySchemas = {
projects: z.object({
title, liveUrl: linkSchema, date: text, description: text, tags: strings, image: imageSchema,
caseStudy: z.object({
introduction: text, role: text, targetUsers: text, status: text, challenge: text,
ownership: strings, decisions: z.array(z.object({ discipline: z.enum(['product', 'engineering', 'design']), title, description: text }).strict()).max(100),
outcomes: strings, skills: strings, tools: strings, team: text, constraints: strings,
metrics: z.array(z.object({ value: text, label: text }).strict()).max(100), lessons: strings, gallery,
}).strict(),
}).strict(),
experience: z.object({ role: title, company: title, date: text, location: text, logo: imageSchema, isCurrent: z.boolean(), summary: text, bullets: strings }).strict(),
education: z.object({ degree: title, institution: title, date: text, description: text, logo: imageSchema }).strict(),
milestones: z.object({ title, issuer: text, date: text, description: text, image: imageSchema, link: linkSchema }).strict(),
skills: z.object({ label: title, skills: z.array(z.object({ name: title, level: z.number().int().min(0).max(5) }).strict()).max(100), keywords: strings, tools: z.array(z.object({ id: slugSchema, name: title, icon: imageSchema }).strict()).max(100) }).strict(),
};
export const sectionSchemas = {
hero: z.object({ sectionLabel: text, titleLabel: text, greeting: text, name: title, subtitle: text, avatarAlt: text }).strict(),
about: z.object({ eyebrow: text, title, description: text, steps: z.array(z.object({ title, description: text }).strict()).max(20) }).strict(),
projects: z.object({ eyebrow: text, title }).strict(),
experience: z.object({ title }).strict(),
skills: z.object({ eyebrow: text, title }).strict(),
education: z.object({ title }).strict(),
milestones: z.object({ eyebrow: text, title }).strict(),
footer: z.object({ title, subtitle: text }).strict(),
};
export const settingsSchema = z.object({
defaultLanguage: z.string().min(2).max(35), avatar: imageSchema,
contactUrl: linkSchema, blogUrl: linkSchema, coursesUrl: linkSchema, email: z.union([z.email(), z.literal('')]),
contacts: z.array(z.object({ id: z.enum(['phone', 'linkedin', 'github', 'telegram']), label: text, href: linkSchema }).strict()).max(4)
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Contact types must be unique'),
metadata: z.record(z.string(), z.object({ title, description: text, keywords: strings }).strict()),
}).strict();
export const languageSchema = z.object({
code: z.string().min(2).max(35).refine(value => {
try { return Intl.getCanonicalLocales(value)[0].toLowerCase() === value && !['admin','api','media','projects'].includes(value); } catch { return false; }
}, 'Use a lowercase language tag, such as en, fa, or pt-br'),
name: title, direction: z.enum(['ltr', 'rtl']), enabled: z.boolean(),
messages: z.record(z.string().max(150), z.string().max(2000)),
}).strict();
export const entrySchema = z.object({
id: slugSchema, kind: z.enum(entryKinds), position: z.number().int().min(0).max(100000), visible: z.boolean(),
shared: z.record(z.string(), z.unknown()), translations: z.record(z.string(), z.record(z.string(), z.unknown())),
}).strict();
export const sectionSchema = z.object({
key: z.enum(sectionKeys), visible: z.boolean(), translations: z.record(z.string(), z.record(z.string(), z.unknown())),
}).strict();
export type Settings = z.infer<typeof settingsSchema>;
export type Language = z.infer<typeof languageSchema>;
export type Entry = z.infer<typeof entrySchema>;
export type Section = z.infer<typeof sectionSchema>;
export type EntryKind = typeof entryKinds[number];
export type SectionKey = typeof sectionKeys[number];
export type EntryData<K extends EntryKind> = z.infer<(typeof entrySchemas)[K]> & { id: string };
export function formatMessage(template: string, values: Record<string, string | number>, locale = 'en') {
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
const value = values[key];
return value === undefined ? match : typeof value === 'number' ? new Intl.NumberFormat(locale).format(value) : value;
});
}
export function portfolioPath(locale: string, defaultLanguage: string) { return locale === defaultLanguage ? '/' : `/${locale}`; }
export function projectPath(locale: string, id: string, defaultLanguage: string) { return `${locale === defaultLanguage ? '' : `/${locale}`}/projects/${id}`; }

View File

@@ -0,0 +1,99 @@
{
"notFound": {
"code": "404",
"title": "This page isn’t here",
"description": "The address may have changed, or the page may no longer exist.",
"backHome": "Back to portfolio"
},
"hero": {
"themeLightLabel": "Switch to light mode",
"themeDarkLabel": "Switch to dark mode",
"languageButtonLabel": "Choose language",
"languageMenuLabel": "Languages",
"englishLabel": "English",
"persianLabel": "فارسی",
"blog": "Blog",
"blogLabel": "Blog, coming soon",
"courses": "Courses",
"coursesLabel": "Courses, coming soon",
"comingSoon": "Coming soon",
"contact": "Get in touch",
"contactLabel": "Get in touch"
},
"about": {
"journeyLabel": "Career journey"
},
"projects": {
"previousLabel": "Previous projects",
"nextLabel": "Next projects",
"carouselLabel": "Scrollable project cards",
"carouselRoleDescription": "carousel",
"scrollbarLabel": "Project carousel position",
"viewCaseStudy": "View case study",
"liveProjectLabel": "Open the live {title} project in a new tab",
"drawerLabel": "Project case study",
"closeCaseStudy": "Close case study",
"shareLabel": "Share",
"shareCaseStudy": "Share this case study",
"shareDialogTitle": "Share case study",
"shareDialogDescription": "Send this project to someone or copy its direct link.",
"closeShareDialog": "Close sharing options",
"shareTelegram": "Telegram",
"shareWhatsApp": "WhatsApp",
"shareLinkedIn": "LinkedIn",
"shareEmail": "Email",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"copyFailed": "Could not copy the link",
"roleLabel": "Role",
"usersLabel": "Users",
"statusLabel": "Status",
"teamLabel": "Team",
"galleryLabel": "Product gallery",
"openGalleryImage": "Open full-screen image: {caption}",
"selectGalleryImage": "Show image {index}: {caption}",
"imageViewerLabel": "Full-screen project gallery",
"closeImageViewer": "Close full-screen image",
"previousImage": "Previous image",
"nextImage": "Next image",
"imageCounter": "{current} of {total}",
"challengeLabel": "The challenge",
"ownershipLabel": "My ownership",
"decisionsLabel": "Key decisions",
"outcomesLabel": "Outcomes",
"skillsLabel": "Skills",
"toolsLabel": "Tools",
"constraintsLabel": "Constraints",
"metricsLabel": "Results",
"lessonsLabel": "Lessons",
"previousCaseStudy": "Previous project",
"nextCaseStudy": "Next project",
"decisionLabels": {
"product": "Product",
"engineering": "Engineering",
"design": "Design"
}
},
"experience": {
"logoAlt": "{company} logo"
},
"skills": {
"skillsLabel": "SKILLS",
"toolsLabel": "TOOLS",
"filterLabel": "Choose skill discipline"
},
"education": {},
"milestones": {
"viewCertificate": "View certificate",
"viewCertificateLabel": "View {title} certificate"
},
"footer": {
"emailLabel": "Send email",
"contactsLabel": "Social and contact links",
"sceneLabel": "Interactive 3D isometric footer scene",
"phone": "Phone",
"linkedin": "LinkedIn",
"github": "GitHub",
"telegram": "Telegram"
}
}

View File

@@ -0,0 +1,99 @@
{
"notFound": {
"code": "۴۰۴",
"title": "این صفحه پیدا نشد",
"description": "ممکن است نشانی صفحه تغییر کرده باشد یا دیگر در دسترس نباشد.",
"backHome": "بازگشت به نمونه‌کارها"
},
"hero": {
"themeLightLabel": "رفتن به حالت روشن",
"themeDarkLabel": "رفتن به حالت تاریک",
"languageButtonLabel": "انتخاب زبان",
"languageMenuLabel": "زبان‌ها",
"englishLabel": "English",
"persianLabel": "فارسی",
"blog": "وبلاگ",
"blogLabel": "وبلاگ، به‌زودی",
"courses": "دوره‌ها",
"coursesLabel": "دوره‌ها، به‌زودی",
"comingSoon": "به‌زودی",
"contact": "ارتباط با من",
"contactLabel": "ارسال پیام در لینکدین"
},
"about": {
"journeyLabel": "مسیر حرفه‌ای"
},
"projects": {
"previousLabel": "پروژه‌های قبلی",
"nextLabel": "پروژه‌های بعدی",
"carouselLabel": "فهرست افقی پروژه‌ها",
"carouselRoleDescription": "گردونه",
"scrollbarLabel": "موقعیت در فهرست پروژه‌ها",
"viewCaseStudy": "دیدن مطالعه موردی",
"liveProjectLabel": "باز کردن نسخه زنده {title} در زبانه جدید",
"drawerLabel": "مطالعه موردی پروژه",
"closeCaseStudy": "بستن مطالعه موردی",
"shareLabel": "اشتراک‌گذاری",
"shareCaseStudy": "اشتراک‌گذاری این مطالعه موردی",
"shareDialogTitle": "اشتراک‌گذاری مطالعه موردی",
"shareDialogDescription": "این پروژه را برای دیگران بفرستید یا پیوند مستقیم آن را کپی کنید.",
"closeShareDialog": "بستن گزینه‌های اشتراک‌گذاری",
"shareTelegram": "تلگرام",
"shareWhatsApp": "واتس‌اپ",
"shareLinkedIn": "لینکدین",
"shareEmail": "ایمیل",
"copyLink": "کپی پیوند",
"linkCopied": "پیوند کپی شد",
"copyFailed": "کپی پیوند ممکن نشد",
"roleLabel": "نقش من",
"usersLabel": "کاربران",
"statusLabel": "وضعیت",
"teamLabel": "تیم",
"galleryLabel": "تصاویر محصول",
"openGalleryImage": "باز کردن تصویر در نمای تمام‌صفحه: {caption}",
"selectGalleryImage": "نمایش تصویر {index}: {caption}",
"imageViewerLabel": "گالری تمام‌صفحه پروژه",
"closeImageViewer": "بستن تصویر تمام‌صفحه",
"previousImage": "تصویر قبلی",
"nextImage": "تصویر بعدی",
"imageCounter": "{current} از {total}",
"challengeLabel": "چالش",
"ownershipLabel": "مسئولیت‌های من",
"decisionsLabel": "تصمیم‌های کلیدی",
"outcomesLabel": "نتیجه‌ها",
"skillsLabel": "مهارت‌ها",
"toolsLabel": "ابزارها",
"constraintsLabel": "محدودیت‌ها",
"metricsLabel": "نتیجه‌های عددی",
"lessonsLabel": "آموخته‌ها",
"previousCaseStudy": "پروژه قبلی",
"nextCaseStudy": "پروژه بعدی",
"decisionLabels": {
"product": "محصول",
"engineering": "مهندسی",
"design": "طراحی"
}
},
"experience": {
"logoAlt": "نشان {company}"
},
"skills": {
"skillsLabel": "مهارت‌ها",
"toolsLabel": "ابزارها",
"filterLabel": "انتخاب حوزه مهارت"
},
"education": {},
"milestones": {
"viewCertificate": "دیدن گواهی",
"viewCertificateLabel": "دیدن گواهی {title}"
},
"footer": {
"emailLabel": "ارسال ایمیل",
"contactsLabel": "راه‌های ارتباطی و شبکه‌های اجتماعی",
"sceneLabel": "نمای تعاملی سه‌بعدی راه‌های ارتباطی",
"phone": "تلفن",
"linkedin": "لینکدین",
"github": "گیت‌هاب",
"telegram": "تلگرام"
}
}

22
src/content/messages.ts Normal file
View File

@@ -0,0 +1,22 @@
import english from './messages.en.json';
import persian from './messages.fa.json';
export type InterfaceMessages = typeof english;
export const dictionaries: Record<string, InterfaceMessages> = { en: english, fa: persian };
export function flattenMessages(value: Record<string, unknown>, prefix = ''): Record<string, string> {
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
const path = prefix ? `${prefix}.${key}` : key;
return typeof item === 'string' ? [[path, item]] : Object.entries(flattenMessages(item as Record<string, unknown>, path));
}));
}
export const messageDefaults = flattenMessages(english);
export function resolveMessages(locale: string, overrides: Record<string, string>): InterfaceMessages {
const result = structuredClone(dictionaries[locale] ?? english);
for (const [path, value] of Object.entries(overrides)) {
if (!Object.hasOwn(messageDefaults, path) || !value) continue;
const [section, field, child] = path.split('.');
const group = result[section as keyof InterfaceMessages] as Record<string, unknown>;
if (child) (group[field] as Record<string, string>)[child] = value;
else group[field] = value;
}
return result;
}

21
src/content/samples.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { EntryKind, Entry, Section, Settings, SectionKey } from './contracts';
export const sampleImage = { src: '/images/portfolio-placeholder.svg', width: 640, height: 400 };
export const entrySamples = {
projects: { title: 'A meaningful project', liveUrl: '', date: '2026', description: 'Describe the problem you solved and the people you helped.', tags: ['Design', 'Development'], image: sampleImage,
caseStudy: { introduction: 'Share the story behind your work.', role: 'Designer and developer', targetUsers: 'Your audience', status: 'Complete', challenge: 'What needed to change?', ownership: [], decisions: [], outcomes: [], skills: [], tools: [], team: '', constraints: [], metrics: [], lessons: [], gallery: [] } },
experience: { role: 'Your role', company: 'Organization', date: '2024 – Present', location: 'Remote', logo: sampleImage, isCurrent: true, summary: 'Describe your contribution.', bullets: [] },
education: { degree: 'Your qualification', institution: 'Institution', date: '2024', description: 'What did you learn?', logo: sampleImage },
milestones: { title: 'A milestone worth sharing', issuer: 'Organization', date: '2026', description: 'Describe your achievement.', image: sampleImage, link: '' },
skills: { label: 'My expertise', skills: [{ name: 'Problem solving', level: 4 }], keywords: ['Collaboration'], tools: [] },
} satisfies Record<EntryKind, Record<string, unknown>>;
export const sectionSamples = {
hero: { sectionLabel: 'Introduction', titleLabel: 'Your name — portfolio', greeting: 'Hello, I’m', name: 'Your Name', subtitle: 'I make thoughtful things for people.', avatarAlt: 'Profile image' },
about: { eyebrow: 'My journey', title: 'A little about me', description: 'Tell visitors what matters to you and how you approach your work.', steps: [{ title: 'Where it began', description: 'Share a chapter in your journey.' }] },
projects: { eyebrow: 'Selected work', title: 'Things I have built' },
experience: { title: 'Where I have contributed' }, skills: { eyebrow: 'My toolkit', title: 'Skills and tools' },
education: { title: 'What I have learned' }, milestones: { eyebrow: 'Along the way', title: 'Milestones' },
footer: { title: 'Let’s make something meaningful.', subtitle: 'Get in touch to discuss your next project.' },
} satisfies Record<SectionKey, Record<string, unknown>>;
export const sampleSettings: Settings = { defaultLanguage: 'en', avatar: sampleImage, contactUrl: 'mailto:hello@example.com', blogUrl: '', coursesUrl: '', email: 'hello@example.com', contacts: [], metadata: { en: { title: 'Your Name — Portfolio', description: 'A selection of my work and experience.', keywords: [] } } };
export function sampleEntries(): Entry[] { return Object.entries(entrySamples).map(([kind, data], position) => ({ id: `sample-${kind}`, kind: kind as EntryKind, position, visible: true, shared: data, translations: {} })); }
export function sampleSections(): Section[] { return Object.entries(sectionSamples).map(([key, data]) => ({ key: key as SectionKey, visible: true, translations: { en: data } })); }