feat: add validated content services and safe legacy import
This commit is contained in:
30
scripts/export-legacy.ts
Normal file
30
scripts/export-legacy.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Run only when refreshing the explicit legacy-import fixture, before editing legacy data.
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { messagesByLocale } from '../src/i18n/content';
|
||||||
|
import { getProjects } from '../src/data/projects';
|
||||||
|
import { getExperiences } from '../src/data/experience';
|
||||||
|
import { getEducation } from '../src/data/education';
|
||||||
|
import { getMilestones } from '../src/data/milestones';
|
||||||
|
import { filterTabs, skillsByDiscipline } from '../src/data/skills';
|
||||||
|
|
||||||
|
const placeholders: Record<string, string[]> = {
|
||||||
|
ariaLabel: ['progress'], progressLabel: ['progress'], liveProjectLabel: ['title'],
|
||||||
|
openGalleryImage: ['caption'], selectGalleryImage: ['index', 'caption'],
|
||||||
|
imageCounter: ['current', 'total'], logoAlt: ['company'], viewCertificateLabel: ['title'],
|
||||||
|
};
|
||||||
|
const fixture = { messages: messagesByLocale, entries: Object.fromEntries(['en', 'fa'].map(locale => [locale, {
|
||||||
|
projects: getProjects(locale as 'en' | 'fa'), experience: getExperiences(locale as 'en' | 'fa'),
|
||||||
|
education: [{ id: 'education', ...getEducation(locale as 'en' | 'fa') }],
|
||||||
|
milestones: getMilestones(locale as 'en' | 'fa'),
|
||||||
|
skills: filterTabs.map(tab => ({ id: tab.id, label: messagesByLocale[locale as 'en' | 'fa'].skills.filterLabels[tab.id], ...skillsByDiscipline[tab.id] })),
|
||||||
|
}])) };
|
||||||
|
mkdirSync('src/seed', { recursive: true });
|
||||||
|
writeFileSync('src/seed/legacy.json', JSON.stringify(fixture, (key, value) => {
|
||||||
|
if (typeof value !== 'function') return value;
|
||||||
|
const names = placeholders[key];
|
||||||
|
let result = value(...names.map((name, index) => ['progress', 'index', 'current', 'total'].includes(name) ? 98760 + index : `{${name}}`));
|
||||||
|
names.forEach((name, index) => {
|
||||||
|
result = result.replaceAll(String(98760 + index), `{${name}}`).replaceAll(new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(98760 + index), `{${name}}`);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, 2) + '\n');
|
||||||
3528
src/seed/legacy.json
Normal file
3528
src/seed/legacy.json
Normal file
File diff suppressed because it is too large
Load Diff
144
src/server/content.ts
Normal file
144
src/server/content.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { DatabaseConnection } from './db';
|
||||||
|
import * as tables from './schema';
|
||||||
|
import { entrySchema, entrySchemas, languageSchema, sectionSchema, sectionSchemas, settingsSchema, type EntryKind, type EntryData, type SectionKey } from '../content/contracts';
|
||||||
|
import { messageDefaults, resolveMessages } from '../content/messages';
|
||||||
|
import { markContentEdited } from './seed';
|
||||||
|
import { sectionSamples } from '../content/samples';
|
||||||
|
|
||||||
|
export class ContentError extends Error {
|
||||||
|
constructor(message: string, public status = 400) { super(message); }
|
||||||
|
}
|
||||||
|
export function readContent({ db }: DatabaseConnection) {
|
||||||
|
const settings = db.select().from(tables.siteSettings).get()?.data;
|
||||||
|
if (!settings) throw new ContentError('Database is not initialized. Run the init command.', 503);
|
||||||
|
return {
|
||||||
|
settings, languages: db.select().from(tables.languages).all().map(row => row.data),
|
||||||
|
sections: db.select().from(tables.sections).all().map(row => row.data),
|
||||||
|
entries: db.select().from(tables.entries).orderBy(asc(tables.entries.position), asc(tables.entries.id)).all(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type ContentSnapshot = ReturnType<typeof readContent>;
|
||||||
|
|
||||||
|
export function publicContent(snapshot: ContentSnapshot, locale: string) {
|
||||||
|
const { settings } = snapshot;
|
||||||
|
const language = snapshot.languages.find(item => item.code === locale && item.enabled);
|
||||||
|
if (!language) throw new ContentError('Language not found', 404);
|
||||||
|
const messages = resolveMessages(locale, language.messages);
|
||||||
|
const sections = Object.fromEntries(snapshot.sections.map(section => [section.key, {
|
||||||
|
...(section.visible ? { ...section.translations[settings.defaultLanguage], ...section.translations[locale] }
|
||||||
|
: Object.fromEntries(Object.entries(sectionSamples[section.key]).map(([key, value]) => [key, Array.isArray(value) ? [] : '']))),
|
||||||
|
visible: section.visible,
|
||||||
|
}])) as { [K in SectionKey]: z.infer<(typeof sectionSchemas)[K]> & { visible: boolean } };
|
||||||
|
const visible = snapshot.entries.filter(entry => entry.visible && sections[entry.kind].visible);
|
||||||
|
const getEntries = <K extends EntryKind>(kind: K) => visible.filter(entry => entry.kind === kind).map(entry => ({
|
||||||
|
...entry.shared, ...entry.translations[settings.defaultLanguage], ...entry.translations[locale], id: entry.id,
|
||||||
|
})) as EntryData<K>[];
|
||||||
|
return {
|
||||||
|
locale, direction: language.direction, defaultLanguage: settings.defaultLanguage,
|
||||||
|
languages: snapshot.languages.filter(item => item.enabled).map(({ code, name, direction }) => ({ code, name, direction })),
|
||||||
|
profile: { avatar: settings.avatar, email: settings.email, contacts: settings.contacts, contactUrl: settings.contactUrl, blogUrl: settings.blogUrl, coursesUrl: settings.coursesUrl },
|
||||||
|
metadata: settings.metadata[locale] ?? settings.metadata[settings.defaultLanguage],
|
||||||
|
messages: {
|
||||||
|
...messages,
|
||||||
|
hero: { ...messages.hero, ...sections.hero }, about: { ...messages.about, ...sections.about },
|
||||||
|
projects: { ...messages.projects, ...sections.projects }, experience: { ...messages.experience, ...sections.experience },
|
||||||
|
skills: { ...messages.skills, ...sections.skills }, education: { ...messages.education, ...sections.education },
|
||||||
|
milestones: { ...messages.milestones, ...sections.milestones }, footer: { ...messages.footer, ...sections.footer },
|
||||||
|
},
|
||||||
|
sections, projects: getEntries('projects'), experience: getEntries('experience'),
|
||||||
|
education: getEntries('education'), milestones: getEntries('milestones'), skills: getEntries('skills'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type PublicPortfolio = ReturnType<typeof publicContent>;
|
||||||
|
export type PortfolioProps = { portfolio: PublicPortfolio };
|
||||||
|
|
||||||
|
function validateTranslationCodes(translations: Record<string, unknown>, snapshot: ContentSnapshot) {
|
||||||
|
for (const code of Object.keys(translations)) {
|
||||||
|
if (!snapshot.languages.some(language => language.code === code)) throw new ContentError(`Unknown translation language: ${code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function validateMediaReferences(connection: DatabaseConnection, value: unknown) {
|
||||||
|
if (typeof value === 'string' && value.startsWith('/media/')) {
|
||||||
|
if (!connection.db.select().from(tables.media).where(eq(tables.media.key, value.slice(7))).get()) throw new ContentError('The selected upload no longer exists');
|
||||||
|
} else if (Array.isArray(value)) value.forEach(item => validateMediaReferences(connection, item));
|
||||||
|
else if (value && typeof value === 'object') Object.values(value).forEach(item => validateMediaReferences(connection, item));
|
||||||
|
}
|
||||||
|
export function saveContent(connection: DatabaseConnection, resource: string, input: unknown) {
|
||||||
|
const { db } = connection;
|
||||||
|
return db.transaction(() => {
|
||||||
|
const snapshot = readContent(connection);
|
||||||
|
let result: unknown;
|
||||||
|
if (resource === 'settings') {
|
||||||
|
const data = settingsSchema.parse(input);
|
||||||
|
if (!snapshot.languages.some(language => language.code === data.defaultLanguage && language.enabled)) throw new ContentError('Default language must be enabled');
|
||||||
|
if (!data.metadata[data.defaultLanguage]) throw new ContentError('Add metadata for the default language first');
|
||||||
|
if (snapshot.sections.some(section => !section.translations[data.defaultLanguage])) throw new ContentError('Translate each section into the new default language first');
|
||||||
|
validateTranslationCodes(data.metadata, snapshot);
|
||||||
|
validateMediaReferences(connection, data);
|
||||||
|
db.update(tables.siteSettings).set({ data }).where(eq(tables.siteSettings.id, 1)).run(); result = data;
|
||||||
|
} else if (resource === 'languages') {
|
||||||
|
const data = languageSchema.parse(input);
|
||||||
|
if (!data.enabled && data.code === snapshot.settings.defaultLanguage) throw new ContentError('The default language cannot be disabled');
|
||||||
|
for (const [key, value] of Object.entries(data.messages)) {
|
||||||
|
if (!Object.hasOwn(messageDefaults, key)) throw new ContentError(`Unknown interface label: ${key}`);
|
||||||
|
const expected: string[] = messageDefaults[key].match(/\{\w+\}/g) ?? [];
|
||||||
|
const supplied: string[] = value.match(/\{\w+\}/g) ?? [];
|
||||||
|
if (value && (expected.some(token => !supplied.includes(token)) || supplied.some(token => !expected.includes(token)))) throw new ContentError(`Keep the named placeholders in ${key}`);
|
||||||
|
}
|
||||||
|
db.insert(tables.languages).values({ code: data.code, data }).onConflictDoUpdate({ target: tables.languages.code, set: { data } }).run(); result = data;
|
||||||
|
} else if (resource === 'sections') {
|
||||||
|
const data = sectionSchema.parse(input);
|
||||||
|
validateTranslationCodes(data.translations, snapshot);
|
||||||
|
if (!data.translations[snapshot.settings.defaultLanguage]) throw new ContentError('The default-language section content is required');
|
||||||
|
for (const [locale, value] of Object.entries(data.translations)) {
|
||||||
|
const parsed = sectionSchemas[data.key].safeParse(value);
|
||||||
|
if (!parsed.success) throw new z.ZodError(parsed.error.issues.map(issue => ({ ...issue, path: ['translations', locale, ...issue.path] })));
|
||||||
|
}
|
||||||
|
db.update(tables.sections).set({ data }).where(eq(tables.sections.key, data.key)).run(); result = data;
|
||||||
|
} else if (resource === 'entries') {
|
||||||
|
const data = entrySchema.parse(input);
|
||||||
|
const existing = snapshot.entries.find(entry => entry.id === data.id);
|
||||||
|
if (existing && existing.kind !== data.kind) throw new ContentError('This ID belongs to another content type', 409);
|
||||||
|
validateTranslationCodes(data.translations, snapshot);
|
||||||
|
const schema = entrySchemas[data.kind];
|
||||||
|
const base = schema.safeParse(data.shared);
|
||||||
|
if (!base.success) throw new z.ZodError(base.error.issues.map(issue => ({ ...issue, path: ['shared', ...issue.path] })));
|
||||||
|
for (const [locale, value] of Object.entries(data.translations)) {
|
||||||
|
const parsed = schema.safeParse({ ...data.shared, ...value });
|
||||||
|
if (!parsed.success) throw new z.ZodError(parsed.error.issues.map(issue => ({ ...issue, path: ['translations', locale, ...issue.path] })));
|
||||||
|
}
|
||||||
|
validateMediaReferences(connection, data);
|
||||||
|
db.insert(tables.entries).values(data).onConflictDoUpdate({ target: tables.entries.id, set: data }).run(); result = data;
|
||||||
|
} else throw new ContentError('Unknown resource', 404);
|
||||||
|
markContentEdited(connection);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function deleteContent(connection: DatabaseConnection, resource: string, id: string) {
|
||||||
|
connection.db.transaction(db => {
|
||||||
|
if (resource === 'entries') db.delete(tables.entries).where(eq(tables.entries.id, id)).run();
|
||||||
|
else if (resource === 'languages') {
|
||||||
|
const snapshot = readContent(connection);
|
||||||
|
if (snapshot.settings.defaultLanguage === id) throw new ContentError('The default language cannot be deleted');
|
||||||
|
// Disabling preserves translations and URLs can be restored by enabling the language.
|
||||||
|
throw new ContentError('Disable this language instead to preserve its translations');
|
||||||
|
} else throw new ContentError('This resource cannot be deleted');
|
||||||
|
markContentEdited(connection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function resolvePublicRoute(snapshot: ContentSnapshot, segments: string[] = []) {
|
||||||
|
let locale = snapshot.settings.defaultLanguage;
|
||||||
|
let parts = segments;
|
||||||
|
if (parts[0] && parts[0] !== 'projects') {
|
||||||
|
const language = snapshot.languages.find(item => item.code === parts[0] && item.enabled && item.code !== locale);
|
||||||
|
if (!language) throw new ContentError('Page not found', 404);
|
||||||
|
locale = language.code; parts = parts.slice(1);
|
||||||
|
}
|
||||||
|
if (parts.length !== 0 && !(parts.length === 2 && parts[0] === 'projects')) throw new ContentError('Page not found', 404);
|
||||||
|
const portfolio = publicContent(snapshot, locale);
|
||||||
|
const project = parts[1] ? portfolio.projects.find(item => item.id === parts[1]) : undefined;
|
||||||
|
if (parts[1] && !project) throw new ContentError('Project not found', 404);
|
||||||
|
return { portfolio, project };
|
||||||
|
}
|
||||||
50
src/server/legacy-import.ts
Normal file
50
src/server/legacy-import.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import legacy from '../seed/legacy.json';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { DatabaseConnection } from './db';
|
||||||
|
import * as tables from './schema';
|
||||||
|
import { entryKinds, entrySchemas, sectionKeys, sectionSchemas, settingsSchema, type EntryKind } from '../content/contracts';
|
||||||
|
import { entrySamples } from '../content/samples';
|
||||||
|
import { messageDefaults, flattenMessages } from '../content/messages';
|
||||||
|
import { ContentError } from './content';
|
||||||
|
|
||||||
|
export function importLegacy(connection: DatabaseConnection) {
|
||||||
|
connection.db.transaction(db => {
|
||||||
|
if (db.select().from(tables.state).where(eq(tables.state.key, 'content-status')).get()?.value !== 'sample') throw new ContentError('Import is only available before content has been edited');
|
||||||
|
const settings = settingsSchema.parse({
|
||||||
|
defaultLanguage: 'en', avatar: { src: '/images/avatar.png', width: 192, height: 192 },
|
||||||
|
contactUrl: 'https://www.linkedin.com/messaging/compose/?recipient=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w', blogUrl: '', coursesUrl: '',
|
||||||
|
email: 'amirhosseinkhalili901@gmail.com', contacts: [
|
||||||
|
{ id: 'phone', label: '09388263077', href: 'tel:09388263077' },
|
||||||
|
{ id: 'linkedin', label: '@amiirkhl', href: 'https://linkedin.com/in/amiirkhl' },
|
||||||
|
{ id: 'github', label: 'git.amiirkhl.ir', href: 'https://git.amiirkhl.ir' },
|
||||||
|
{ id: 'telegram', label: '@amiirkhl', href: 'https://t.me/amiirkhl' },
|
||||||
|
], metadata: { en: legacy.messages.en.metadata, fa: legacy.messages.fa.metadata },
|
||||||
|
});
|
||||||
|
db.update(tables.siteSettings).set({ data: settings }).where(eq(tables.siteSettings.id, 1)).run();
|
||||||
|
for (const locale of ['en', 'fa'] as const) {
|
||||||
|
const labels = flattenMessages(legacy.messages[locale]);
|
||||||
|
const data = { code: locale, name: locale === 'en' ? 'English' : 'فارسی', direction: locale === 'en' ? 'ltr' as const : 'rtl' as const, enabled: true, messages: Object.fromEntries(Object.entries(labels).filter(([key]) => key in messageDefaults)) };
|
||||||
|
db.insert(tables.languages).values({ code: locale, data }).onConflictDoUpdate({ target: tables.languages.code, set: { data } }).run();
|
||||||
|
}
|
||||||
|
for (const key of sectionKeys) {
|
||||||
|
const schema = sectionSchemas[key];
|
||||||
|
const translations = Object.fromEntries(['en', 'fa'].map(locale => [locale, schema.parse(Object.fromEntries(Object.keys(schema.shape).map(field => [field, (legacy.messages[locale as 'en' | 'fa'][key] as Record<string, unknown>)[field]])))]));
|
||||||
|
db.update(tables.sections).set({ data: { key, visible: true, translations } }).where(eq(tables.sections.key, key)).run();
|
||||||
|
}
|
||||||
|
db.delete(tables.entries).run();
|
||||||
|
const normalize = (kind: EntryKind, item: Record<string, unknown>) => {
|
||||||
|
const { id: _id, ...data } = item;
|
||||||
|
void _id;
|
||||||
|
return entrySchemas[kind].parse({ ...entrySamples[kind], ...(kind === 'experience' ? { isCurrent: false } : {}), ...data,
|
||||||
|
...(kind === 'projects' ? { caseStudy: { ...entrySamples.projects.caseStudy, ...(data.caseStudy as object) } } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (const kind of entryKinds) {
|
||||||
|
legacy.entries.en[kind].forEach((entry, position) => {
|
||||||
|
const translated = legacy.entries.fa[kind].find(item => item.id === entry.id)!;
|
||||||
|
db.insert(tables.entries).values({ id: entry.id, kind, position, visible: true, shared: normalize(kind, entry), translations: { fa: normalize(kind, translated) } }).run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.update(tables.state).set({ value: 'legacy-imported' }).where(eq(tables.state.key, 'content-status')).run();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user