Add schema validation and sync metadata UI
This commit is contained in:
+64
-81
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user