feat: add build-time content sync scaffold

This commit is contained in:
SepComet 2026-05-05 09:04:11 +08:00
parent 1ab1717e64
commit d85c8ac85d
7 changed files with 420 additions and 53 deletions

40
.env.example Normal file
View File

@ -0,0 +1,40 @@
# -----------------------------------------------------------------------------
# Build-time content sync
# Copy this file to `.env` and fill in the real values in your build environment.
# Tokens must only exist in the server/build environment and must never be
# exposed to browser-side code.
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Gitea
# -----------------------------------------------------------------------------
GITEA_BASE_URL=https://gitea.sepcomet.xyz
GITEA_TOKEN=
GITEA_USERNAME=sepcomet
# -----------------------------------------------------------------------------
# Seafile
# -----------------------------------------------------------------------------
SEAFILE_BASE_URL=https://seafile.sepcomet.xyz
SEAFILE_TOKEN=
# -----------------------------------------------------------------------------
# Output
# Relative paths are resolved from the repo root.
# -----------------------------------------------------------------------------
SYNC_OUTPUT_DIR=src/data/generated
# -----------------------------------------------------------------------------
# Optional behavior
# When STRICT_SYNC=true, the sync step should fail loudly instead of silently
# falling back to cached/seed data.
# -----------------------------------------------------------------------------
STRICT_SYNC=false
# -----------------------------------------------------------------------------
# Optional future behavior
# First phase only needs metadata + links. If one day you want to mirror actual
# download files into the site, these variables can be enabled.
# -----------------------------------------------------------------------------
SEAFILE_MIRROR_DOWNLOADS=false
DOWNLOADS_OUTPUT_DIR=public/downloads

6
.gitignore vendored
View File

