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 { const seedProjects = await readJson( 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 { const seedShares = await readJson( 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 { 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(filePath: string): Promise { 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; });