feat: add validated content services and safe legacy import

This commit is contained in:
2026-09-15 21:47:44 +03:30
parent a9fe865d99
commit 77114c57cf
4 changed files with 3752 additions and 0 deletions

3528
src/seed/legacy.json Normal file

File diff suppressed because it is too large Load Diff

144
src/server/content.ts Normal file
View 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 };
}

View 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();
});
}