@ -36,4 +36,8 @@ Desktop.ini
*.pid *.pid
*.seed *.seed
public/images/gallery public/images/gallery
# generated sync data
src/data/generated/*.json
!src/data/generated/.gitkeep

View File

@ -2,9 +2,16 @@
## 1. 项目范围 ## 1. 项目范围
**只做 Web 应用本身。** AstrBot 插件、Gitea/Seafile API 接入属于独立项目 本项目是一个**公开静态站点 + 构建时数据同步层**
主页是一个**公开的静态站点**,通过 markdown/JSON 文件驱动内容(这些文件可由 AstrBot 等其他系统写入,但主页本身不关心来源)。 站点运行时仍然是纯静态页面,但在构建阶段由本项目主动向 Gitea / Seafile 拉取所需数据,清洗后生成本地只读数据文件,再交给 Astro 渲染。
也就是说:
- **运行时**:纯静态站点,对外公开访问
- **构建时**:服务端脚本拉取远程数据、生成内容文件
AstrBot、定时任务、手动脚本等都可以作为“触发构建”的外部入口但**同步逻辑本身属于这个仓库的一部分**。
- 域名:`sepcomet.xyz`(备案中) - 域名:`sepcomet.xyz`(备案中)
- 部署:自有服务器 Docker 环境 - 部署:自有服务器 Docker 环境
@ -28,25 +35,31 @@
- 每条日志卡片:标题、日期、标签、摘要 - 每条日志卡片:标题、日期、标签、摘要
- 点击进入日志详情页(/logs/[slug] - 点击进入日志详情页(/logs/[slug]
- 日志详情Markdown 全文渲染,关联的仓库名和 commit 链接 - 日志详情Markdown 全文渲染,关联的仓库名和 commit 链接
- 内容来自 `src/content/logs/` 下的 `.md` 文件(文件来源不在本范围内,由 AstrBot 或其他系统写入) - 内容来自 `src/content/logs/` 下的 `.md` 文件
- 首版允许日志继续由手动维护或外部系统写入;日志生成逻辑不要求在本项目内实现
### 2.3 项目展示(/projects ### 2.3 项目展示(/projects
- 项目卡片网格布局 - 项目卡片网格布局
- 每个项目卡片:名称、描述、封面图、标签 - 每个项目卡片:名称、描述、封面图、标签
- 点击可展开/跳转详情演示视频如有、Gitea 仓库链接、Seafile 下载链接(如有) - 点击可展开/跳转详情演示视频如有、Gitea 仓库链接、Seafile 下载链接(如有)
- 数据来源:`src/content/projects/index.json`(手动维护) - 数据来源:构建时从 Gitea / Seafile 拉取并生成的结构化数据文件
- 页面层只读取最终生成数据,不直接处理上游 API 响应
### 2.4 Gitea 统计(可嵌入首页或独立页) ### 2.4 Gitea 统计(可嵌入首页或独立页)
- 贡献热力图(客户端 JS 调 Gitea API 渲染) - 贡献热力图
- 最近活动列表 - 最近活动列表
- Gitea 地址通过站点配置注入 - 仓库简要信息(名称、描述、更新时间、链接等)
- 数据由构建脚本从 Gitea 拉取并清洗
- 运行时不依赖浏览器直接访问带 token 的私有 API
### 2.5 Seafile 分享(/shares 或首页侧栏) ### 2.5 Seafile 分享(/shares 或首页侧栏)
- 文件分享链接列表,每条含:文件名、描述、链接、时间 - 文件分享链接列表,每条含:文件名、描述、链接、时间
- 数据来源:`src/content/shares/index.json`(手动维护) - 数据来源:构建时从 Seafile 拉取并生成的结构化数据文件
- 支持展示特定目录或特定文件(如项目打包产物)
- 首版优先展示文件元数据与下载链接;如确有需要,可在后续增加“构建时镜像文件到站点静态目录”的能力
--- ---
@ -56,15 +69,18 @@
- 首页 Hero 区(个人简介 + 链接) - 首页 Hero 区(个人简介 + 链接)
- 日志列表 + 详情页Markdown 渲染) - 日志列表 + 详情页Markdown 渲染)
- 项目展示卡片 - 项目展示卡片
- 构建时 Gitea / Seafile 数据同步入口
**P1很重要** **P1很重要**
- Gitea 贡献热力图(客户端 API 调用) - Gitea 贡献热力图(基于构建期抓取的数据渲染)
- 仓库摘要信息同步
- Seafile 分享列表同步
- 响应式布局 - 响应式布局
**P2锦上添花** **P2锦上添花**
- Seafile 分享链接列表
- 日志按标签/时间筛选 - 日志按标签/时间筛选
- 项目演示视频嵌入 - 项目演示视频嵌入
- 构建时镜像特定 Seafile 文件到站点静态目录
- 深色模式 - 深色模式
--- ---
@ -74,11 +90,12 @@
| 层 | 选型 | 理由 | | 层 | 选型 | 理由 |
|----|------|------| |----|------|------|
| **框架** | [Astro](https://astro.build) | 静态站点生成,原生 `.md` 支持,零 JS 默认 + 可选交互岛 | | **框架** | [Astro](https://astro.build) | 静态站点生成,原生 `.md` 支持,零 JS 默认 + 可选交互岛 |
| **样式** | Tailwind CSS | 原子化 CSS快速出效果不需要手写大量样式 | | **样式** | CSS当前 / Tailwind CSS可选 | 首版以简单直接、低心智负担为主,是否接 Tailwind 以后续实现成本决定 |
| **交互组件** | React按需引入 | 仅 Gitea 热力图等需交互处使用Astro Islands | | **交互组件** | Astro 原生 + 按需引入 React | 默认静态渲染;只有热力图等确需交互时再引入前端组件 |
| **内容存储** | 文件系统(`.md` + `.json` | 无需数据库,日志/项目数据直接文件 | | **内容存储** | 文件系统(`.md` + `.json` | 构建脚本先把远程数据清洗为本地文件,再统一消费 |
| **数据同步** | Node.js 构建脚本 | 服务端拉取 Gitea / Seafile 数据,避免浏览器暴露 token |
| **部署** | Nginx + Docker | 静态文件服务,单容器 | | **部署** | Nginx + Docker | 静态文件服务,单容器 |
| **构建触发** | 内容文件变更后手动/脚本 rebuild | 日志写入 → `npm run build` → nginx reload | | **构建触发** | 手动 / 定时任务 / AstrBot | `npm run content:sync``npm run build` → 部署静态产物 |
--- ---
@ -97,7 +114,7 @@ summary: "搭建 Astro 项目骨架,确定目录结构与部署方案"
正文内容Markdown — 客观 diff 分析 + 主观动机,由 AstrBot 生成后写入 正文内容Markdown — 客观 diff 分析 + 主观动机,由 AstrBot 生成后写入
``` ```
### 项目数据(src/content/projects/index.json ### 项目数据(示例:`src/data/generated/projects.json`
```json ```json
[ [
@ -105,11 +122,50 @@ summary: "搭建 Astro 项目骨架,确定目录结构与部署方案"
"name": "personal-homepage", "name": "personal-homepage",
"description": "个人主页 Dashboard", "description": "个人主页 Dashboard",
"gitea_repo": "sepcomet/personal-homepage", "gitea_repo": "sepcomet/personal-homepage",
"repo_url": "https://gitea.sepcomet.xyz/sepcomet/personal-homepage",
"cover_image": "/images/projects/homepage.png", "cover_image": "/images/projects/homepage.png",
"demo_video": "https://...", "demo_video": "https://...",
"download_link": "https://seafile.sepcomet.xyz/...", "download_link": "https://seafile.sepcomet.xyz/...",
"tags": ["astro", "web"], "tags": ["astro", "web"],
"featured": true "featured": true,
"updated_at": "2026-05-05T00:00:00Z",
"source": "gitea+seafile"
}
]
```
### Gitea 活动数据(示例:`src/data/generated/gitea-activity.json`
```json
{
"updatedAt": "2026-05-05T00:00:00Z",
"days": [
{ "date": "2026-05-01", "count": 3 },
{ "date": "2026-05-02", "count": 0 }
],
"recent": [
{
"type": "push",
"repo": "sepcomet/personal-homepage",
"message": "更新首页模块结构",
"url": "https://gitea.sepcomet.xyz/...",
"time": "2026-05-05T00:00:00Z"
}
]
}
```
### 分享 / 下载数据(示例:`src/data/generated/shares.json`
```json
[
{
"name": "personal-homepage-demo.zip",
"description": "项目打包产物",
"url": "https://seafile.sepcomet.xyz/...",
"time": "2026-05-05T00:00:00Z",
"size": 12345678,
"source": "seafile"
} }
] ]
``` ```
@ -156,20 +212,25 @@ personal-homepage/
│ │ ├── ProjectCard.astro │ │ ├── ProjectCard.astro
│ │ └── GiteaHeatmap.jsx # React Island │ │ └── GiteaHeatmap.jsx # React Island
│ ├── content/ │ ├── content/
│ │ ├── logs/ # *.md外部系统写入 │ │ └── logs/ # *.md 日志内容
│ │ ├── projects/ │ ├── data/
│ │ │ └── index.json │ │ └── generated/ # 构建时同步生成的 JSON
│ │ └── shares/ │ │ ├── gitea-activity.json
│ │ └── index.json │ │ ├── projects.json
│ │ └── shares.json
│ ├── config.ts │ ├── config.ts
│ └── styles/ │ └── styles/
│ └── global.css │ └── global.css
├── scripts/
│ ├── fetch-gitea.ts
│ ├── fetch-seafile.ts
│ └── sync-content.ts
├── public/ ├── public/
│ ├── images/ │ ├── images/
│ │ └── avatar.png │ │ └── avatar.png
│ └── downloads/ # 可选:构建时镜像的下载文件
│ └── favicon.svg │ └── favicon.svg
├── astro.config.mjs ├── astro.config.mjs
├── tailwind.config.mjs
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
└── package.json └── package.json
@ -193,8 +254,10 @@ services:
``` ```
- Docker 构建阶段:`npm run build` 生成静态文件 - Docker 构建阶段:`npm run build` 生成静态文件
- Docker 构建阶段:先执行 `npm run content:sync`,再执行 `npm run build`
- 运行阶段Nginx 托管 `dist/` 目录 - 运行阶段Nginx 托管 `dist/` 目录
- 更新日志AstrBot 写入新的 `.md` → 触发 rebuild → nginx 热加载 - 日常更新:定时任务 / AstrBot 触发同步与重建
- Gitea / Seafile token 只存在于构建环境,不进入前端产物
--- ---
@ -202,19 +265,18 @@ services:
| Phase | 内容 | 产出 | | Phase | 内容 | 产出 |
|-------|------|------| |-------|------|------|
| **1** | Astro 项目初始化 + Tailwind + Header/Footer/Hero + Docker | 可访问的骨架页面 | | **1** | Astro 项目初始化 + 首页/日志/项目/分享骨架 | 可访问的骨架页面 |
| **2** | 日志系统:列表页 + 详情页 + frontmatter 解析 + 示例内容 | 日志模块 | | **2** | 日志系统:列表页 + 详情页 + frontmatter 解析 + 示例内容 | 日志模块 |
| **3** | 项目展示:卡片组件 + JSON 数据 + 布局 | 项目模块 | | **3** | 数据同步层Gitea / Seafile 拉取脚本、环境变量、生成文件 | 构建期数据链路 |
| **4** | Gitea 热力图 React Island + 站点配置 | 统计模块 | | **4** | 页面改读生成数据 + Gitea 活动真实化 + 仓库摘要接入 | 统计与项目模块 |
| **5** | Seafile 分享 + 响应式打磨 + 深色模式 + SEO | 收尾 | | **5** | Docker、定时重建、响应式打磨、深色模式、SEO | 收尾 |
--- ---
## 9. 不在此项目范围内 ## 9. 不在此项目范围内
- AstrBot 插件开发(独立项目) - AstrBot 插件开发(独立项目)
- Gitea/Seafile API 的调用与数据拉取(独立脚本或 AstrBot 插件负责) - 日志 markdown 文件的生成逻辑(可由 AstrBot 或其他系统负责)
- 日志 markdown 文件的生成逻辑AstrBot 负责)
- 自动化部署流程CI/CD可后续补充 - 自动化部署流程CI/CD可后续补充
主页只负责:**读取已有数据文件 → 渲染为 HTML → 静态托管**。 本项目负责:**构建时拉取远程数据 / 读取本地日志 → 生成站点数据文件 → 渲染为 HTML → 静态托管**。

70
TODO.md
View File

@ -1,6 +1,14 @@
# TODO # TODO
基于当前仓库现状整理的最小落地清单。目标不是加复杂后端,而是补齐“安全的数据同步层 + 定时重建入口”。 基于当前仓库现状整理的最小落地清单。当前项目定位已经明确为:
> **静态站点 + 构建时数据同步层**
目标不是加长期在线后端,而是补齐:
- 构建时从 Gitea / Seafile 主动拉数据
- 生成站点内部统一消费的只读数据
- 提供稳定的重建入口与失败兜底
## 当前已具备 ## 当前已具备
@ -8,8 +16,7 @@
- [x] 首页、项目页、分享页、日志列表页、日志详情页 - [x] 首页、项目页、分享页、日志列表页、日志详情页
- [x] `src/config.ts` 站点基础配置 - [x] `src/config.ts` 站点基础配置
- [x] `src/content/logs/*.md` 日志内容集合 - [x] `src/content/logs/*.md` 日志内容集合
- [x] `src/content/projects/index.json` 项目数据入口 - [x] 项目 / 分享静态种子数据入口(当前仍为手工 JSON
- [x] `src/content/shares/index.json` 分享数据入口
- [x] `npm run build` 静态构建命令 - [x] `npm run build` 静态构建命令
## P0先补齐可运行的数据同步链 ## P0先补齐可运行的数据同步链
@ -20,18 +27,16 @@
- [ ] 新建 `scripts/fetch-seafile.ts` - [ ] 新建 `scripts/fetch-seafile.ts`
- [ ] 新建 `scripts/sync-content.ts` - [ ] 新建 `scripts/sync-content.ts`
- [ ] 约定职责: - [ ] 约定职责:
- `fetch-gitea.ts`:拉取 Gitea 数据 - `fetch-gitea.ts`:拉取贡献热力图、最近活动、选定仓库摘要
- `fetch-seafile.ts`:拉取 Seafile 数据 - `fetch-seafile.ts`:拉取特定目录 / 特定文件元数据与分享链接
- `sync-content.ts`:统一清洗、转换、写入站点数据文件 - `sync-content.ts`:统一清洗、转换、校验、写入站点数据文件
### 2. 明确生成数据写入位置 ### 2. 明确生成数据写入位置
- [ ] 决定是否继续覆盖: - [ ] 改为统一输出到:
- `src/content/projects/index.json` - `src/data/generated/projects.json`
- `src/content/shares/index.json` - `src/data/generated/shares.json`
- [ ] 或改为单独输出到: - `src/data/generated/gitea-activity.json`
- `data/generated/projects.json`
- `data/generated/shares.json`
- [ ] 页面层统一只读最终生成数据,不直接处理上游原始响应 - [ ] 页面层统一只读最终生成数据,不直接处理上游原始响应
### 3. 增加环境变量约定 ### 3. 增加环境变量约定
@ -40,9 +45,14 @@
- [ ] 至少定义: - [ ] 至少定义:
- `GITEA_BASE_URL=` - `GITEA_BASE_URL=`
- `GITEA_TOKEN=` - `GITEA_TOKEN=`
- `GITEA_USERNAME=`
- `SEAFILE_BASE_URL=` - `SEAFILE_BASE_URL=`
- `SEAFILE_TOKEN=` - `SEAFILE_TOKEN=`
- [ ] 确保 token 只在服务端构建环境 / Astrbot 环境中使用 - `SYNC_OUTPUT_DIR=`
- [ ] 如需镜像下载文件,再补:
- `SEAFILE_MIRROR_DOWNLOADS=`
- `DOWNLOADS_OUTPUT_DIR=`
- [ ] 确保 token 只在服务端构建环境 / AstrBot 环境中使用
### 4. 增加 package scripts ### 4. 增加 package scripts
@ -56,8 +66,8 @@
### 5. 明确失败策略 ### 5. 明确失败策略
- [ ] 约定 Gitea / Seafile 拉取失败时如何处理: - [ ] 约定 Gitea / Seafile 拉取失败时如何处理:
- 直接构建失败 - 默认保留上次成功数据继续构建
- 或保留上次成功数据继续构建 - 必要时可通过参数切换为严格失败
- [ ] 在同步脚本里输出明确日志,便于 Astrbot 告警 - [ ] 在同步脚本里输出明确日志,便于 Astrbot 告警
## P1强烈建议尽快补上 ## P1强烈建议尽快补上
@ -66,6 +76,7 @@
- [ ] 为 projects 数据建立 schema 校验 - [ ] 为 projects 数据建立 schema 校验
- [ ] 为 shares 数据建立 schema 校验 - [ ] 为 shares 数据建立 schema 校验
- [ ] 为 gitea activity 数据建立 schema 校验
- [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面 - [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
### 7. 增加同步元数据 ### 7. 增加同步元数据
@ -92,26 +103,38 @@
### 10. 把 Gitea Activity 从静态占位改成真实数据 ### 10. 把 Gitea Activity 从静态占位改成真实数据
- [ ] 当前 `src/components/GiteaActivity.astro` 仍是静态示例数据 - [ ] 当前 `src/components/GiteaActivity.astro` 仍是静态示例数据
- [ ] 后续改为读取同步后生成的数据 - [ ] 改为读取 `src/data/generated/gitea-activity.json`
- [ ] 再决定是否需要客户端渲染热力图 - [ ] 再决定是否需要客户端渲染热力图
### 11. 补部署文件 ### 11. 仓库数据与页面映射收口
- [ ] 明确“哪些仓库进入 projects”
- [ ] 明确“哪些仓库只用于 activity 不进入 projects”
- [ ] 决定项目封面图是继续本地维护,还是允许远程字段补充
### 12. Seafile 下载策略收口
- [ ] 首版默认只展示元数据 + 下载链接
- [ ] 评估是否需要构建时同步特定打包文件到 `public/downloads/`
- [ ] 如同步文件本体,补充大小限制、覆盖策略、清理策略
### 13. 补部署文件
- [ ] 增加 `Dockerfile` - [ ] 增加 `Dockerfile`
- [ ] 增加 `docker-compose.yml` - [ ] 增加 `docker-compose.yml`
- [ ] 明确静态产物部署方式 - [ ] 明确静态产物部署方式
### 12. 补文档 ### 14. 补文档
- [ ] 在 README 或单独文档里写清: - [ ] 在 README 或单独文档里写清:
- Astrbot 如何触发 - AstrBot / cron 如何触发
- 环境变量如何配置 - 环境变量如何配置
- 同步脚本职责 - 同步脚本职责
- 构建失败如何排查 - 构建失败如何排查
## 推荐最小闭环 ## 推荐最小闭环
- [ ] Astrbot 每天 00:00 触发 - [ ] AstrBot 每天 00:00 触发
- [ ] 执行 `npm run content:sync` - [ ] 执行 `npm run content:sync`
- [ ] 执行 `npm run build` - [ ] 执行 `npm run build`
- [ ] 部署静态产物 - [ ] 部署静态产物
@ -121,6 +144,7 @@
1. 先补 `.env.example` 1. 先补 `.env.example`
2. 再补 `scripts/sync-content.ts` 2. 再补 `scripts/sync-content.ts`
3. 再补 `package.json` scripts 3. 再补 `fetch-gitea.ts` / `fetch-seafile.ts`
4. 再补 projects / shares schema 校验 4. 再补 `package.json` scripts
5. 最后接 Astrbot 定时任务 5. 再补 projects / shares / activity schema 校验
6. 最后接 AstrBot 定时任务

View File

@ -8,7 +8,9 @@
}, },
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"content:sync": "node --experimental-strip-types ./scripts/sync-content.ts",
"build": "astro build", "build": "astro build",
"rebuild": "npm run content:sync && npm run build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro"
}, },

235
scripts/sync-content.ts Normal file
View File

@ -0,0 +1,235 @@
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;
});

View File