personal-homepage/scripts/sync-content.ts

236 lines
5.9 KiB
TypeScript

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
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;
};
type GeneratedProject = ProjectSeed & {
repo_url?: string;
updated_at: string;
source: 'seed' | 'gitea' | 'gitea+seafile';
};
type GeneratedShare = ShareSeed & {
size?: number;
source: 'seed' | 'seafile';
updated_at: string;
};
type ActivityDay = {
date: string;
count: number;
};
type RecentActivity = {
type: string;
repo: string;
message: string;
url: string;
time: string;
};
type GeneratedGiteaActivity = {
updatedAt: string;
source: 'placeholder' | 'gitea';
itemCount: number;
days: ActivityDay[];
recent: RecentActivity[];
};
type SyncConfig = {
rootDir: string;
outputDir: string;
strictSync: boolean;
gitea: {
baseUrl: string;
token: string;
username: string;
};
seafile: {
baseUrl: string;
token: string;
mirrorDownloads: boolean;
downloadsOutputDir: string;
};
};
async function main() {
const config = loadConfig(process.cwd());
const syncedAt = new Date().toISOString();
console.log('[sync-content] start');
console.log(`[sync-content] output dir: ${config.outputDir}`);
await mkdir(config.outputDir, { recursive: true });
if (config.seafile.mirrorDownloads) {
console.warn(
'[sync-content] SEAFILE_MIRROR_DOWNLOADS=true, but file mirroring is not implemented yet.',
);
}
const [projects, shares, giteaActivity] = await Promise.all([
resolveProjects(config, syncedAt),
resolveShares(config, syncedAt),
resolveGiteaActivity(config, syncedAt),
]);
await Promise.all([
writeJson(path.join(config.outputDir, 'projects.json'), projects),
writeJson(path.join(config.outputDir, 'shares.json'), shares),
writeJson(path.join(config.outputDir, 'gitea-activity.json'), giteaActivity),
]);
console.log(`[sync-content] wrote ${projects.length} projects`);
console.log(`[sync-content] wrote ${shares.length} shares`);
console.log(`[sync-content] wrote ${giteaActivity.itemCount} activity items`);
console.log('[sync-content] done');
}
function loadConfig(rootDir: string): SyncConfig {
return {
rootDir,
outputDir: resolveFromRoot(rootDir, process.env.SYNC_OUTPUT_DIR ?? 'src/data/generated'),
strictSync: getBooleanEnv('STRICT_SYNC', false),
gitea: {
baseUrl: process.env.GITEA_BASE_URL?.trim() ?? '',
token: process.env.GITEA_TOKEN?.trim() ?? '',
username: process.env.GITEA_USERNAME?.trim() ?? '',
},
seafile: {
baseUrl: process.env.SEAFILE_BASE_URL?.trim() ?? '',
token: process.env.SEAFILE_TOKEN?.trim() ?? '',
mirrorDownloads: getBooleanEnv('SEAFILE_MIRROR_DOWNLOADS', false),
downloadsOutputDir: resolveFromRoot(
rootDir,
process.env.DOWNLOADS_OUTPUT_DIR ?? 'public/downloads',
),
},
};
}
async function resolveProjects(
config: SyncConfig,
syncedAt: string,
): Promise<GeneratedProject[]> {
const seedProjects = await readJson<ProjectSeed[]>(
path.join(config.rootDir, 'src/content/projects/index.json'),
);
if (hasGiteaConfig(config) || hasSeafileConfig(config)) {
console.warn(
'[sync-content] remote project sync not implemented yet, falling back to local seed data.',
);
}
return seedProjects.map((project) => ({
...project,
repo_url:
project.gitea_repo && config.gitea.baseUrl
? `${trimTrailingSlash(config.gitea.baseUrl)}/${project.gitea_repo}`
: undefined,
updated_at: syncedAt,
source: 'seed',
}));
}
async function resolveShares(config: SyncConfig, syncedAt: string): Promise<GeneratedShare[]> {
const seedShares = await readJson<ShareSeed[]>(
path.join(config.rootDir, 'src/content/shares/index.json'),
);
if (hasSeafileConfig(config)) {
console.warn(
'[sync-content] remote share sync not implemented yet, falling back to local seed data.',
);
}
return seedShares.map((share) => ({
...share,
source: 'seed',
updated_at: syncedAt,
}));
}
async function resolveGiteaActivity(
config: SyncConfig,
syncedAt: string,
): Promise<GeneratedGiteaActivity> {
if (hasGiteaConfig(config)) {
if (config.strictSync) {
throw new Error(
'STRICT_SYNC=true, but remote Gitea activity sync is not implemented yet.',
);
}
console.warn(
'[sync-content] remote Gitea activity sync not implemented yet, writing placeholder output.',
);
}
return {
updatedAt: syncedAt,
source: 'placeholder',
itemCount: 0,
days: [],
recent: [],
};
}
function hasGiteaConfig(config: SyncConfig) {
return Boolean(config.gitea.baseUrl && config.gitea.token && config.gitea.username);
}
function hasSeafileConfig(config: SyncConfig) {
return Boolean(config.seafile.baseUrl && config.seafile.token);
}
function getBooleanEnv(name: string, fallback: boolean) {
const value = process.env[name];
if (value == null || value.trim() === '') {
return fallback;
}
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
function resolveFromRoot(rootDir: string, targetPath: string) {
return path.isAbsolute(targetPath) ? targetPath : path.join(rootDir, targetPath);
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, '');
}
async function readJson<T>(filePath: string): Promise<T> {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as T;
}
async function writeJson(filePath: string, data: unknown) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
}
main().catch((error) => {
console.error('[sync-content] failed');
console.error(error);
process.exitCode = 1;
});