feat: define validated multilingual portfolio content
This commit is contained in:
1
public/images/portfolio-placeholder.svg
Normal file
1
public/images/portfolio-placeholder.svg
Normal 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
80
src/content/contracts.ts
Normal 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}`; }
|
||||||
99
src/content/messages.en.json
Normal file
99
src/content/messages.en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/content/messages.fa.json
Normal file
99
src/content/messages.fa.json
Normal 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
22
src/content/messages.ts
Normal 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
21
src/content/samples.ts
Normal 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 } })); }
|
||||||
Reference in New Issue
Block a user