Add schema validation and sync metadata UI

This commit is contained in:
SepComet
2026-05-06 09:47:00 +08:00
parent e03af0c800
commit 85e510fbc0
14 changed files with 474 additions and 126 deletions
+64 -81
View File
@@ -1,73 +1,20 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { z } from 'zod';
import {
type GiteaActivityData,
type ProjectData,
type ProjectDownload,
type ShareData,
generatedProjectListSchema,
generatedShareListSchema,
giteaActivitySchema,
parseWithSchema,
projectSeedListSchema,
shareSeedListSchema,
} from './schema';
export type ProjectDownload = {
name: string;
description?: string;
url?: string;
type?: 'build' | 'demo' | 'document' | 'asset';
platform?: string;
size?: number;
updated_at?: string;
source?: 'seed' | 'seafile';
};
type ProjectSeed = {
name: string;
description: string;
gitea_repo: string;
cover_image: string;
demo_video?: string;
download_link?: string;
downloads?: ProjectDownload[];
tags: string[];
featured: boolean;
};
export type ProjectData = ProjectSeed & {
repo_url?: string;
updated_at?: string;
source?: 'seed' | 'gitea' | 'gitea+seafile';
};
type ShareSeed = {
name: string;
description?: string;
url?: string;
time?: string;
};
export type ShareData = ShareSeed & {
type?: 'build' | 'demo' | 'document' | 'asset';
project?: string;
project_repo?: string;
platform?: string;
size?: number;
source?: 'seed' | 'seafile';
updated_at?: string;
};
export type ActivityDay = {
date: string;
count: number;
};
export type RecentActivity = {
type: string;
repo: string;
message: string;
url: string;
time: string;
};
export type GiteaActivityData = {
updatedAt: string;
source: 'placeholder' | 'gitea';
itemCount: number;
days: ActivityDay[];
recent: RecentActivity[];
};
export type { GiteaActivityData, ProjectData, ProjectDownload, ShareData };
const rootDir = process.cwd();
const generatedProjectsPath = path.join(rootDir, 'src/data/generated/projects.json');
@@ -86,34 +33,71 @@ const emptyActivity: GiteaActivityData = {
};
export async function loadProjects(): Promise<ProjectData[]> {
return readJsonWithFallback<ProjectData[]>(generatedProjectsPath, seedProjectsPath);
return readJsonWithFallback<ProjectData[]>({
preferredPath: generatedProjectsPath,
fallbackPath: seedProjectsPath,
preferredLabel: 'generated projects',
fallbackLabel: 'seed projects',
preferredSchema: generatedProjectListSchema,
fallbackSchema: projectSeedListSchema,
});
}
export async function loadShares(): Promise<ShareData[]> {
return readJsonWithFallback<ShareData[]>(generatedSharesPath, seedSharesPath);
return readJsonWithFallback<ShareData[]>({
preferredPath: generatedSharesPath,
fallbackPath: seedSharesPath,
preferredLabel: 'generated shares',
fallbackLabel: 'seed shares',
preferredSchema: generatedShareListSchema,
fallbackSchema: shareSeedListSchema,
});
}
export async function loadGiteaActivity(): Promise<GiteaActivityData> {
try {
return await readJson<GiteaActivityData>(generatedActivityPath);
return parseWithSchema(
giteaActivitySchema,
await readJson<unknown>(generatedActivityPath),
'generated gitea activity',
);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
console.warn(
`[data-loaders] failed to load generated gitea activity, using empty placeholder: ${getErrorMessage(error)}`,
);
}
return emptyActivity;
}
}
async function readJsonWithFallback<T>(preferredPath: string, fallbackPath: string): Promise<T> {
async function readJsonWithFallback<T>(input: {
preferredPath: string;
fallbackPath: string;
preferredLabel: string;
fallbackLabel: string;
preferredSchema: z.ZodType<T>;
fallbackSchema: z.ZodType<T>;
}): Promise<T> {
try {
return await readJson<T>(preferredPath);
return parseWithSchema(
input.preferredSchema,
await readJson<unknown>(input.preferredPath),
input.preferredLabel,
);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
console.warn(
`[data-loaders] failed to load ${input.preferredLabel}, falling back to ${input.fallbackLabel}: ${getErrorMessage(error)}`,
);
}
return readJson<T>(fallbackPath);
return parseWithSchema(
input.fallbackSchema,
await readJson<unknown>(input.fallbackPath),
input.fallbackLabel,
);
}
}
@@ -123,10 +107,9 @@ async function readJson<T>(filePath: string): Promise<T> {
}
function isMissingFileError(error: unknown) {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
);
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+138
View File
@@ -0,0 +1,138 @@
import { z } from 'zod';
const nonEmptyText = z.string().trim().min(1);
const looseText = z.string().trim();
const parseableDateTime = z
.string()
.trim()
.refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Expected a parseable date/time string',
});
const isoDay = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, {
message: 'Expected a YYYY-MM-DD date string',
});
const repoFullName = z.string().trim().regex(/^[^/\s]+\/[^/\s]+$/, {
message: 'Expected an owner/repo string',
});
export const seafileResourceTypeSchema = z.enum(['build', 'demo', 'document', 'asset']);
export const projectDownloadSchema = z.object({
name: nonEmptyText,
description: looseText.optional(),
url: looseText.optional(),
type: seafileResourceTypeSchema.optional(),
platform: looseText.optional(),
size: z.number().int().nonnegative().optional(),
updated_at: parseableDateTime.optional(),
source: z.enum(['seed', 'seafile']).optional(),
});
export const projectDataSchema = z.object({
name: nonEmptyText,
description: nonEmptyText,
gitea_repo: repoFullName,
cover_image: looseText,
demo_video: looseText.optional(),
download_link: looseText.optional(),
downloads: z.array(projectDownloadSchema).optional(),
tags: z.array(nonEmptyText),
featured: z.boolean(),
repo_url: looseText.optional(),
updated_at: parseableDateTime.optional(),
source: z.enum(['seed', 'gitea', 'gitea+seafile']).optional(),
});
export const generatedProjectSchema = projectDataSchema.extend({
updated_at: parseableDateTime,
source: z.enum(['seed', 'gitea', 'gitea+seafile']),
});
export const shareDataSchema = z.object({
name: nonEmptyText,
description: looseText.optional(),
url: looseText.optional(),
type: seafileResourceTypeSchema.optional(),
project: looseText.optional(),
project_repo: looseText.optional(),
platform: looseText.optional(),
size: z.number().int().nonnegative().optional(),
updated_at: parseableDateTime.optional(),
time: parseableDateTime.optional(),
source: z.enum(['seed', 'seafile']).optional(),
});
export const generatedShareSchema = shareDataSchema.extend({
source: z.enum(['seed', 'seafile']),
});
export const activityDaySchema = z.object({
date: isoDay,
count: z.number().int().nonnegative(),
});
export const recentActivitySchema = z.object({
type: nonEmptyText,
repo: nonEmptyText,
message: nonEmptyText,
url: nonEmptyText,
time: parseableDateTime,
});
export const giteaActivitySchema = z.object({
updatedAt: parseableDateTime,
source: z.enum(['placeholder', 'gitea']),
itemCount: z.number().int().nonnegative(),
days: z.array(activityDaySchema),
recent: z.array(recentActivitySchema),
});
export const projectSeedListSchema = z.array(projectDataSchema);
export const generatedProjectListSchema = z.array(generatedProjectSchema);
export const shareSeedListSchema = z.array(shareDataSchema);
export const generatedShareListSchema = z.array(generatedShareSchema);
export type ProjectDownload = z.infer<typeof projectDownloadSchema>;
export type ProjectData = z.infer<typeof projectDataSchema>;
export type GeneratedProject = z.infer<typeof generatedProjectSchema>;
export type ShareData = z.infer<typeof shareDataSchema>;
export type GeneratedShare = z.infer<typeof generatedShareSchema>;
export type ActivityDay = z.infer<typeof activityDaySchema>;
export type RecentActivity = z.infer<typeof recentActivitySchema>;
export type GiteaActivityData = z.infer<typeof giteaActivitySchema>;
export function parseWithSchema<T>(schema: z.ZodType<T>, input: unknown, label: string): T {
const result = schema.safeParse(input);
if (result.success) {
return result.data;
}
throw new Error(formatSchemaError(label, result.error));
}
export function formatSchemaError(label: string, error: z.ZodError) {
const issues = error.issues
.slice(0, 8)
.map((issue) => `- ${label}${formatIssuePath(issue.path)}: ${issue.message}`);
const suffix = error.issues.length > issues.length ? '\n- ...' : '';
return ['[data-schema] validation failed', ...issues].join('\n') + suffix;
}
function formatIssuePath(path: (string | number)[]) {
if (path.length === 0) {
return '';
}
const formattedPath = path.reduce((output, segment) => {
if (typeof segment === 'number') {
return `${output}[${segment}]`;
}
return output ? `${output}.${segment}` : segment;
}, '');
return `.${formattedPath}`;
}