feat: add build-time content sync scaffold
This commit is contained in:
parent
1ab1717e64
commit
d85c8ac85d
|
|
@ -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
|
||||
|
|
@ -37,3 +37,7 @@ Desktop.ini
|
|||
*.seed
|
||||
|
||||
public/images/gallery
|
||||
|
||||
# generated sync data
|
||||
src/data/generated/*.json
|
||||
!src/data/generated/.gitkeep
|
||||
|
|
|
|||
120
REQUIREMENTS.md
120
REQUIREMENTS.md
|
|
@ -2,9 +2,16 @@
|
|||
|
||||
## 1. 项目范围
|
||||
|
||||
**只做 Web 应用本身。** AstrBot 插件、Gitea/Seafile API 接入属于独立项目。
|
||||
本项目是一个**公开静态站点 + 构建时数据同步层**。
|
||||
|
||||
主页是一个**公开的静态站点**,通过 markdown/JSON 文件驱动内容(这些文件可由 AstrBot 等其他系统写入,但主页本身不关心来源)。
|
||||
站点运行时仍然是纯静态页面,但在构建阶段由本项目主动向 Gitea / Seafile 拉取所需数据,清洗后生成本地只读数据文件,再交给 Astro 渲染。
|
||||
|
||||
也就是说:
|
||||
|
||||
- **运行时**:纯静态站点,对外公开访问
|
||||
- **构建时**:服务端脚本拉取远程数据、生成内容文件
|
||||
|
||||
AstrBot、定时任务、手动脚本等都可以作为“触发构建”的外部入口,但**同步逻辑本身属于这个仓库的一部分**。
|
||||
|
||||
- 域名:`sepcomet.xyz`(备案中)
|
||||
- 部署:自有服务器 Docker 环境
|
||||
|
|
@ -28,25 +35,31 @@
|
|||
- 每条日志卡片:标题、日期、标签、摘要
|
||||
- 点击进入日志详情页(/logs/[slug])
|
||||
- 日志详情:Markdown 全文渲染,关联的仓库名和 commit 链接
|
||||
- 内容来自 `src/content/logs/` 下的 `.md` 文件(文件来源不在本范围内,由 AstrBot 或其他系统写入)
|
||||
- 内容来自 `src/content/logs/` 下的 `.md` 文件
|
||||
- 首版允许日志继续由手动维护或外部系统写入;日志生成逻辑不要求在本项目内实现
|
||||
|
||||
### 2.3 项目展示(/projects)
|
||||
|
||||
- 项目卡片网格布局
|
||||
- 每个项目卡片:名称、描述、封面图、标签
|
||||
- 点击可展开/跳转详情:演示视频(如有)、Gitea 仓库链接、Seafile 下载链接(如有)
|
||||
- 数据来源:`src/content/projects/index.json`(手动维护)
|
||||
- 数据来源:构建时从 Gitea / Seafile 拉取并生成的结构化数据文件
|
||||
- 页面层只读取最终生成数据,不直接处理上游 API 响应
|
||||
|
||||
### 2.4 Gitea 统计(可嵌入首页或独立页)
|
||||
|
||||
- 贡献热力图(客户端 JS 调 Gitea API 渲染)
|
||||
- 贡献热力图
|
||||
- 最近活动列表
|
||||
- Gitea 地址通过站点配置注入
|
||||
- 仓库简要信息(名称、描述、更新时间、链接等)
|
||||
- 数据由构建脚本从 Gitea 拉取并清洗
|
||||
- 运行时不依赖浏览器直接访问带 token 的私有 API
|
||||
|
||||
### 2.5 Seafile 分享(/shares 或首页侧栏)
|
||||
|
||||
- 文件分享链接列表,每条含:文件名、描述、链接、时间
|
||||
- 数据来源:`src/content/shares/index.json`(手动维护)
|
||||
- 数据来源:构建时从 Seafile 拉取并生成的结构化数据文件
|
||||
- 支持展示特定目录或特定文件(如项目打包产物)
|
||||
- 首版优先展示文件元数据与下载链接;如确有需要,可在后续增加“构建时镜像文件到站点静态目录”的能力
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -56,15 +69,18 @@
|
|||
- 首页 Hero 区(个人简介 + 链接)
|
||||
- 日志列表 + 详情页(Markdown 渲染)
|
||||
- 项目展示卡片
|
||||
- 构建时 Gitea / Seafile 数据同步入口
|
||||
|
||||
**P1(很重要):**
|
||||
- Gitea 贡献热力图(客户端 API 调用)
|
||||
- Gitea 贡献热力图(基于构建期抓取的数据渲染)
|
||||
- 仓库摘要信息同步
|
||||
- Seafile 分享列表同步
|
||||
- 响应式布局
|
||||
|
||||
**P2(锦上添花):**
|
||||
- Seafile 分享链接列表
|
||||
- 日志按标签/时间筛选
|
||||
- 项目演示视频嵌入
|
||||
- 构建时镜像特定 Seafile 文件到站点静态目录
|
||||
- 深色模式
|
||||
|
||||
---
|
||||
|
|
@ -74,11 +90,12 @@
|
|||
| 层 | 选型 | 理由 |
|
||||
|----|------|------|
|
||||
| **框架** | [Astro](https://astro.build) | 静态站点生成,原生 `.md` 支持,零 JS 默认 + 可选交互岛 |
|
||||
| **样式** | Tailwind CSS | 原子化 CSS,快速出效果,不需要手写大量样式 |
|
||||
| **交互组件** | React(按需引入) | 仅 Gitea 热力图等需交互处使用,Astro Islands |
|
||||
| **内容存储** | 文件系统(`.md` + `.json`) | 无需数据库,日志/项目数据直接文件 |
|
||||
| **样式** | CSS(当前) / Tailwind CSS(可选) | 首版以简单直接、低心智负担为主,是否接 Tailwind 以后续实现成本决定 |
|
||||
| **交互组件** | Astro 原生 + 按需引入 React | 默认静态渲染;只有热力图等确需交互时再引入前端组件 |
|
||||
| **内容存储** | 文件系统(`.md` + `.json`) | 构建脚本先把远程数据清洗为本地文件,再统一消费 |
|
||||
| **数据同步** | Node.js 构建脚本 | 服务端拉取 Gitea / Seafile 数据,避免浏览器暴露 token |
|
||||
| **部署** | 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 生成后写入
|
||||
```
|
||||
|
||||
### 项目数据(src/content/projects/index.json)
|
||||
### 项目数据(示例:`src/data/generated/projects.json`)
|
||||
|
||||
```json
|
||||
[
|
||||
|
|
@ -105,11 +122,50 @@ summary: "搭建 Astro 项目骨架,确定目录结构与部署方案"
|
|||
"name": "personal-homepage",
|
||||
"description": "个人主页 Dashboard",
|
||||
"gitea_repo": "sepcomet/personal-homepage",
|
||||
"repo_url": "https://gitea.sepcomet.xyz/sepcomet/personal-homepage",
|
||||
"cover_image": "/images/projects/homepage.png",
|
||||
"demo_video": "https://...",
|
||||
"download_link": "https://seafile.sepcomet.xyz/...",
|
||||
"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
|
||||
│ │ └── GiteaHeatmap.jsx # React Island
|
||||
│ ├── content/
|
||||
│ │ ├── logs/ # *.md(外部系统写入)
|
||||
│ │ ├── projects/
|
||||
│ │ │ └── index.json
|
||||
│ │ └── shares/
|
||||
│ │ └── index.json
|
||||
│ │ └── logs/ # *.md 日志内容
|
||||
│ ├── data/
|
||||
│ │ └── generated/ # 构建时同步生成的 JSON
|
||||
│ │ ├── gitea-activity.json
|
||||
│ │ ├── projects.json
|
||||
│ │ └── shares.json
|
||||
│ ├── config.ts
|
||||
│ └── styles/
|
||||
│ └── global.css
|
||||
├── scripts/
|
||||
│ ├── fetch-gitea.ts
|
||||
│ ├── fetch-seafile.ts
|
||||
│ └── sync-content.ts
|
||||
├── public/
|
||||
│ ├── images/
|
||||
│ │ └── avatar.png
|
||||
│ └── downloads/ # 可选:构建时镜像的下载文件
|
||||
│ └── favicon.svg
|
||||
├── astro.config.mjs
|
||||
├── tailwind.config.mjs
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
|
|
@ -193,8 +254,10 @@ services:
|
|||
```
|
||||
|
||||
- Docker 构建阶段:`npm run build` 生成静态文件
|
||||
- Docker 构建阶段:先执行 `npm run content:sync`,再执行 `npm run build`
|
||||
- 运行阶段:Nginx 托管 `dist/` 目录
|
||||
- 更新日志:AstrBot 写入新的 `.md` → 触发 rebuild → nginx 热加载
|
||||
- 日常更新:定时任务 / AstrBot 触发同步与重建
|
||||
- Gitea / Seafile token 只存在于构建环境,不进入前端产物
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -202,19 +265,18 @@ services:
|
|||
|
||||
| Phase | 内容 | 产出 |
|
||||
|-------|------|------|
|
||||
| **1** | Astro 项目初始化 + Tailwind + Header/Footer/Hero + Docker | 可访问的骨架页面 |
|
||||
| **1** | Astro 项目初始化 + 首页/日志/项目/分享骨架 | 可访问的骨架页面 |
|
||||
| **2** | 日志系统:列表页 + 详情页 + frontmatter 解析 + 示例内容 | 日志模块 |
|
||||
| **3** | 项目展示:卡片组件 + JSON 数据 + 布局 | 项目模块 |
|
||||
| **4** | Gitea 热力图 React Island + 站点配置 | 统计模块 |
|
||||
| **5** | Seafile 分享 + 响应式打磨 + 深色模式 + SEO | 收尾 |
|
||||
| **3** | 数据同步层:Gitea / Seafile 拉取脚本、环境变量、生成文件 | 构建期数据链路 |
|
||||
| **4** | 页面改读生成数据 + Gitea 活动真实化 + 仓库摘要接入 | 统计与项目模块 |
|
||||
| **5** | Docker、定时重建、响应式打磨、深色模式、SEO | 收尾 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 不在此项目范围内
|
||||
|
||||
- AstrBot 插件开发(独立项目)
|
||||
- Gitea/Seafile API 的调用与数据拉取(独立脚本或 AstrBot 插件负责)
|
||||
- 日志 markdown 文件的生成逻辑(AstrBot 负责)
|
||||
- 日志 markdown 文件的生成逻辑(可由 AstrBot 或其他系统负责)
|
||||
- 自动化部署流程(CI/CD,可后续补充)
|
||||
|
||||
主页只负责:**读取已有数据文件 → 渲染为 HTML → 静态托管**。
|
||||
本项目负责:**构建时拉取远程数据 / 读取本地日志 → 生成站点数据文件 → 渲染为 HTML → 静态托管**。
|
||||
|
|
|
|||
70
TODO.md
70
TODO.md
|
|
@ -1,6 +1,14 @@
|
|||
# TODO
|
||||
|
||||
基于当前仓库现状整理的最小落地清单。目标不是加复杂后端,而是补齐“安全的数据同步层 + 定时重建入口”。
|
||||
基于当前仓库现状整理的最小落地清单。当前项目定位已经明确为:
|
||||
|
||||
> **静态站点 + 构建时数据同步层**
|
||||
|
||||
目标不是加长期在线后端,而是补齐:
|
||||
|
||||
- 构建时从 Gitea / Seafile 主动拉数据
|
||||
- 生成站点内部统一消费的只读数据
|
||||
- 提供稳定的重建入口与失败兜底
|
||||
|
||||
## 当前已具备
|
||||
|
||||
|
|
@ -8,8 +16,7 @@
|
|||
- [x] 首页、项目页、分享页、日志列表页、日志详情页
|
||||
- [x] `src/config.ts` 站点基础配置
|
||||
- [x] `src/content/logs/*.md` 日志内容集合
|
||||
- [x] `src/content/projects/index.json` 项目数据入口
|
||||
- [x] `src/content/shares/index.json` 分享数据入口
|
||||
- [x] 项目 / 分享静态种子数据入口(当前仍为手工 JSON)
|
||||
- [x] `npm run build` 静态构建命令
|
||||
|
||||
## P0:先补齐可运行的数据同步链
|
||||
|
|
@ -20,18 +27,16 @@
|
|||
- [ ] 新建 `scripts/fetch-seafile.ts`
|
||||
- [ ] 新建 `scripts/sync-content.ts`
|
||||
- [ ] 约定职责:
|
||||
- `fetch-gitea.ts`:拉取 Gitea 数据
|
||||
- `fetch-seafile.ts`:拉取 Seafile 数据
|
||||
- `sync-content.ts`:统一清洗、转换、写入站点数据文件
|
||||
- `fetch-gitea.ts`:拉取贡献热力图、最近活动、选定仓库摘要
|
||||
- `fetch-seafile.ts`:拉取特定目录 / 特定文件元数据与分享链接
|
||||
- `sync-content.ts`:统一清洗、转换、校验、写入站点数据文件
|
||||
|
||||
### 2. 明确生成数据写入位置
|
||||
|
||||
- [ ] 决定是否继续覆盖:
|
||||
- `src/content/projects/index.json`
|
||||
- `src/content/shares/index.json`
|
||||
- [ ] 或改为单独输出到:
|
||||
- `data/generated/projects.json`
|
||||
- `data/generated/shares.json`
|
||||
- [ ] 改为统一输出到:
|
||||
- `src/data/generated/projects.json`
|
||||
- `src/data/generated/shares.json`
|
||||
- `src/data/generated/gitea-activity.json`
|
||||
- [ ] 页面层统一只读最终生成数据,不直接处理上游原始响应
|
||||
|
||||
### 3. 增加环境变量约定
|
||||
|
|
@ -40,9 +45,14 @@
|
|||
- [ ] 至少定义:
|
||||
- `GITEA_BASE_URL=`
|
||||
- `GITEA_TOKEN=`
|
||||
- `GITEA_USERNAME=`
|
||||
- `SEAFILE_BASE_URL=`
|
||||
- `SEAFILE_TOKEN=`
|
||||
- [ ] 确保 token 只在服务端构建环境 / Astrbot 环境中使用
|
||||
- `SYNC_OUTPUT_DIR=`
|
||||
- [ ] 如需镜像下载文件,再补:
|
||||
- `SEAFILE_MIRROR_DOWNLOADS=`
|
||||
- `DOWNLOADS_OUTPUT_DIR=`
|
||||
- [ ] 确保 token 只在服务端构建环境 / AstrBot 环境中使用
|
||||
|
||||
### 4. 增加 package scripts
|
||||
|
||||
|
|
@ -56,8 +66,8 @@
|
|||
### 5. 明确失败策略
|
||||
|
||||
- [ ] 约定 Gitea / Seafile 拉取失败时如何处理:
|
||||
- 直接构建失败
|
||||
- 或保留上次成功数据继续构建
|
||||
- 默认保留上次成功数据继续构建
|
||||
- 必要时可通过参数切换为严格失败
|
||||
- [ ] 在同步脚本里输出明确日志,便于 Astrbot 告警
|
||||
|
||||
## P1:强烈建议尽快补上
|
||||
|
|
@ -66,6 +76,7 @@
|
|||
|
||||
- [ ] 为 projects 数据建立 schema 校验
|
||||
- [ ] 为 shares 数据建立 schema 校验
|
||||
- [ ] 为 gitea activity 数据建立 schema 校验
|
||||
- [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
|
||||
|
||||
### 7. 增加同步元数据
|
||||
|
|
@ -92,26 +103,38 @@
|
|||
### 10. 把 Gitea Activity 从静态占位改成真实数据
|
||||
|
||||
- [ ] 当前 `src/components/GiteaActivity.astro` 仍是静态示例数据
|
||||
- [ ] 后续改为读取同步后生成的数据
|
||||
- [ ] 改为读取 `src/data/generated/gitea-activity.json`
|
||||
- [ ] 再决定是否需要客户端渲染热力图
|
||||
|
||||
### 11. 补部署文件
|
||||
### 11. 仓库数据与页面映射收口
|
||||
|
||||
- [ ] 明确“哪些仓库进入 projects”
|
||||
- [ ] 明确“哪些仓库只用于 activity 不进入 projects”
|
||||
- [ ] 决定项目封面图是继续本地维护,还是允许远程字段补充
|
||||
|
||||
### 12. Seafile 下载策略收口
|
||||
|
||||
- [ ] 首版默认只展示元数据 + 下载链接
|
||||
- [ ] 评估是否需要构建时同步特定打包文件到 `public/downloads/`
|
||||
- [ ] 如同步文件本体,补充大小限制、覆盖策略、清理策略
|
||||
|
||||
### 13. 补部署文件
|
||||
|
||||
- [ ] 增加 `Dockerfile`
|
||||
- [ ] 增加 `docker-compose.yml`
|
||||
- [ ] 明确静态产物部署方式
|
||||
|
||||
### 12. 补文档
|
||||
### 14. 补文档
|
||||
|
||||
- [ ] 在 README 或单独文档里写清:
|
||||
- Astrbot 如何触发
|
||||
- AstrBot / cron 如何触发
|
||||
- 环境变量如何配置
|
||||
- 同步脚本职责
|
||||
- 构建失败如何排查
|
||||
|
||||
## 推荐最小闭环
|
||||
|
||||
- [ ] Astrbot 每天 00:00 触发
|
||||
- [ ] AstrBot 每天 00:00 触发
|
||||
- [ ] 执行 `npm run content:sync`
|
||||
- [ ] 执行 `npm run build`
|
||||
- [ ] 部署静态产物
|
||||
|
|
@ -121,6 +144,7 @@
|
|||
|
||||
1. 先补 `.env.example`
|
||||
2. 再补 `scripts/sync-content.ts`
|
||||
3. 再补 `package.json` scripts
|
||||
4. 再补 projects / shares schema 校验
|
||||
5. 最后接 Astrbot 定时任务
|
||||
3. 再补 `fetch-gitea.ts` / `fetch-seafile.ts`
|
||||
4. 再补 `package.json` scripts
|
||||
5. 再补 projects / shares / activity schema 校验
|
||||
6. 最后接 AstrBot 定时任务
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"content:sync": "node --experimental-strip-types ./scripts/sync-content.ts",
|
||||
"build": "astro build",
|
||||
"rebuild": "npm run content:sync && npm run build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
Loading…
Reference in New Issue