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 数据加校验
|
### 1. 给 JSON 数据加校验
|
||||||
|
|
||||||
- [ ] 为 projects 数据建立 schema 校验
|
- [x] 为 projects 数据建立 schema 校验
|
||||||
- [ ] 为 shares 数据建立 schema 校验
|
- [x] 为 shares 数据建立 schema 校验
|
||||||
- [ ] 为 gitea activity 数据建立 schema 校验
|
- [x] 为 gitea activity 数据建立 schema 校验
|
||||||
- [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
|
- [x] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
|
||||||
|
|
||||||
### 2. 增加同步元数据展示
|
### 2. 增加同步元数据展示
|
||||||
|
|
||||||
- [x] 生成 `updatedAt`
|
- [x] 生成 `updatedAt`
|
||||||
- [x] 生成 `source`
|
- [x] 生成 `source`
|
||||||
- [x] 生成 `itemCount`(activity)
|
- [x] 生成 `itemCount`(activity)
|
||||||
- [ ] 前端更明确地显示“数据更新时间”
|
- [x] 前端更明确地显示“数据更新时间”
|
||||||
|
|
||||||
### 3. 增加变更检测
|
### 3. 增加变更检测
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"name": "personal-homepage",
|
"name": "personal-homepage",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^6.2.1"
|
"astro": "^6.2.1",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
|
|
@ -4720,9 +4721,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.2",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
|
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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 { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fetchGiteaSnapshot, type GeneratedGiteaActivity, type GeneratedProject } from './fetch-gitea.ts';
|
import { fetchGiteaSnapshot, type GeneratedGiteaActivity } from './fetch-gitea.ts';
|
||||||
import { fetchSeafileSnapshot, type GeneratedShare } from './fetch-seafile.ts';
|
import { fetchSeafileSnapshot } from './fetch-seafile.ts';
|
||||||
|
import {
|
||||||
type ProjectSeed = {
|
generatedProjectListSchema,
|
||||||
name: string;
|
generatedShareListSchema,
|
||||||
description: string;
|
giteaActivitySchema,
|
||||||
gitea_repo: string;
|
parseWithSchema,
|
||||||
cover_image: string;
|
projectSeedListSchema,
|
||||||
demo_video?: string;
|
shareSeedListSchema,
|
||||||
download_link?: string;
|
} from '../src/data/schema.ts';
|
||||||
tags: string[];
|
|
||||||
featured: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShareSeed = {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
url?: string;
|
|
||||||
time?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SyncConfig = {
|
type SyncConfig = {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
|
|
@ -60,11 +50,15 @@ async function main() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedProjects = await readJson<ProjectSeed[]>(
|
const seedProjects = parseWithSchema(
|
||||||
path.join(config.rootDir, 'src/content/projects/index.json'),
|
projectSeedListSchema,
|
||||||
|
await readJson<unknown>(path.join(config.rootDir, 'src/content/projects/index.json')),
|
||||||
|
'seed projects',
|
||||||
);
|
);
|
||||||
const seedShares = await readJson<ShareSeed[]>(
|
const seedShares = parseWithSchema(
|
||||||
path.join(config.rootDir, 'src/content/shares/index.json'),
|
shareSeedListSchema,
|
||||||
|
await readJson<unknown>(path.join(config.rootDir, 'src/content/shares/index.json')),
|
||||||
|
'seed shares',
|
||||||
);
|
);
|
||||||
|
|
||||||
const giteaSnapshot = hasGiteaConfig(config)
|
const giteaSnapshot = hasGiteaConfig(config)
|
||||||
|
|
@ -111,15 +105,31 @@ async function main() {
|
||||||
recent: [],
|
recent: [],
|
||||||
} satisfies GeneratedGiteaActivity);
|
} 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([
|
await Promise.all([
|
||||||
writeJson(path.join(config.outputDir, 'projects.json'), seafileSnapshot.projects),
|
writeJson(path.join(config.outputDir, 'projects.json'), validatedProjects),
|
||||||
writeJson(path.join(config.outputDir, 'shares.json'), seafileSnapshot.shares),
|
writeJson(path.join(config.outputDir, 'shares.json'), validatedShares),
|
||||||
writeJson(path.join(config.outputDir, 'gitea-activity.json'), giteaActivity),
|
writeJson(path.join(config.outputDir, 'gitea-activity.json'), validatedActivity),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log(`[sync-content] wrote ${seafileSnapshot.projects.length} projects`);
|
console.log(`[sync-content] wrote ${validatedProjects.length} projects`);
|
||||||
console.log(`[sync-content] wrote ${seafileSnapshot.shares.length} shares`);
|
console.log(`[sync-content] wrote ${validatedShares.length} shares`);
|
||||||
console.log(`[sync-content] wrote ${giteaActivity.itemCount} activity items`);
|
console.log(`[sync-content] wrote ${validatedActivity.itemCount} activity items`);
|
||||||
console.log('[sync-content] done');
|
console.log('[sync-content] done');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
import type { GiteaActivityData } from '../data/loaders';
|
import type { GiteaActivityData } from '../data/loaders';
|
||||||
import { site } from '../config';
|
import { site } from '../config';
|
||||||
|
import SyncMeta from './SyncMeta.astro';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activity: GiteaActivityData;
|
activity: GiteaActivityData;
|
||||||
|
|
@ -26,9 +27,6 @@ const heatmap = Array.from({ length: heatmapSize }, (_, index) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentActivities = activity.recent.slice(0, 3);
|
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';
|
const isPlaceholder = activity.source === 'placeholder';
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -50,7 +48,13 @@ const isPlaceholder = activity.source === 'placeholder';
|
||||||
|
|
||||||
<div class="activity-grid">
|
<div class="activity-grid">
|
||||||
<div>
|
<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 贡献热力图">
|
<div class="heatmap" aria-label="Gitea 贡献热力图">
|
||||||
{
|
{
|
||||||
heatmap.map((level) => (
|
heatmap.map((level) => (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
import { site } from '../config';
|
import { site } from '../config';
|
||||||
import type { ProjectDownload } from '../data/loaders';
|
import type { ProjectDownload } from '../data/loaders';
|
||||||
|
import { formatDateTime } from '../utils/datetime';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -13,6 +14,8 @@ interface Props {
|
||||||
demoVideo?: string;
|
demoVideo?: string;
|
||||||
downloadLink?: string;
|
downloadLink?: string;
|
||||||
downloads?: ProjectDownload[];
|
downloads?: ProjectDownload[];
|
||||||
|
updatedAt?: string;
|
||||||
|
source?: 'seed' | 'gitea' | 'gitea+seafile';
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -26,11 +29,19 @@ const {
|
||||||
demoVideo = '',
|
demoVideo = '',
|
||||||
downloadLink = '',
|
downloadLink = '',
|
||||||
downloads = [],
|
downloads = [],
|
||||||
|
updatedAt = '',
|
||||||
|
source,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const fallbackRepoUrl = repo ? `${site.gitea.url}/${repo}` : '';
|
const fallbackRepoUrl = repo ? `${site.gitea.url}/${repo}` : '';
|
||||||
const resolvedRepoUrl = repoUrl || fallbackRepoUrl;
|
const resolvedRepoUrl = repoUrl || fallbackRepoUrl;
|
||||||
const visibleDownloads = downloads.filter((item) => item.name || item.url);
|
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">
|
<article class="card">
|
||||||
|
|
@ -41,6 +52,8 @@ const visibleDownloads = downloads.filter((item) => item.name || item.url);
|
||||||
<div class="project-meta">
|
<div class="project-meta">
|
||||||
<span class="mono">{repo}</span>
|
<span class="mono">{repo}</span>
|
||||||
{featured ? <span class="tag">featured</span> : null}
|
{featured ? <span class="tag">featured</span> : null}
|
||||||
|
{source ? <span class="tag">{sourceLabels[source]}</span> : null}
|
||||||
|
{updatedAt ? <span class="mono">更新于 {updatedLabel}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="project-title">{name}</h3>
|
<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 { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
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 = {
|
export type { GiteaActivityData, ProjectData, ProjectDownload, ShareData };
|
||||||
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[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const rootDir = process.cwd();
|
const rootDir = process.cwd();
|
||||||
const generatedProjectsPath = path.join(rootDir, 'src/data/generated/projects.json');
|
const generatedProjectsPath = path.join(rootDir, 'src/data/generated/projects.json');
|
||||||
|
|
@ -86,34 +33,71 @@ const emptyActivity: GiteaActivityData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadProjects(): Promise<ProjectData[]> {
|
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[]> {
|
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> {
|
export async function loadGiteaActivity(): Promise<GiteaActivityData> {
|
||||||
try {
|
try {
|
||||||
return await readJson<GiteaActivityData>(generatedActivityPath);
|
return parseWithSchema(
|
||||||
|
giteaActivitySchema,
|
||||||
|
await readJson<unknown>(generatedActivityPath),
|
||||||
|
'generated gitea activity',
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingFileError(error)) {
|
if (!isMissingFileError(error)) {
|
||||||
throw error;
|
console.warn(
|
||||||
|
`[data-loaders] failed to load generated gitea activity, using empty placeholder: ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return emptyActivity;
|
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 {
|
try {
|
||||||
return await readJson<T>(preferredPath);
|
return parseWithSchema(
|
||||||
|
input.preferredSchema,
|
||||||
|
await readJson<unknown>(input.preferredPath),
|
||||||
|
input.preferredLabel,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingFileError(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) {
|
function isMissingFileError(error: unknown) {
|
||||||
return (
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||||
typeof error === 'object' &&
|
}
|
||||||
error !== null &&
|
|
||||||
'code' in error &&
|
function getErrorMessage(error: unknown) {
|
||||||
error.code === 'ENOENT'
|
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 ProjectCard from '../components/ProjectCard.astro';
|
||||||
import RandomCornerImage from '../components/RandomCornerImage.astro';
|
import RandomCornerImage from '../components/RandomCornerImage.astro';
|
||||||
import SectionTitle from '../components/SectionTitle.astro';
|
import SectionTitle from '../components/SectionTitle.astro';
|
||||||
|
import SyncMeta from '../components/SyncMeta.astro';
|
||||||
import { site } from '../config';
|
import { site } from '../config';
|
||||||
import { loadGiteaActivity, loadProjects } from '../data/loaders';
|
import { loadGiteaActivity, loadProjects } from '../data/loaders';
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { getLatestDate } from '../utils/datetime';
|
||||||
|
|
||||||
const [logs, projects, activity] = await Promise.all([
|
const [logs, projects, activity] = await Promise.all([
|
||||||
getCollection('logs'),
|
getCollection('logs'),
|
||||||
|
|
@ -20,6 +22,10 @@ const latestLogs = sortedLogs.slice(0, 3);
|
||||||
const featuredProjects = projects.filter((project) => project.featured).slice(0, 3);
|
const featuredProjects = projects.filter((project) => project.featured).slice(0, 3);
|
||||||
const latestLog = latestLogs[0];
|
const latestLog = latestLogs[0];
|
||||||
const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?? `${site.gitea.username}/personal-homepage`;
|
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="/">
|
<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="打开项目列表"
|
linkLabel="打开项目列表"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SyncMeta
|
||||||
|
label="项目数据"
|
||||||
|
updatedAt={featuredProjectsUpdatedAt}
|
||||||
|
source={featuredProjectSources}
|
||||||
|
itemCount={featuredProjects.length}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
{
|
{
|
||||||
featuredProjects.map((project) => (
|
featuredProjects.map((project) => (
|
||||||
|
|
@ -115,6 +128,8 @@ const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?
|
||||||
demoVideo={project.demo_video}
|
demoVideo={project.demo_video}
|
||||||
downloadLink={project.download_link}
|
downloadLink={project.download_link}
|
||||||
downloads={project.downloads}
|
downloads={project.downloads}
|
||||||
|
updatedAt={project.updated_at}
|
||||||
|
source={project.source}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
---
|
---
|
||||||
import ProjectCard from '../../components/ProjectCard.astro';
|
import ProjectCard from '../../components/ProjectCard.astro';
|
||||||
|
import SyncMeta from '../../components/SyncMeta.astro';
|
||||||
import { site } from '../../config';
|
import { site } from '../../config';
|
||||||
import { loadProjects } from '../../data/loaders';
|
import { loadProjects } from '../../data/loaders';
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
import { getLatestDate } from '../../utils/datetime';
|
||||||
|
|
||||||
const projects = await loadProjects();
|
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
|
<Layout
|
||||||
|
|
@ -20,6 +24,13 @@ const projects = await loadProjects();
|
||||||
<p>项目页现在优先读取构建阶段生成的数据;如果尚未同步,则回退到仓库里的种子数据。</p>
|
<p>项目页现在优先读取构建阶段生成的数据;如果尚未同步,则回退到仓库里的种子数据。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SyncMeta
|
||||||
|
label="项目数据状态"
|
||||||
|
updatedAt={projectsUpdatedAt}
|
||||||
|
source={projectSources}
|
||||||
|
itemCount={projects.length}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
{
|
{
|
||||||
projects.map((project) => (
|
projects.map((project) => (
|
||||||
|
|
@ -34,6 +45,8 @@ const projects = await loadProjects();
|
||||||
demoVideo={project.demo_video}
|
demoVideo={project.demo_video}
|
||||||
downloadLink={project.download_link}
|
downloadLink={project.download_link}
|
||||||
downloads={project.downloads}
|
downloads={project.downloads}
|
||||||
|
updatedAt={project.updated_at}
|
||||||
|
source={project.source}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,18 @@
|
||||||
import { site } from '../config';
|
import { site } from '../config';
|
||||||
import { loadShares } from '../data/loaders';
|
import { loadShares } from '../data/loaders';
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import SyncMeta from '../components/SyncMeta.astro';
|
||||||
|
import { formatDateTime, getLatestDate } from '../utils/datetime';
|
||||||
|
|
||||||
const shares = await loadShares();
|
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
|
<Layout
|
||||||
|
|
@ -19,13 +29,21 @@ const shares = await loadShares();
|
||||||
<p>这里优先展示构建时从 Seafile 同步生成的分享数据;当前未接入时会先回退到静态种子数据。</p>
|
<p>这里优先展示构建时从 Seafile 同步生成的分享数据;当前未接入时会先回退到静态种子数据。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SyncMeta
|
||||||
|
label="分享数据状态"
|
||||||
|
updatedAt={sharesUpdatedAt}
|
||||||
|
source={shareSources}
|
||||||
|
itemCount={shares.length}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="card-grid" style="grid-template-columns: 1fr;">
|
<div class="card-grid" style="grid-template-columns: 1fr;">
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<p class="log-meta">
|
<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.project ? <span class="tag">{share.project}</span> : null}
|
||||||
{share.type ? <span class="tag tag--tech">{share.type}</span> : null}
|
{share.type ? <span class="tag tag--tech">{share.type}</span> : null}
|
||||||
|
{share.source ? <span class="tag">{sourceLabels[share.source]}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
<h2 class="project-title">{share.name}</h2>
|
<h2 class="project-title">{share.name}</h2>
|
||||||
{share.description ? <p>{share.description}</p> : null}
|
{share.description ? <p>{share.description}</p> : null}
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,66 @@ main {
|
||||||
font-weight: 600;
|
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 {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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