diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ebf538f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 0fd5ebe..28faf98 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,8 @@ Desktop.ini *.pid *.seed -public/images/gallery \ No newline at end of file +public/images/gallery + +# generated sync data +src/data/generated/*.json +!src/data/generated/.gitkeep diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 86cf2fd..f1698e2 100644 --- a/REQUIREMENTS.md +++ b/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 → 静态托管**。 diff --git a/TODO.md b/TODO.md index a8fe774..1381d13 100644 --- a/TODO.md +++ b/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 定时任务 diff --git a/package.json b/package.json index 3cf5e41..b8eefe5 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/sync-content.ts b/scripts/sync-content.ts new file mode 100644 index 0000000..02fafc5 --- /dev/null +++ b/scripts/sync-content.ts @@ -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 { + 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; +}); diff --git a/src/data/generated/.gitkeep b/src/data/generated/.gitkeep new file mode 100644 index 0000000..e69de29