feat: add validated content services and safe legacy import
This commit is contained in:
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