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
|
*.seed
|
||||||
|
|
||||||
public/images/gallery
|
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. 项目范围
|
## 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
70
TODO.md
|
|
@ -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 定时任务
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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