Add schema validation and sync metadata UI
This commit is contained in:
parent
e03af0c800
commit
85e510fbc0
10
TODO.md
10
TODO.md
|
|
@ -66,17 +66,17 @@
|
|||
|
||||
### 1. 给 JSON 数据加校验
|
||||
|
||||
- [ ] 为 projects 数据建立 schema 校验
|
||||
- [ ] 为 shares 数据建立 schema 校验
|
||||
- [ ] 为 gitea activity 数据建立 schema 校验
|
||||
- [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
|
||||
- [x] 为 projects 数据建立 schema 校验
|
||||
- [x] 为 shares 数据建立 schema 校验
|
||||
- [x] 为 gitea activity 数据建立 schema 校验
|
||||
- [x] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
|
||||
|
||||
### 2. 增加同步元数据展示
|
||||
|
||||
- [x] 生成 `updatedAt`
|
||||
- [x] 生成 `source`
|
||||
- [x] 生成 `itemCount`(activity)
|
||||
- [ ] 前端更明确地显示“数据更新时间”
|
||||
- [x] 前端更明确地显示“数据更新时间”
|
||||
|
||||
### 3. 增加变更检测
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
"name": "personal-homepage",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"astro": "^6.2.1"
|
||||
"astro": "^6.2.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
|
|
@ -4720,9 +4721,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
|
||||
"integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^6.2.1"
|
||||
"astro": "^6.2.1",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,16 @@
|
|||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fetchGiteaSnapshot, type GeneratedGiteaActivity, type GeneratedProject } from './fetch-gitea.ts';
|
||||
import { fetchSeafileSnapshot, type GeneratedShare } from './fetch-seafile.ts';
|
||||
|
||||
type ProjectSeed = {
|
||||
name: string;
|
||||
description: string;
|
||||
gitea_repo: string;
|
||||
cover_image: string;
|
||||
demo_video?: string;
|
||||
download_link?: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
};
|
||||
|
||||
type ShareSeed = {
|
||||
name: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
time?: string;
|
||||
};
|
||||
import { fetchGiteaSnapshot, type GeneratedGiteaActivity } from './fetch-gitea.ts';
|
||||
import { fetchSeafileSnapshot } from './fetch-seafile.ts';
|
||||
import {
|
||||
generatedProjectListSchema,
|
||||
generatedShareListSchema,
|
||||
giteaActivitySchema,
|
||||
parseWithSchema,
|
||||
projectSeedListSchema,
|
||||
shareSeedListSchema,
|
||||
} from '../src/data/schema.ts';
|
||||
|
||||
type SyncConfig = {
|
||||
rootDir: string;
|
||||
|
|
@ -60,11 +50,15 @@ async function main() {
|
|||
);
|
||||
}
|
||||
|
||||
const seedProjects = await readJson<ProjectSeed[]>(
|
||||
path.join(config.rootDir, 'src/content/projects/index.json'),
|
||||
const seedProjects = parseWithSchema(
|
||||
projectSeedListSchema,
|
||||
await readJson<unknown>(path.join(config.rootDir, 'src/content/projects/index.json')),
|
||||
'seed projects',
|
||||
);
|
||||
const seedShares = await readJson<ShareSeed[]>(
|
||||
path.join(config.rootDir, 'src/content/shares/index.json'),
|
||||
const seedShares = parseWithSchema(
|
||||
shareSeedListSchema,
|
||||
await readJson<unknown>(path.join(config.rootDir, 'src/content/shares/index.json')),
|
||||
'seed shares',
|
||||
);
|
||||
|
||||
const giteaSnapshot = hasGiteaConfig(config)
|
||||
|
|
@ -111,15 +105,31 @@ async function main() {
|
|||
recent: [],
|
||||
} satisfies GeneratedGiteaActivity);
|
||||
|
||||
const validatedProjects = parseWithSchema(
|
||||
generatedProjectListSchema,
|
||||
seafileSnapshot.projects,
|
||||
'generated projects',
|
||||
);
|
||||
const validatedShares = parseWithSchema(
|
||||
generatedShareListSchema,
|
||||
seafileSnapshot.shares,
|
||||
'generated shares',
|
||||
);
|
||||
const validatedActivity = parseWithSchema(
|
||||
giteaActivitySchema,
|
||||
giteaActivity,
|
||||
'generated gitea activity',
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
writeJson(path.join(config.outputDir, 'projects.json'), seafileSnapshot.projects),
|
||||
writeJson(path.join(config.outputDir, 'shares.json'), seafileSnapshot.shares),
|
||||
writeJson(path.join(config.outputDir, 'gitea-activity.json'), giteaActivity),
|
||||
writeJson(path.join(config.outputDir, 'projects.json'), validatedProjects),
|
||||
writeJson(path.join(config.outputDir, 'shares.json'), validatedShares),
|
||||
writeJson(path.join(config.outputDir, 'gitea-activity.json'), validatedActivity),
|
||||
]);
|
||||
|
||||
console.log(`[sync-content] wrote ${seafileSnapshot.projects.length} projects`);
|
||||
console.log(`[sync-content] wrote ${seafileSnapshot.shares.length} shares`);
|
||||
console.log(`[sync-content] wrote ${giteaActivity.itemCount} activity items`);
|
||||
console.log(`[sync-content] wrote ${validatedProjects.length} projects`);
|
||||
console.log(`[sync-content] wrote ${validatedShares.length} shares`);
|
||||
console.log(`[sync-content] wrote ${validatedActivity.itemCount} activity items`);
|
||||
console.log('[sync-content] done');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import type { GiteaActivityData } from '../data/loaders';
|
||||
import { site } from '../config';
|
||||
import SyncMeta from './SyncMeta.astro';
|
||||
|
||||
interface Props {
|
||||
activity: GiteaActivityData;
|
||||
|
|
@ -26,9 +27,6 @@ const heatmap = Array.from({ length: heatmapSize }, (_, index) => {
|
|||
});
|
||||
|
||||
const recentActivities = activity.recent.slice(0, 3);
|
||||
const updatedLabel = activity.updatedAt
|
||||
? new Date(activity.updatedAt).toLocaleString('zh-CN', { hour12: false })
|
||||
: '尚未同步';
|
||||
const isPlaceholder = activity.source === 'placeholder';
|
||||
---
|
||||
|
||||
|
|
@ -50,7 +48,13 @@ const isPlaceholder = activity.source === 'placeholder';
|
|||
|
||||
<div class="activity-grid">
|
||||
<div>
|
||||
<p class="activity-copy">最近同步:{updatedLabel}</p>
|
||||
<SyncMeta
|
||||
label="同步状态"
|
||||
updatedAt={activity.updatedAt}
|
||||
source={activity.source}
|
||||
itemCount={activity.itemCount}
|
||||
variant="dark"
|
||||
/>
|
||||
<div class="heatmap" aria-label="Gitea 贡献热力图">
|
||||
{
|
||||
heatmap.map((level) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { site } from '../config';
|
||||
import type { ProjectDownload } from '../data/loaders';
|
||||
import { formatDateTime } from '../utils/datetime';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
|
|
@ -13,6 +14,8 @@ interface Props {
|
|||
demoVideo?: string;
|
||||
downloadLink?: string;
|
||||
downloads?: ProjectDownload[];
|
||||
updatedAt?: string;
|
||||
source?: 'seed' | 'gitea' | 'gitea+seafile';
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -26,11 +29,19 @@ const {
|
|||
demoVideo = '',
|
||||
downloadLink = '',
|
||||
downloads = [],
|
||||
updatedAt = '',
|
||||
source,
|
||||
} = Astro.props;
|
||||
|
||||
const fallbackRepoUrl = repo ? `${site.gitea.url}/${repo}` : '';
|
||||
const resolvedRepoUrl = repoUrl || fallbackRepoUrl;
|
||||
const visibleDownloads = downloads.filter((item) => item.name || item.url);
|
||||
const sourceLabels = {
|
||||
seed: '本地种子',
|
||||
gitea: 'Gitea',
|
||||
'gitea+seafile': 'Gitea + Seafile',
|
||||
} as const;
|
||||
const updatedLabel = formatDateTime(updatedAt);
|
||||
---
|
||||
|
||||
<article class="card">
|
||||
|
|
@ -41,6 +52,8 @@ const visibleDownloads = downloads.filter((item) => item.name || item.url);
|
|||
<div class="project-meta">
|
||||
<span class="mono">{repo}</span>
|
||||
{featured ? <span class="tag">featured</span> : null}
|
||||
{source ? <span class="tag">{sourceLabels[source]}</span> : null}
|
||||
{updatedAt ? <span class="mono">更新于 {updatedLabel}</span> : null}
|
||||
</div>
|
||||
|
||||
<h3 class="project-title">{name}</h3>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
import { formatDateTime } from '../utils/datetime';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
updatedAt?: string;
|
||||
source?: string | string[];
|
||||
itemCount?: number;
|
||||
variant?: 'default' | 'dark';
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
updatedAt,
|
||||
source,
|
||||
itemCount,
|
||||
variant = 'default',
|
||||
} = Astro.props;
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
seed: '本地种子',
|
||||
gitea: 'Gitea',
|
||||
seafile: 'Seafile',
|
||||
'gitea+seafile': 'Gitea + Seafile',
|
||||
placeholder: '占位数据',
|
||||
};
|
||||
|
||||
const normalizedSources = Array.isArray(source)
|
||||
? source
|
||||
: source
|
||||
? [source]
|
||||
: [];
|
||||
|
||||
const sourceText = [...new Set(normalizedSources)]
|
||||
.map((item) => sourceLabels[item] ?? item)
|
||||
.join(' / ');
|
||||
|
||||
const updatedLabel = formatDateTime(updatedAt);
|
||||
const normalizedUpdatedLabel = updatedLabel || '未同步';
|
||||
---
|
||||
|
||||
<div class:list={['sync-meta', variant === 'dark' && 'sync-meta--dark']}>
|
||||
<span class="sync-meta__label">{label}</span>
|
||||
<div class="sync-meta__items">
|
||||
<span class="sync-meta__item">
|
||||
<strong>更新时间</strong>
|
||||
<span>{normalizedUpdatedLabel}</span>
|
||||
</span>
|
||||
|
||||
{sourceText ? (
|
||||
<span class="sync-meta__item">
|
||||
<strong>来源</strong>
|
||||
<span>{sourceText}</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{typeof itemCount === 'number' ? (
|
||||
<span class="sync-meta__item">
|
||||
<strong>条目数</strong>
|
||||
<span>{itemCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -5,9 +5,11 @@ import LogCard from '../components/LogCard.astro';
|
|||
import ProjectCard from '../components/ProjectCard.astro';
|
||||
import RandomCornerImage from '../components/RandomCornerImage.astro';
|
||||
import SectionTitle from '../components/SectionTitle.astro';
|
||||
import SyncMeta from '../components/SyncMeta.astro';
|
||||
import { site } from '../config';
|
||||
import { loadGiteaActivity, loadProjects } from '../data/loaders';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { getLatestDate } from '../utils/datetime';
|
||||
|
||||
const [logs, projects, activity] = await Promise.all([
|
||||
getCollection('logs'),
|
||||
|
|
@ -20,6 +22,10 @@ const latestLogs = sortedLogs.slice(0, 3);
|
|||
const featuredProjects = projects.filter((project) => project.featured).slice(0, 3);
|
||||
const latestLog = latestLogs[0];
|
||||
const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?? `${site.gitea.username}/personal-homepage`;
|
||||
const featuredProjectSources = [...new Set(featuredProjects.map((project) => project.source).filter(Boolean))];
|
||||
const featuredProjectsUpdatedAt = getLatestDate(
|
||||
featuredProjects.map((project) => project.updated_at).filter(Boolean),
|
||||
);
|
||||
---
|
||||
|
||||
<Layout title={`${site.name} | ${site.title}`} description={site.description} currentPath="/">
|
||||
|
|
@ -101,6 +107,13 @@ const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?
|
|||
linkLabel="打开项目列表"
|
||||
/>
|
||||
|
||||
<SyncMeta
|
||||
label="项目数据"
|
||||
updatedAt={featuredProjectsUpdatedAt}
|
||||
source={featuredProjectSources}
|
||||
itemCount={featuredProjects.length}
|
||||
/>
|
||||
|
||||
<div class="card-grid">
|
||||
{
|
||||
featuredProjects.map((project) => (
|
||||
|
|
@ -115,6 +128,8 @@ const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?
|
|||
demoVideo={project.demo_video}
|
||||
downloadLink={project.download_link}
|
||||
downloads={project.downloads}
|
||||
updatedAt={project.updated_at}
|
||||
source={project.source}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
---
|
||||
import ProjectCard from '../../components/ProjectCard.astro';
|
||||
import SyncMeta from '../../components/SyncMeta.astro';
|
||||
import { site } from '../../config';
|
||||
import { loadProjects } from '../../data/loaders';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import { getLatestDate } from '../../utils/datetime';
|
||||
|
||||
const projects = await loadProjects();
|
||||
const projectSources = [...new Set(projects.map((project) => project.source).filter(Boolean))];
|
||||
const projectsUpdatedAt = getLatestDate(projects.map((project) => project.updated_at).filter(Boolean));
|
||||
---
|
||||
|
||||
<Layout
|
||||
|
|
@ -20,6 +24,13 @@ const projects = await loadProjects();
|
|||
<p>项目页现在优先读取构建阶段生成的数据;如果尚未同步,则回退到仓库里的种子数据。</p>
|
||||
</div>
|
||||
|
||||
<SyncMeta
|
||||
label="项目数据状态"
|
||||
updatedAt={projectsUpdatedAt}
|
||||
source={projectSources}
|
||||
itemCount={projects.length}
|
||||
/>
|
||||
|
||||
<div class="card-grid">
|
||||
{
|
||||
projects.map((project) => (
|
||||
|
|
@ -34,6 +45,8 @@ const projects = await loadProjects();
|
|||
demoVideo={project.demo_video}
|
||||
downloadLink={project.download_link}
|
||||
downloads={project.downloads}
|
||||
updatedAt={project.updated_at}
|
||||
source={project.source}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,18 @@
|
|||
import { site } from '../config';
|
||||
import { loadShares } from '../data/loaders';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import SyncMeta from '../components/SyncMeta.astro';
|
||||
import { formatDateTime, getLatestDate } from '../utils/datetime';
|
||||
|
||||
const shares = await loadShares();
|
||||
const shareSources = [...new Set(shares.map((share) => share.source).filter(Boolean))];
|
||||
const sharesUpdatedAt = getLatestDate(
|
||||
shares.map((share) => share.updated_at || share.time).filter(Boolean),
|
||||
);
|
||||
const sourceLabels = {
|
||||
seed: '本地种子',
|
||||
seafile: 'Seafile',
|
||||
} as const;
|
||||
---
|
||||
|
||||
<Layout
|
||||
|
|
@ -19,13 +29,21 @@ const shares = await loadShares();
|
|||
<p>这里优先展示构建时从 Seafile 同步生成的分享数据;当前未接入时会先回退到静态种子数据。</p>
|
||||
</div>
|
||||
|
||||
<SyncMeta
|
||||
label="分享数据状态"
|
||||
updatedAt={sharesUpdatedAt}
|
||||
source={shareSources}
|
||||
itemCount={shares.length}
|
||||
/>
|
||||
|
||||
<div class="card-grid" style="grid-template-columns: 1fr;">
|
||||
{shares.map((share) => (
|
||||
<article class="card">
|
||||
<p class="log-meta">
|
||||
<span class="mono">{share.updated_at || share.time || '未标注时间'}</span>
|
||||
<span class="mono">{formatDateTime(share.updated_at || share.time) || '未标注时间'}</span>
|
||||
{share.project ? <span class="tag">{share.project}</span> : null}
|
||||
{share.type ? <span class="tag tag--tech">{share.type}</span> : null}
|
||||
{share.source ? <span class="tag">{sourceLabels[share.source]}</span> : null}
|
||||
</p>
|
||||
<h2 class="project-title">{share.name}</h2>
|
||||
{share.description ? <p>{share.description}</p> : null}
|
||||
|
|
|
|||
|
|
@ -342,6 +342,66 @@ main {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sync-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.8rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.sync-meta--dark {
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.sync-meta__label {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-size: 0.96rem;
|
||||
color: var(--muted-strong);
|
||||
}
|
||||
|
||||
.sync-meta--dark .sync-meta__label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sync-meta__items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.sync-meta__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--muted-strong);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sync-meta__item strong {
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sync-meta--dark .sync-meta__item {
|
||||
background: rgba(30, 41, 59, 0.88);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.sync-meta--dark .sync-meta__item strong {
|
||||
color: rgba(191, 219, 254, 0.9);
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
export function formatDateTime(value?: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const time = Date.parse(value);
|
||||
if (Number.isNaN(time)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(time));
|
||||
}
|
||||
|
||||
export function getLatestDate(values: string[]) {
|
||||
const timestamps = values.map((value) => Date.parse(value)).filter((value) => !Number.isNaN(value));
|
||||
if (timestamps.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Date(Math.max(...timestamps)).toISOString();
|
||||
}
|
||||
Loading…
Reference in New Issue