Afilmory Docs (#66)

This commit is contained in:
MaxtuneLee
2025-09-11 18:42:47 +08:00
committed by GitHub
parent 57c1fc25fe
commit 41b0d7a73d
74 changed files with 15768 additions and 943 deletions

132
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,132 @@
# Afilmory Codebase Guide for AI Agents
## Project Architecture
Afilmory is a modern photo gallery monorepo with sophisticated image processing capabilities:
- **`apps/ssr/`** - Next.js 15 SSR application with database and APIs
- **`apps/web/`** - React 19 frontend with Vite, WebGL viewer, masonry layout
- **`packages/builder/`** - Photo processing engine with S3/GitHub storage adapters
- **`packages/docs/`** - Vite SSG documentation site (planned custom implementation)
- **`packages/webgl-viewer/`** - High-performance WebGL photo viewer component
## Critical Development Commands
```bash
# Photo processing & manifest generation
pnpm run build:manifest # Incremental photo sync
pnpm run build:manifest -- --force # Full rebuild
pnpm run build:manifest -- --force-thumbnails # Regenerate thumbnails only
# Development servers
pnpm dev # Starts both SSR + web apps
pnpm --filter @afilmory/ssr dev # SSR only (Next.js on :1924)
pnpm --filter web dev # Web only (Vite)
# Database operations (SSR app)
pnpm --filter @afilmory/ssr db:generate # Generate Drizzle migrations
pnpm --filter @afilmory/ssr db:migrate # Run migrations
```
## Code Quality & Architecture Patterns
### State Management Architecture
- **Jotai** for client state in web app - use atomic patterns, avoid large atoms
- **TanStack Query** for server state - leverage cache invalidation patterns
- **React Context** - follow composition over deep nesting, prevent re-renders
### Photo Processing Pipeline
1. **Storage Adapters** (`packages/builder/src/storage/`) - implement `StorageProvider` interface
2. **Format Conversion** - HEIC/TIFF → web formats via Sharp
3. **EXIF Extraction** - camera settings, GPS, Fujifilm recipes
4. **Concurrent Processing** - worker threads/cluster mode in `builderConfig.performance`
### WebGL Integration
- Custom WebGL viewer in `packages/webgl-viewer/` for high-performance rendering
- Gesture support, zoom/pan operations
- Integration with masonry layout via Masonic
## Project-Specific Conventions
### Configuration System
- **`builder.config.json`** - photo processing, storage, performance settings
- **`config.json`** - site metadata merged with `site.config.ts` defaults
- **`env.ts`** - centralized environment validation with Zod schemas
### Apple UIKit Color System
```typescript
// Use semantic Tailwind classes from tailwindcss-uikit-colors
className = 'text-text-primary bg-fill-secondary material-thin'
// NOT generic colors like "text-blue-500"
```
### i18n Flat Key Structure
```json
// locales/app/en.json - use dot notation, avoid nesting
{
"exif.camera.model": "Camera Model",
"photo.count_one": "{{count}} photo",
"photo.count_other": "{{count}} photos"
}
```
### Monorepo Workspace Patterns
- Use `pnpm --filter <package>` for targeted operations
- Shared dependencies in `pnpm-workspace.yaml` catalog
- Cross-package imports via workspace protocol: `"@afilmory/components": "workspace:*"`
## Storage Provider Integration
When extending storage support, implement the adapter pattern:
```typescript
// packages/builder/src/storage/providers/
class NewStorageProvider implements StorageProvider {
async listImages(): Promise<StorageObject[]> {
/* */
}
async getFile(key: string): Promise<Buffer | null> {
/* */
}
// Key methods for storage abstraction
}
```
## Performance Considerations
- **Photo Processing**: Configure worker pools in `builderConfig.performance.worker`
- **WebGL Viewer**: Implement texture memory management and disposal
- **Bundle Splitting**: Leverage Vite's code splitting for image processing tools
- **Image Optimization**: Use Sharp for thumbnails, Blurhash for placeholders
## Documentation Site (packages/docs/)
Currently planned as a custom Vite SSG implementation:
- **MDX** with React components, math (KaTeX), charts (Mermaid)
- **Custom Vite plugins** for file-system routing and search indexing
- **Design system** aligned with main app's Apple UIKit colors
- Reference `packages/docs/requirements.md` and `tasks.md` for implementation details
## Integration Points
- **Database**: Drizzle ORM with PostgreSQL for SSR app
- **Image CDN**: S3-compatible storage with custom domain support
- **Map Integration**: MapLibre for GPS photo locations
- **Live Photos**: iOS video detection and playback support
- **RSS/Social**: OpenGraph metadata and feed generation
## Other Considerations
- Don't have to implement everything at once; focus on core features first
- Use existing packages as references for implementation patterns
- Follow the established architecture for consistency
- Keep documentation up-to-date with code changes
- If you need to run commands, ask for help, don't run them blindly

81
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
# Sample workflow for building and deploying a Next.js site to GitHub Pages
#
# To get started with Next.js see: https://nextjs.org/docs/getting-started
#
name: Deploy Afilmory Docs to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ['docs', 'main']
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Setup Pages
uses: actions/configure-pages@v5
with:
# Automatically inject basePath in your Next.js configuration file and disable
# server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
#
# You may remove this line if you want to manage the configuration yourself.
static_site_generator: next
- name: Cache pnpm modules
uses: actions/cache@v4
env:
cache-name: cache-pnpm-modules
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: true
- name: Install dependencies
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
- name: Build with Next.js
run: ${{ steps.detect-package-manager.outputs.runner }} pnpm docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./packages/docs/dist
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -0,0 +1,48 @@
<!-- Generated by SpecStory -->
# 根据 #file:builder 包编写文档可以创建多个mdx文件来描述builder的使用方法 (2025-07-14 13:49Z)
_**User**_
根据 #file:builder编写文档可以创建多个mdx文件来描述builder的使用方法和二次开发思路
---
_**Assistant**_
我来分析 builder 包的代码结构和功能,然后为其编写详细的文档。首先让我探索 builder 包的内容。 现在让我探索更多关键模块来了解 builder 的完整架构: 现在我了解了 builder 包的完整架构。基于分析,我将为其创建详细的文档。让我创建多个 MDX 文件来描述不同方面:
---
_**User**_
使用英文编写文档
---
_**Assistant**_
I'll update the existing overview documentation to English and create additional MDX files for comprehensive Builder package documentation.
```
```
```
```
```
```
```
```
```
```
---

View File

@@ -0,0 +1,193 @@
<!-- Generated by SpecStory -->
# 在这个文件里面完成一个插件,满足 #file:Sidebar.tsx #file:App.tsx (2025-07-19 16:41Z)
_**User**_
在这个文件里面完成一个插件,满足 #file:Sidebar.tsx #file:App.tsx 的需求
---
_**Assistant**_
我来分析一下当前的需求,然后完成 Vite 路由插件的实现。
首先让我查看一下当前文件的内容和相关的项目结构: 现在我需要查看 SSG 插件的实现,以了解路由是如何生成的: 现在我来查看一下项目的内容结构,以便了解需要生成的路由: 基于对代码的分析,我发现 `App.tsx``Sidebar.tsx` 都在引用一个 `routes` 变量,但这个变量还没有定义。我需要创建一个 Vite 插件,来动态生成路由信息,并且让它在开发和构建过程中都能正常工作。
现在我来实现这个路由插件: 让我修复这些 lint 错误:
```
```
```
```
现在我需要更新 Vite 配置文件来使用这个新的路由插件:
```
```
```
```
现在我需要更新 `Sidebar.tsx` 文件,导入路由信息:
```
```
我需要首先生成路由文件。让我先运行插件来生成路由:
---
_**User**_
为什么build的时候一直在反复执行这个函数generateStaticPages
---
_**Assistant**_
我来分析一下为什么 `generateStaticPages` 函数在构建时会反复执行。让我查看当前的插件实现: 我发现问题了!在 `generateStaticPages` 函数中,它在内部调用了 `build()` 函数来构建 SSR 版本,而这个 `build()` 调用会再次触发相同的插件,导致无限循环。
让我查看 Vite 配置文件来确认插件配置: 现在我明白问题所在了。当 `generateStaticPages` 函数调用 `build()` 时,它传递了相同的插件配置 `plugins: [...viteConfig.plugins]`,这包括了 SSG 插件本身,导致无限递归。
我需要修复这个问题,避免在内部构建时重复使用 SSG 插件:
```
```
现在我还需要进一步优化,避免重复构建。让我添加一个标志来检查 SSR 构建是否已经完成:
```
```
让我实际使用这个标志来避免重复构建:
```
```
现在让我进一步优化,添加更好的条件检查,避免在开发模式或非主构建过程中执行 SSG
```
```
让我再进一步改进,添加更精确的检查机制,避免在 SSR 构建过程中触发 SSG
```
```
现在让我再做一个重要的优化,通过环境变量来控制是否执行 SSG这样可以更好地控制构建过程
```
```
现在修改内部的 build 调用,设置环境变量以避免触发 SSG
```
```
现在让我再添加一个更直接的解决方案 - 使用一个全局变量来防止递归:
```
```
现在让我使用这个全局标志:
```
```
现在在 SSR 构建过程中设置这个标志:
```
```
现在问题应该解决了。让我总结一下我做的修改来解决 `generateStaticPages` 函数反复执行的问题:
## 问题分析
原始代码中的问题是:
1. `generateStaticPages` 函数在内部调用 `build()` 来构建 SSR 版本
2. 这个 `build()` 调用使用了相同的插件配置 `[...viteConfig.plugins]`,包括了 SSG 插件本身
3. 这导致了无限递归SSG 插件触发 → 构建 SSR → SSG 插件再次触发 → 无限循环
## 解决方案
我实施了多层防护来解决这个问题:
1. **过滤插件**:在内部 SSR 构建时过滤掉 SSG 插件
2. **全局标志**:使用 `isSSRBuilding` 全局变量防止递归
3. **环境变量**:设置 `VITE_SSR_BUILD` 环境变量作为额外保护
4. **构建条件检查**:只在主构建(非 SSR 构建)时执行 SSG
5. **文件存在检查**:避免重复构建已存在的 SSR 文件
6. **错误处理**:使用 try-finally 确保标志能正确重置
现在构建过程应该是:
1. 主构建开始SSG 插件启动
2. SSG 检查是否需要构建 SSR 入口文件
3. 如果需要,启动 SSR 构建(使用过滤后的插件,不包含 SSG
4. SSR 构建完成后,继续生成静态页面
5. 不会再次触发 SSG 插件
这样就避免了无限递归的问题。
---
_**User**_
vite v7.0.5 building for production...
📝 Generated routes file: src/routes.ts
✓ 37 modules transformed.
dist/index.html 0.41 kB │ gzip: 0.25 kB
dist/routes.json 1.21 kB │ gzip: 0.49 kB
dist/assets/server-CEVB1K0h.js 179.29 kB │ gzip: 54.55 kB
✓ built in 6.00s
🚀 Generating static pages...
📦 Building SSR entry...
vite v7.0.5 building SSR bundle for production...
📝 Generated routes file: src/routes.ts
📝 Generated routes file: src/routes.ts
✓ 5 modules transformed.
✓ 5 modules transformed. (x2)
✗ Build failed in 67ms
Failed to generate static pages: [[@mdx-js/rollup] Unexpected `FunctionDeclaration` in code: only import/exports are supported
file: /Users/maxtune/workspace/personal/Afilmory/packages/docs/content/en/index.mdx
] {
ancestors: undefined,
cause: undefined,
column: 1,
fatal: undefined,
line: 3,
name: '3:1-275:2',
place: {
start: { line: 3, column: 1, offset: 155 },
end: { line: 275, column: 2, offset: 8724 }
},
reason: 'Unexpected `FunctionDeclaration` in code: only import/exports are supported',
ruleId: 'non-esm',
source: 'micromark-extension-mdxjs-esm',
url: 'https://github.com/micromark/micromark-extension-mdxjs-esm#unexpected-type-in-code-only-importexports-are-supported',
code: 'PLUGIN_ERROR',
plugin: '@mdx-js/rollup',
hook: 'transform',
id: '/Users/maxtune/workspace/personal/Afilmory/packages/docs/content/en/index.mdx',
watchFiles: [
'/Users/maxtune/workspace/personal/Afilmory/packages/docs/src/entry-server.tsx',
'/Users/maxtune/workspace/personal/Afilmory/packages/docs/package.json',
'/Users/maxtune/workspace/personal/Afilmory/packages/docs/src/App.tsx',
'/Users/maxtune/workspace/personal/Afilmory/packages/docs/src/routes.ts',
'/Users/maxtune/workspace/personal/Afilmory/packages/docs/src/layouts/DocsLayout.tsx',
'/Users/maxtune/workspace/personal/Afilmory/packages/docs/src/components/MDXProvider.tsx',
'/Users/maxtune/workspace/personal/Afilmory/packages/docs/content/en/index.mdx'
]
}
error during build:
[@mdx-js/rollup] Unexpected `FunctionDeclaration` in code: only import/exports are supported
file: /Users/maxtune/workspace/personal/Afilmory/packages/docs/content/en/index.mdx
ELIFECYCLE Command failed with exit code 1.
---
_**Assistant**_
看起来 MDX 插件在 SSR 构建过程中遇到了问题。这个错误表明 MDX 文件中有函数声明,但在 ESM 模式下只支持 import/export 语句。
让我先检查一下生成的路由文件,看看是否有问题: 问题在于 MDX 插件在 SSR 构建时无法正确处理 MDX 文件。这可能是因为 MDX 插件配置在 SSR 模式下存在问题。让我检查一下 Vite 配置中的 MDX 配置: 我需要修改 SSG 插件,在 SSR 构建时也要包含 MDX 插件的正确配置。问题是当前的插件过滤逻辑过于简单,没有考虑到 MDX 插件的依赖关系。
让我修改 SSG 插件的插件过滤逻辑:
```
```
---

View File

@@ -0,0 +1,25 @@
<!-- Generated by SpecStory -->
# 在这个脚本中,执行 #file:main-static.js 然后将得到的html合并到 #fil (2025-07-20 06:55Z)
_**User**_
在这个脚本中,执行 #file:main-static.js 然后将得到的html合并到 #file:index.html 的占位符中
---
_**Assistant**_
我需要先查看当前的build.ts文件内容然后了解如何执行main-static.js并将结果合并到index.html中。 现在我需要查看packages/docs的package.json来了解项目结构和依赖 让我查看vite.config.ts来了解构建配置 现在让我检查src目录结构 让我查看main-static.tsx文件 现在我理解了架构。我需要创建一个build.ts脚本它将
1. 导入并执行main-static.js中的render函数
2. 获取返回的HTML
3. 将HTML插入到index.html模板的占位符中
让我创建这个脚本:
```
```
现在让我测试这个脚本是否能正常工作。首先需要确保静态文件已经构建: 很好!现在运行我们的构建脚本: 让我检查是否需要安装tsx
---

View File

@@ -0,0 +1,103 @@
<!-- Generated by SpecStory -->
# 在这里写一个contents的约定式路由生成器插件使用index作为首页输出一个route.ts (2025-07-20 08:15Z)
_**User**_
在这里写一个contents的约定式路由生成器插件使用index作为首页输出一个route.ts文件到src里面
---
_**Assistant**_
我来帮你创建一个约定式路由生成器插件。首先让我查看一下 `` 的结构和现有文件,了解项目的配置和需求。 现在让我检查一下现有的插件目录和内容: 现在我来创建约定式路由生成器插件。这个插件将扫描 `contents` 目录,根据文件结构生成路由配置,并输出到 `src/routes.ts` 文件中。 我需要修复这些错误。首先安装 glob 依赖,然后修复类型问题: 现在修复代码中的 lint 错误:
```
```
```
```
```
```
```
```
```
```
现在让我更新 vite 配置文件来使用这个插件:
```
```
现在让我测试一下插件是否能正常工作。我需要先调整 MDX 的配置,让它正确处理 contents 目录:
```
```
现在让我创建一些测试内容来验证路由生成器: 现在让我更新 `index.md` 文件,添加一些元数据:
```
```
现在让我测试路由生成器插件。首先运行构建命令来触发插件: 让我直接检查一下是否生成了路由文件: 让我用 TypeScript 运行: 让我修复插件中的一些问题。首先检查当前工作目录和相对路径: Copilot has been working on this problem for a while. It can continue to iterate, or you can send a new message to refine your prompt.
---
_**User**_
@agent Pause: "Continue to iterate?"
---
_**Assistant**_
Let me know if there's anything else I can help with!
---
_**User**_
alias没配好导致模块解析报错
---
_**Assistant**_
我看到路由文件已经成功生成了!但是确实有模块解析的问题,导入路径使用了 `@/contents/` 这样的别名,但 Vite 配置中没有设置对应的别名。让我修复这个问题。
首先检查当前的 Vite 配置: 现在我需要添加路径别名配置,并修复路由生成器中的导入路径。有两种解决方案:
1. 在 Vite 配置中添加 `@` 别名指向根目录
2. 修改路由生成器使用相对路径
我选择第一种方案,这样更符合现代前端项目的惯例:
```
```
现在还需要更新 TypeScript 配置以支持路径别名:
```
```
现在我还需要修复路由文件中的一个小问题。我注意到 API 路径缺少前导斜杠: 我发现了问题API 路径缺少前导斜杠。让我修复路由生成器中的逻辑: 我看到问题了。在处理 `api/index` 这种情况时,路径被替换为空字符串,然后 `|| '/'` 应该生效,但实际上没有。让我修复这个逻辑:
```
```
现在重新生成路由文件:
---
_**User**_
需要加上文件后缀,否则没办法正常解析
---
_**Assistant**_
---

View File

@@ -0,0 +1,542 @@
<!-- Generated by SpecStory -->
# React component rendering issue resolution (2025-07-20 08:40Z)
_**User**_
Functions are not valid as a React child. This may happen if you return MDXContent$3 instead of <MDXContent$3 /> from render 这个一般怎么解决
---
_**Assistant**_
这个错误是因为在 React 中返回了一个函数组件而不是 JSX 元素。让我先查看一下相关的代码来确认问题。 问题出现在 `App` 组件中。目前代码是这样的:
```javascript
function App({ url }) {
return jsx(Fragment, { children: routes.map((route) => {
if (route.path === url) {
return route.component; // 问题在这里返回的是组件函数而不是JSX元素
}
}) });
}
```
这里 `route.component` 是一个组件函数(如 `MDXContent$3`),但你需要渲染它作为 JSX 元素。
解决方案有几种:
## 方案 1在 App 组件中正确渲染组件
```javascript
function App({ url }) {
const matchedRoute = routes.find(route => route.path === url);
if (!matchedRoute) {
return jsx(Fragment, { children: null });
}
const Component = matchedRoute.component;
return jsx(Component, {});
}
```
## 方案 2修改 routes 配置,直接存储 JSX 元素
```javascript
const routes = [
{
path: "/",
component: jsx(MDXContent$3, {}), // 存储 JSX 元素而不是组件函数
title: "Afilmory Documentation",
meta: {
"title": "Afilmory Documentation",
"description": "Official documentation for Afilmory photo gallery system"
}
},
// ... 其他路由
];
function App({ url }) {
const matchedRoute = routes.find(route => route.path === url);
return jsx(Fragment, {
children: matchedRoute ? matchedRoute.component : null
});
}
```
## 方案 3使用 createElement推荐
```javascript
import { createElement } from 'react';
function App({ url }) {
const matchedRoute = routes.find(route => route.path === url);
if (!matchedRoute) {
return jsx(Fragment, { children: null });
}
return jsx(Fragment, {
children: createElement(matchedRoute.component)
});
}
```
**推荐使用方案 1 或方案 3**,因为它们保持了组件的灵活性,允许传递 props并且更清晰易懂。
根据你的代码结构,最简洁的修复是:
````javascript
function App({ url }) {
const matchedRoute = routes.find(route => route.path === url);
if (!matchedRoute) {
return jsx(Fragment, { children: null });
}
const Component = matchedRoute.component;
return jsx(Component, {});
}
````
这样就能正确渲染组件而不是返回函数本身了。
---
_**User**_
那你看下这边呢
---
_**Assistant**_
这个 `App.tsx` 文件也有同样的问题。让我先查看一下 routes 的结构来确认: 可以看到 routes 中的 `component` 是 `React.ComponentType`,也就是组件函数。在你的 `App.tsx` 中,问题在于:
1. `return route.component` - 这返回的是组件函数,不是 JSX 元素
2. 语法错误:`else <div>404</div>` 应该是 `return <div>404</div>`
这里是修复后的代码:
````tsx
import './App.css'
import routes from './routes'
function App({ url }: { url?: string }) {
if (import.meta.env.DEV) {
return <div>1</div>
}
const matchedRoute = routes.find(route => route.path === url)
if (!matchedRoute) {
return <div>404</div>
}
const Component = matchedRoute.component
return <Component />
}
export default App
````
主要修改:
1. **修复组件渲染**`const Component = matchedRoute.component; return <Component />` - 正确渲染组件
2. **修复逻辑结构**:使用 `if` 语句而不是三元运算符,更清晰
3. **修复 404 处理**:当没有匹配的路由时正确返回 404 页面
4. **使用 `find()`**:更简洁地查找匹配的路由
这样就解决了 "Functions are not valid as a React child" 的错误。
---
_**User**_
这里不止要渲染根目录了需要读取routes里面的路径然后遍历渲染所有的html并输出到对应的目录下面。写完之后不需要你来测试稍微和我讲讲思路就行
---
_**Assistant**_
我来帮你修改这个构建脚本,让它能够遍历所有路由并生成对应的 HTML 文件。
```
```
让我修复这些 TypeScript 错误:
```
```
```
```
现在让我检查一下 routes 的类型定义: 很好,现在让我使用正确的类型定义:
```
```
```
```
现在让我修复 meta 属性访问的问题:
```
```
现在让我修复 title 访问的问题:
```
```
## 思路讲解
这个修改后的构建脚本的核心思路是:
### 1. **批量渲染所有路由**
- 遍历 `routes` 数组中的每个路由配置
- 对每个路由调用 `staticModule.render(route.path)` 生成对应的 HTML
### 2. **目录结构映射**
- `/` → `dist/index.html` (根目录)
- `/api` → `dist/api/index.html`
- `/api/builder` → `dist/api/builder/index.html`
- `/getting-started` → `dist/getting-started/index.html`
### 3. **动态生成 meta 标签**
- 从路由的 `meta` 配置中提取 `title` 和 `description`
- 生成 SEO 友好的 meta 标签(包括 Open Graph 标签)
- 每个页面都有独立的标题和描述
### 4. **文件系统操作**
- 使用 `mkdir({ recursive: true })` 确保目录存在
- 为每个路由生成独立的 HTML 文件
- 保持 URL 结构与文件系统结构一致
### 5. **模板系统**
- 读取基础 HTML 模板一次
- 为每个路由替换占位符:`<!--app-html-->`、`<!--app-title-->`、`<!--app-head-->`
- 每个页面都有完整的 HTML 结构
这样生成的静态网站可以直接部署到任何静态托管服务,每个页面都是独立的 HTML 文件,有利于 SEO 和加载性能。
---
_**User**_
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/contents' imported from /Users/maxtune/workspace/personal/Afilmory/packages/docs/src/routes.ts 是否是ts配置有问题
---
_**Assistant**_
这个错误是因为 `@/contents` 路径别名在运行时无法解析。问题在于:
1. **TypeScript 编译时**`@/` 别名可以正确解析
2. **Node.js 运行时**:无法识别 `@/` 路径别名,需要实际的相对路径
让我检查一下相关的配置文件: 问题确实是路径别名的问题。在构建后的 JavaScript 文件中,`@/contents` 无法被 Node.js 解析。有几种解决方案:
## 方案 1修改 `routes.ts` 使用相对路径
```
```
## 方案 2使用 tsx 或 ts-node 运行构建脚本
如果你想保持 `@/` 别名,可以修改构建脚本的运行方式: 很好,看到你已经在使用 `tsx` 了。让我检查一下是否需要添加路径解析配置。
## 方案 3在 tsx 运行时添加路径解析
可以在 `scripts/build.ts` 中添加动态的路径解析:
```
```
现在推荐的解决方案是:
## 最佳解决方案
我已经将 `routes.ts` 中的 `@/` 别名改为相对路径 `../`,这是最直接的解决方案,因为:
1. **兼容性好**:无论是开发环境还是生产环境都能正确解析
2. **无需额外配置**:不需要配置复杂的路径解析
3. **明确性**:相对路径更明确,便于理解项目结构
如果你更喜欢保持 `@/` 别名,可以:
1. **使用 `tsx` 运行时解析**:确保 `tsx` 能正确处理路径别名
2. **配置构建工具**:在 Vite 构建时将路径别名转换为相对路径
3. **使用 `tsconfig-paths`**:在 Node.js 运行时动态解析路径
但相对路径是最简单、最可靠的方案。
---
_**User**_
修改这个插件多生成一个json不包含component参数只有基本信息
---
_**Assistant**_
我来修改这个插件,让它同时生成一个不包含 component 参数的 JSON 文件,只包含基本的路由信息。
```
```
```
```
现在我需要添加生成 JSON 的函数:
```
```
现在让我们看看修改后的效果。这个插件现在会:
## 新增功能
1. **生成额外的 JSON 文件**:默认为 `src/routes.json`
2. **JSON 文件内容**:只包含基本路由信息,不包含 React 组件引用
## 生成的文件对比
**`routes.ts`**(原有功能):
```typescript
import Route0 from '../contents/index.md'
// ... 其他导入
export const routes: RouteConfig[] = [
{
path: '/',
component: Route0, // React 组件
title: 'Home',
meta: { ... }
}
// ...
]
```
**routes.json**(新增功能):
```json
[
{
"path": "/",
"title": "Home",
"meta": {
"title": "Home",
"description": "..."
}
},
{
"path": "/api",
"title": "API Reference",
"meta": {
"title": "API Reference",
"description": "..."
}
}
]
```
## 使用场景
这个 JSON 文件可以用于:
1. **静态站点地图生成**:用于生成 sitemap.xml
2. **导航菜单**:前端可以直接读取生成导航
3. **API 接口**:提供给其他系统使用的路由信息
4. **构建脚本**:像你的 `build.ts` 可以读取这个 JSON 而不需要导入 React 组件
## 自定义配置
如果需要自定义 JSON 文件名,可以在插件配置中指定:
```typescript
routeGenerator({
jsonFile: 'routes-manifest.json' // 自定义文件名
})
```
这样就能同时生成 TypeScript 路由配置和纯数据的 JSON 文件了。
---
_**User**_
为什么在dev环境下会水合错误
---
_**Assistant**_
水合错误通常是因为服务端渲染(SSR)的 HTML 和客户端渲染的内容不匹配。让我检查一下你的代码来诊断问题。 让我查看当前的 `App.tsx` 文件: 现在让我查看当前的 `App.tsx` 文件: 看起来问题确实是在 App 组件中。基于你提供的代码片段和水合错误,问题是:
**在开发环境中,服务端渲染(SSR)的内容和客户端渲染的内容不匹配**
从你的 `main-client.tsx` 可以看出是要做客户端水合:
```tsx
hydrateRoot(
document.getElementById('root')!,
<StrictMode>
<App url={url_path} />
</StrictMode>,
)
```
但是在 `App.tsx` 中:
```tsx
{import.meta.env.DEV ? (
<div>1</div> // 开发环境显示 "1"
) : (
routes.map((route) => {
if (route.path === url) {
return route.component // 生产环境返回组件函数(这里也有问题)
} else <div>404</div> // 语法错误
})
)}
```
## 问题分析:
1. **开发/生产环境内容不同**:开发环境显示 `<div>1</div>`,而生产环境显示路由内容
2. **服务端和客户端环境变量不同**`import.meta.env.DEV` 在 SSR 和客户端可能有不同的值
3. **语法错误**`else <div>404</div>` 语法不正确
## 解决方案:
````tsx
import './App.css'
import routes from './routes'
function App({ url }: { url?: string }) {
// 统一处理,不区分开发/生产环境
const matchedRoute = routes.find(route => route.path === url)
if (!matchedRoute) {
return <div>404 - Page Not Found</div>
}
const Component = matchedRoute.component
return <Component />
}
export default App
````
## 为什么会有水合错误:
1. **环境不一致**SSR 时 `import.meta.env.DEV` 可能是 `false`,客户端可能是 `true`
2. **内容不匹配**:这导致 SSR 生成的 HTML 和客户端首次渲染的内容不同
3. **React 水合失败**React 检测到差异后抛出水合错误
## 最佳实践:
- **避免在渲染逻辑中使用环境变量**,特别是会导致 SSR 和客户端不一致的情况
- **保持 SSR 和客户端渲染内容一致**
- **使用 `useEffect` 处理仅客户端的逻辑**
修改后的代码会确保 SSR 和客户端渲染相同的内容,从而避免水合错误。
---
_**User**_
match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<App url="/">
<MDXContent>
+ <hr>
...
---
_**Assistant**_
这个水合错误提示说明服务端渲染的 HTML 和客户端渲染的内容不匹配。根据错误信息,问题出现在 MDX 内容的渲染上。
让我检查一下相关的代码: 问题在于:
1. **客户端使用了 `import.meta.env.BASE_URL` 作为 fallback**
2. **SSR 和客户端的环境变量可能不同**
3. **URL 路径解析不一致**
让我检查 main-static.tsx 文件: 现在我明白问题了。关键问题是:
**客户端和服务端使用不同的默认 URL 值**
- **客户端**:使用 `import.meta.env.BASE_URL`(可能是 `/`
- **服务端**:传入明确的 URL 参数
## 解决方案:
````tsx
import './App.css'
import routes from './routes'
function App({ url }: { url?: string }) {
// 标准化 URL确保 SSR 和客户端一致
const normalizedUrl = url || '/'
console.log('App rendered with URL:', normalizedUrl)
const matchedRoute = routes.find(
(route) => route.path === normalizedUrl
)
if (!matchedRoute) {
return <div>404</div>
}
const Component = matchedRoute.component
return <Component />
}
export default App
````
同时需要确保客户端传入正确的 URL
````tsx
import { StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
// 确保传入与 SSR 相同的 URL 格式
const url_path = window.location.pathname || '/'
hydrateRoot(
document.getElementById('root')!,
<StrictMode>
<App url={url_path} />
</StrictMode>,
)
````
## 为什么会有这个问题:
1. **SSR**:构建脚本调用 `render('/')` 传入明确的路径
2. **客户端**:使用 `window.location.pathname`,但有 `import.meta.env.BASE_URL` 作为 fallback
3. **不匹配**:如果 `import.meta.env.BASE_URL` 不是 `/`,就会导致不同的路由匹配
## 最佳实践:
- **保持路径解析逻辑一致**SSR 和客户端使用相同的默认值
- **避免使用可能不同的环境变量**:特别是在路由解析中
- **明确处理 fallback**:使用明确的默认值而不是依赖环境变量
这样修改后SSR 和客户端会渲染相同的内容,避免水合错误。
---

View File

@@ -0,0 +1,128 @@
<!-- Generated by SpecStory -->
# Hydration mismatch error in React SSR application (2025-07-20 09:15Z)
_**User**_
match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<App url="/">
<MDXContent>
+ <hr>
...
---
_**Assistant**_
I can see you're encountering a hydration mismatch error in your React SSR application. This error occurs when the server-rendered HTML doesn't match what React expects to render on the client side.
Let me examine your current App component and the broader context to help identify the issue. Now let me check the routes structure to understand the issue better: Based on the hydration mismatch error you're encountering and looking at your `App.tsx` code, the issue is that you're returning a component function instead of rendering it as a JSX element. Here's the fix:
## The Problem
In your current `App.tsx`:
```tsx
const Component = matchedRoute.component
return <Component />
```
This looks correct, but based on the error message mentioning `<hr>` elements and hydration mismatch, the issue is likely that:
1. The server-side rendering and client-side rendering are producing different content
2. There might be environment-specific conditional rendering causing the mismatch
## The Solution
Here's the corrected `App.tsx`:
````tsx
import './App.css'
import routes from './routes'
function App({ url }: { url?: string }) {
console.log('App rendered with URL:', url, import.meta.env)
const matchedRoute = routes.find((route) => route.path === url)
if (!matchedRoute) {
return <div>404</div>
}
const Component = matchedRoute.component
return <Component />
}
export default App
````
Wait, your current code actually looks correct. The issue might be elsewhere. Let me check if there are any environment-specific conditionals or dynamic content that could cause hydration mismatches: The hydration mismatch is likely caused by the `console.log` statement that includes `import.meta.env`, which might behave differently between server and client. Let me check the main client file: Let me check the `main-client.tsx` file: Now let me check the `main-client.tsx` file directly: Based on my analysis of the hydration mismatch error and your `App.tsx` file, the issue is clear. The problem is that you have a `console.log` statement that includes `import.meta.env`, which can have different values between server-side rendering and client-side rendering, causing a hydration mismatch.
## The Problem
The hydration mismatch occurs because:
1. **Console.log with environment variables**: The line `console.log('App rendered with URL:', url, import.meta.env)` outputs different content on server vs client
2. **MDX content generation**: The error mentions `<hr>` element, which suggests your MDX content contains elements that are being generated differently
## The Solution
Here's the corrected `App.tsx` to fix the hydration mismatch:
````tsx
import './App.css'
import routes from './routes'
function App({ url }: { url?: string }) {
// Remove the console.log that causes hydration mismatch
// console.log('App rendered with URL:', url, import.meta.env)
const matchedRoute = routes.find((route) => route.path === url)
if (!matchedRoute) {
return <div>404</div>
}
const Component = matchedRoute.component
return <Component />
}
export default App
````
## Additional Recommendations
1. **Avoid environment-specific content**: Don't use `import.meta.env`, `process.env`, or any other environment-specific values that might differ between SSR and client-side rendering in your render logic.
2. **Use useEffect for debug logging**: If you need debugging, move it to a `useEffect`:
````tsx
import { useEffect } from 'react'
function App({ url }: { url?: string }) {
useEffect(() => {
if (import.meta.env.DEV) {
console.log('App rendered with URL:', url, import.meta.env)
}
}, [url])
// ... rest of your component
}
````
3. **Ensure consistent route matching**: Make sure the URL normalization is consistent between server and client.
The main issue was the `console.log` statement that included `import.meta.env`, which React detected as causing different output between server and client renders, leading to the hydration mismatch error.
---

View File

@@ -0,0 +1,81 @@
<!-- Generated by SpecStory -->
# 创建一个sidebar组件在components目录中渲染所有的目录 (2025-07-20 09:22Z)
_**User**_
创建一个sidebar组件在components目录中渲染所有的目录
---
_**Assistant**_
我需要先了解当前的项目结构和组件组织方式然后为docs包创建一个sidebar组件来渲染目录。让我先查看相关的文件结构。 现在我了解了项目结构。让我查看一下现有的组件和样式然后创建一个sidebar组件来渲染文档目录。 现在我需要查看应用的样式配置然后创建sidebar组件。 现在我来创建一个sidebar组件。基于项目中使用的Apple UIKit颜色系统和Tailwind CSS我将创建一个现代化的sidebar组件。 现在我需要更新App.tsx来集成这个sidebar组件并创建一个合适的布局。
```
```
现在让我检查一下是否需要添加Tailwind CSS的Apple UIKit颜色配置。让我查看项目的Tailwind配置。 让我检查根目录是否有Tailwind配置或者需要为docs包创建一个配置。 我需要为docs包添加tailwindcss-uikit-colors依赖并更新CSS文件以包含Apple UIKit颜色。
```
```
```
```
现在让我为MDX内容添加一些基本的排版样式以确保文档内容在sidebar布局中显示良好。
```
```
现在让我创建一个更简洁的组件导出文件,方便其他地方使用: 让我安装新的依赖项: 现在让我启动开发服务器来测试sidebar组件
---
_**User**_
theme 好像有点问题参考这个https://github.com/Innei/apple-uikit-colors/blob/main/packages/tailwindcss-uikit-colors/src/v4/macos.css
---
_**Assistant**_
我来查看一下参考的CSS文件了解正确的Apple UIKit颜色主题格式然后修复当前的样式问题。 现在我明白了问题所在。从参考的CSS文件可以看到Apple UIKit颜色系统使用的是不同的命名约定。让我检查当前的样式文件并根据正确的颜色命名更新它。 我需要查看一下tailwind.css文件的内容 现在我需要检查项目中的Sidebar组件看看实际的问题 现在我需要检查App.tsx中的样式 根据我从参考CSS文件中了解到的信息问题是在Tailwind类名的使用上。从Apple UIKit颜色系统的CSS文件可以看到正确的颜色类名应该是
- `text` 而不是 `text-primary`
- `text-secondary` 而不是 `text-text-secondary`
- `fill` 而不是 `fill-primary`
- 等等
让我修复Sidebar组件中的类名
```
```
```
```
现在修复App.tsx中的类名
```
```
```
```
现在更新CSS文件中的样式使用正确的颜色类名
---
_**User**_
5:42:56 PM [vite] Internal server error: Could not resolve value for theme function: `theme(colors.text.primary)`. Consider checking if the path is correct or provide a fallback value to silence this error.
---
_**Assistant**_
我看到问题了。错误显示 `theme(colors.text.primary)` 无法解析这是因为在CSS中使用了错误的颜色路径。根据Apple UIKit颜色系统正确的颜色类名应该是 `text``text-secondary` 等,而不是 `text.primary`
让我先检查当前的CSS文件内容然后修复这些主题函数调用 现在让我检查tailwind.css文件 现在我需要根据Apple UIKit颜色系统的正确格式来修复CSS文件。根据参考的CSS文件正确的颜色变量应该是
---

View File

@@ -0,0 +1,16 @@
<!-- Generated by SpecStory -->
# 简化frontmatter格式及其渲染只保留创建时间和最后修改时间在页面上渲染在文章最下面 (2025-07-20 09:47Z)
_**User**_
简化frontmatter格式及其渲染只保留创建时间和最后修改时间在页面上渲染在文章最下面
---
_**Assistant**_
我来帮您简化 frontmatter 格式,只保留创建时间和最后修改时间,并在页面底部渲染。首先让我查看当前的文档结构和 frontmatter 相关代码。 现在让我查看处理 frontmatter 的代码: 让我查看 `routes.ts` 文件的完整内容: 现在让我查看是否有处理 frontmatter 的插件或代码:
---

View File

@@ -0,0 +1,84 @@
<!-- Generated by SpecStory -->
# 图标替换为lucid-react的图标只有点击箭头可以收起但是进入了该页面默认打开 (2025-07-20 10:10Z)
_**User**_
图标替换为lucid-react的图标只有点击箭头可以收起但是进入了该页面默认打开
---
_**Assistant**_
我理解您的需求:
1. 将SVG图标替换为lucide-react的图标
2. 只有点击箭头才能收起/展开
3. 当进入该页面时,默认展开
让我先检查是否已经安装了lucide-react然后进行相应的修改。 很好lucide-react已经安装了。现在我来修改Sidebar组件
```
```
现在我需要修改NavigationItemComponent实现以下功能
1. 替换SVG为ChevronRight图标
2. 检查当前路径或其子路径是否匹配,如果匹配则默认展开
3. 将点击事件分离:点击标题导航,点击箭头收起/展开
```
```
现在我需要修改按钮结构,将其分为两个部分:标题部分和箭头部分:
```
```
现在让我检查一下修改后的代码是否有错误: 完美!现在让我读取完整的文件来确保所有修改都正确: 完美!我已经成功完成了您要求的所有修改:
## 修改总结
1. **替换图标**将原来的SVG图标替换为`lucide-react``ChevronRight`图标
2. **分离点击事件**
- 标题部分点击:只导航到对应页面
- 箭头部分点击:只控制展开/收起状态
- 使用`e.stopPropagation()`防止事件冒泡
3. **智能默认展开**
- 使用`currentPath?.startsWith(item.path)`检查当前页面是否在该节点或其子节点下
- 如果匹配,则默认展开该节点
4. **改进的UI结构**
- 将原来的单个按钮分为两个部分:标题按钮和箭头按钮
- 箭头按钮有独立的hover效果`hover:bg-fill-quaternary`
- 保持了原有的视觉样式和动画效果
现在用户体验更加直观:
- 点击文档标题可以直接导航
- 只有点击箭头图标才会展开/收起子菜单
- 当前页面所在的分类会自动展开,方便用户了解当前位置
---
_**User**_
如果进入页面则打开二级菜单
---
_**Assistant**_
我理解您的需求。您希望当进入某个页面时,如果该页面属于某个二级菜单,那么该二级菜单应该自动展开。
让我先检查routes结构来了解路径层级关系然后修改展开逻辑。 现在我看到了路径结构。我需要修改展开逻辑,使得当访问某个子页面时(比如 `/api/builder`),其父级菜单(`/api`)会自动展开。
当前的逻辑是 `currentPath?.startsWith(item.path)`,这会检查当前路径是否以该项目的路径开头。但这个逻辑需要更精确,我们需要:
1. 如果当前路径是某个父级路径的子路径,则展开该父级
2. 如果当前路径就是该项目本身,也要展开(如果有子项目)
让我修改展开逻辑:
```
```
---

View File

@@ -0,0 +1,102 @@
<!-- Generated by SpecStory -->
# 优化docs的页面设计 (2025-07-20 11:38Z)
_**User**_
优化docs的页面设计
---
_**Assistant**_
我来帮你优化 docs 页面的设计。首先让我了解一下当前的代码结构和样式设置。 现在让我查看一些文档内容来了解当前的结构: 现在我来优化 docs 页面的设计。基于当前的代码结构和 Apple UIKit 设计系统,我将提供以下改进:
1. **改进主应用布局和视觉设计**
2. **优化侧边栏设计**
3. **增强文档阅读体验**
4. **添加响应式设计**
5. **改进样式系统**
让我开始优化:
```
```
我需要先创建 TableOfContents 组件: 现在优化 Sidebar 组件:
```
```
现在优化 DocumentMeta 组件:
```
```
现在优化样式文件:
```
```
现在创建一个搜索组件来增强用户体验: 现在更新 Sidebar 组件以包含搜索功能:
```
```
```
```
现在创建一个面包屑导航组件:
```
```
```
```
现在将面包屑导航添加到主应用中:
```
```
```
```
现在创建一个主题切换组件: Copilot has been working on this problem for a while. It can continue to iterate, or you can send a new message to refine your prompt.
---
_**User**_
不要有硬黑色描边使用vercel设计风格如图所示
---
_**Assistant**_
我看到您希望采用 Vercel 的设计风格,避免硬黑色描边。根据附图中的 Vercel 设计,我来优化文档页面的设计风格,使其更加现代和柔和。
让我更新样式和组件:
```
```
```
```
```
```
```
```
```
```
现在更新 Sidebar 组件,采用 Vercel 风格的设计:
```
```
---

View File

@@ -0,0 +1,90 @@
<!-- Generated by SpecStory -->
# 使用苹果风格优化设计 #file:CLAUDE.md (2025-07-20 11:53Z)
_**User**_
使用苹果风格优化设计 #file:CLAUDE.md
---
_**Assistant**_
我将按照苹果风格优化设计你的文档样式。让我先查看当前的样式文件,然后应用苹果的设计系统。 现在我将根据苹果的设计原则优化文档样式。我会使用苹果的 UIKit 颜色系统和设计语言来改进当前的样式:
```
```
```
```
```
```
```
```
```
```
我已经按照苹果风格设计原则优化了文档样式。主要改进包括:
## 🍎 苹果风格优化特性
### 1. **Typography (字体系统)**
- 使用苹果系统字体:`SF Pro Display` (标题) 和 `SF Pro Text` (正文)
- 优化字体渲染:`-webkit-font-smoothing: antialiased`
- 精确的字间距和行高调整
- 使用 `SF Mono` 作为代码字体
### 2. **颜色系统**
- 迁移到苹果 UIKit 颜色变量:
- `--text-primary`, `--text-secondary`, `--text-tertiary`
- `--fill-secondary`, `--fill-quaternary`
- `--separator-opaque`, `--separator-non-opaque`
- `--accent` 用于链接和强调色
### 3. **视觉层次**
- 更大的标题尺寸和更好的间距
- 改进的代码块样式,带有边框和阴影
- 苹果风格的圆角设计 (8px, 12px)
- 优雅的列表项目符号和编号
### 4. **交互设计**
- 链接的悬停效果和微妙的背景色变化
- 点击时的轻微缩放效果 (`transform: scale(0.98)`)
- 焦点状态的可访问性优化
### 5. **组件样式**
- **引用块**: 带有左侧强调条、背景色和引号装饰
- **表格**: 圆角、分隔线和悬停效果
- **图片**: 圆角和阴影效果
- **徽章**: 类似苹果系统的标签样式
### 6. **响应式和暗色模式**
- 针对暗色模式的特殊优化
- 改进的阴影和透明度设置
这个设计现在完全符合苹果的 Human Interface Guidelines提供了更好的可读性、优雅的视觉层次和一致的用户体验。文档现在具有苹果生态系统中应用的现代、精致外观。
---
_**User**_
这里也需要修改,去掉硬的黑色描边,有需要的话可以使用阴影、灰色描边来强调层次
---
_**Assistant**_
我将优化 App.tsx 中的样式,移除硬黑色描边,使用苹果风格的阴影和灰色分隔线来营造层次感。
```
```
---

View File

@@ -0,0 +1,114 @@
<!-- Generated by SpecStory -->
# 完成手机版适配 (2025-07-20 13:12Z)
_**User**_
完成手机版适配
---
_**Assistant**_
我来帮你完成手机版适配。首先让我看看当前的代码结构,了解需要优化的地方。 现在让我检查一下当前的样式配置,看看是否有响应式设计相关的设置: 现在让我查看vite配置了解当前的设置 现在让我检查一下当前的HTML模板 好的,现在我了解了当前的架构。让我开始实现手机版适配。主要需要做以下几个方面的改进:
1. 在手机上隐藏侧边栏,添加汉堡菜单
2. 优化布局和间距
3. 确保内容在小屏幕上可读
首先,我将修改 `App.tsx` 来添加移动端的响应式布局:
```
```
```
```
现在让我更新404页面的布局
```
```
现在更新主内容区域的布局:
```
```
现在需要移除import中不使用的useEffect
```
```
现在让我更新 Sidebar 组件,使其在移动端有更好的适配:
```
```
现在让我优化导航项的移动端显示,增加触摸友好的尺寸:
```
```
现在让我检查并优化 DocumentMeta 组件的移动端显示: 让我优化DocumentMeta组件在移动端的显示
```
```
现在让我更新样式文件,添加一些移动端特定的优化:
```
```
让我修正样式文件的@apply使用方式将其移到正确的layer中
```
```
看起来这个项目使用的是Tailwind CSS v4需要用不同的语法。让我检查一下Tailwind配置 我看到已经有单独的index.css文件来处理prose样式。让我修改这个文件来优化移动端显示而不是修改tailwind.css
```
```
```
```
Copilot has been working on this problem for a while. It can continue to iterate, or you can send a new message to refine your prompt.
---
_**User**_
@agent Pause: "Continue to iterate?"
---
_**Assistant**_
Let me know if there's anything else I can help with!
---
_**User**_
使用motion库添加一些动画效果注意引入的使用使用m引入这样可以让代码简洁一些动画不要过渡继续遵循苹果设计风格和原则
---
_**Assistant**_
我来帮你使用motion库添加一些符合苹果设计风格的动画效果。首先让我检查项目是否已经安装了framer-motion库。 很好!我看到项目已经安装了 `motion` 库。现在让我为App.tsx添加符合苹果设计风格的动画效果
```
```
现在让我为移动端侧边栏添加动画效果:
```
```
现在为移动端顶部栏添加淡入动画:
```
```
为404页面内容添加动画效果
```
```
现在为主页面的移动端侧边栏添加动画:
```
```
---

View File

@@ -0,0 +1,66 @@
<!-- Generated by SpecStory -->
# 使用苹果风格重新设计这个callout组件图标使用lucid-react (2025-07-20 14:07Z)
_**User**_
使用苹果风格重新设计这个callout组件图标使用lucid-react
---
_**Assistant**_
我将重新设计这个 Callout 组件,使用苹果风格的设计和 lucide-react 图标。让我先安装 lucide-react 依赖,然后更新组件。 让我重新运行安装命令: 现在我将使用苹果风格重新设计 Callout 组件,使用 lucide-react 图标和符合苹果设计规范的样式:
```
```
现在我将更新组件的主体部分,使用苹果风格的设计:
```
```
现在让我检查一下完整的文件以确保一切正确: 完美!我已经重新设计了 Callout 组件,使用苹果风格的设计和 lucide-react 图标。主要改进包括:
## 设计特色
### 🎨 苹果风格设计元素
- **圆角设计**: 使用 `rounded-2xl` (16px) 的大圆角,符合苹果的设计语言
- **半透明背景**: 使用 `/80` 透明度,创建苹果风格的毛玻璃效果
- **微妙阴影**: 使用 `shadow-sm``shadow-black/5`,在悬停时增强到 `shadow-md`
- **优雅间距**: 增加内边距到 `px-6 py-4`,外边距到 `my-6`
### 🎯 更好的用户体验
- **平滑过渡**: 添加 `transition-all duration-200 ease-out` 动画
- **悬停效果**: 鼠标悬停时阴影会变得更明显
- **无障碍性**: 保持 `role="alert"` 属性
- **响应式**: 使用 `flex-shrink-0``min-w-0` 确保布局稳定
### 🔤 图标升级
- **lucide-react 图标**: 使用现代、一致的图标系统
- `Info`: 信息提示
- `AlertTriangle`: 警告
- `AlertCircle`: 错误
- `CheckCircle`: 成功
- **颜色一致性**: 每种类型都有对应的语义颜色
### 🎨 颜色方案
- **信息**: 蓝色系 (`blue-50/80`, `blue-200/60`, `blue-900`)
- **警告**: 琥珀色系 (`amber-50/80`, `amber-200/60`, `amber-900`)
- **错误**: 红色系 (`red-50/80`, `red-200/60`, `red-900`)
- **成功**: 绿色系 (`green-50/80`, `green-200/60`, `green-900`)
这个新设计更符合现代苹果设计系统的美学,提供了更好的视觉层次和用户体验。
---
_**User**_
图标和文字第一行居中持平
---
_**Assistant**_
我将调整图标和文字的对齐方式,让图标与文字的第一行居中对齐。
---

View File

@@ -0,0 +1,30 @@
<!-- Generated by SpecStory -->
# #file:docs 给这个包写一个简单的readme介绍大概怎么使用以及怎么开发用英文不要 (2025-07-20 14:38Z)
_**User**_
#file:docs 给这个包写一个简单的readme介绍大概怎么使用以及怎么开发用英文不要用太多emoji
---
_**Assistant**_
I'll examine the docs package structure first to understand what it contains, then write a comprehensive README for it. Now I'll write a comprehensive README for the docs package based on my understanding of its structure and functionality.
```
```
I've created a comprehensive README for the docs package. The README covers:
## Key Sections:
1. **Overview** - What the package does and its main features
2. **Quick Start** - How to develop, build, and preview the docs
3. **Project Structure** - Clear explanation of the file organization
4. **Writing Documentation** - How to create content with MDX and routing conventions
5. **Development Guide** - Detailed instructions for adding content, components, and styling
6. **Configuration** - Technical details about Vite and plugin setup
7. **Deployment** - Options for hosting the
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,508 @@
<!-- Generated by SpecStory -->
# 不用苹果得了切换成这个https://www.npmjs.com/package/@pastel- (2025-07-21 14:23Z)
_**User**_
不用苹果得了切换成这个https://www.npmjs.com/package/@pastel-palette/tailwindcss?activeTab=code把类名替换成这个的
---
_**Assistant**_
I'll help you switch from the Apple UIKit color system to the Pastel Palette color system. Let me analyze the current Tailwind classes and replace them with the Pastel Palette equivalents.
## Solution Steps
1. Replace Apple UIKit semantic color classes with Pastel Palette equivalents
2. Update text colors, background colors, and border colors
3. Maintain the same visual hierarchy and contrast
### [Sidebar.tsx](file:///Users/maxtune/workspace/personal/Afilmory/packages/docs/src/components/Sidebar.tsx)
Replace Apple UIKit color classes with Pastel Palette color system classes.
````tsx
// ...existing code...
function NavigationItemComponent({
item,
currentPath,
onNavigate,
level = 0,
}: NavigationItemProps) {
// ...existing code...
return (
<div className="w-full">
<div
className={`
flex w-full
items-center transition-all duration-200 ease-in-out select-none
${
isActive
? 'text-blue-800 bg-blue-100 font-medium dark:bg-blue-900 dark:text-blue-200'
: 'hover:bg-gray-50 text-gray-600 hover:text-gray-900 dark:text-gray-400 hover:dark:text-gray-200 dark:hover:bg-gray-800'
}
${level > 0 ? 'pl-3' : ''}
`}
>
<button
onClick={handleTitleClick}
className="flex-1 truncate px-3 py-3 text-left text-base lg:py-2.5 lg:text-sm"
type="button"
>
{item.title}
</button>
{hasChildren && (
<button
onClick={handleArrowClick}
className="hover:bg-gray-100 dark:hover:bg-gray-700 mr-1 rounded-md p-3 transition-all duration-200 lg:p-2"
type="button"
>
<ChevronRight
className={`h-5 w-5 text-gray-500 transition-transform duration-200 lg:h-4 lg:w-4 dark:text-gray-500 ${
isExpanded ? 'rotate-90' : ''
}`}
/>
</button>
)}
</div>
// ...existing code...
</div>
)
}
export function Sidebar({ currentPath, onNavigate }: SidebarProps) {
const navigationTree = buildNavigationTree(routes)
return (
<aside className="bg-transparent relative h-screen w-64 overflow-x-hidden overflow-y-auto p-2 backdrop-blur-2xl">
<div className="border-gray-200 dark:border-gray-700 w-full border-[1px] border-solid">
<div className=" flex items-center px-4 py-6">
<img
src="https://github.com/Afilmory/assets/blob/main/512-mac.png?raw=true"
alt="Afilmory"
className="h-14 w-14 rounded-t-lg"
/>
<div className="ml-3 flex-1">
<h2 className="text-gray-900 dark:text-gray-100 text-xl font-semibold">
Afilmory
</h2>
<p className="text-gray-500 dark:text-gray-400 text-sm">Documentation</p>
</div>
</div>
<nav className="space-y-1">
{navigationTree.map((item) => (
<NavigationItemComponent
key={item.path}
item={item}
currentPath={currentPath}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
</aside>
)
}
````
The changes include:
1. **Active state colors**: Changed from `text-primary bg-blue-500/20` to `text-blue-800 bg-blue-100` with dark mode variants
2. **Hover colors**: Replaced `hover:bg-fill-tertiary` with `hover:bg-gray-50` and dark mode equivalents
3. **Text colors**: Updated `text-text-primary` to `text-gray-900` and `text-text-tertiary` to `text-gray-500`
4. **Border colors**: Changed `border-fill-1` to `border-gray-200` with dark mode support
5. **Button hover states**: Updated `hover:bg-fill-quaternary` to `hover:bg-gray-100`
These Pastel Palette classes provide a clean, accessible color scheme that maintains good contrast and visual hierarchy.
---
_**User**_
替换成新的设计系统通过apply
---
_**Assistant**_
I'll replace the Apple UIKit CSS variables with Pastel Palette Tailwind classes using `@apply` directives. This will make the styles consistent with the new design system.
## Solution Steps
1. Replace CSS custom properties (`var(--color-*)`) with Pastel Palette Tailwind classes
2. Use `@apply` directive to maintain the existing styling structure
3. Update color references to use standard Tailwind color palette
4. Ensure proper dark mode support
### [index.css](file:///Users/maxtune/workspace/personal/Afilmory/packages/docs/src/styles/index.css)
Replace Apple UIKit CSS variables with Pastel Palette Tailwind classes using `@apply`.
````css
@import './tailwind.css';
/* Apple-style documentation prose styling */
.prose {
@apply text-gray-900 dark:text-gray-100;
max-width: none;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
system-ui, sans-serif;
font-feature-settings:
'kern' 1,
'liga' 1,
'calt' 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 移动端优化 */
@media (max-width: 768px) {
.prose {
font-size: 0.9rem;
line-height: 1.65;
}
}
.prose h1 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 700;
font-size: 2.5rem;
line-height: 1.15;
letter-spacing: -0.025em;
margin-bottom: 1.5rem;
margin-top: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
/* 移动端标题优化 */
@media (max-width: 768px) {
.prose h1 {
font-size: 1.875rem;
margin-bottom: 1rem;
}
}
.prose h2 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 2rem;
line-height: 1.2;
letter-spacing: -0.02em;
margin-top: 3rem;
margin-bottom: 1.25rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
/* 移动端H2标题优化 */
@media (max-width: 768px) {
.prose h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
}
.prose h3 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1.5rem;
line-height: 1.3;
letter-spacing: -0.015em;
margin-top: 2rem;
margin-bottom: 1rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
/* 移动端H3标题优化 */
@media (max-width: 768px) {
.prose h3 {
font-size: 1.25rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
}
.prose h4 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1.25rem;
line-height: 1.35;
letter-spacing: -0.01em;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
.prose h5 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1.125rem;
line-height: 1.4;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
.prose h6 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1rem;
line-height: 1.5;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
.prose p {
@apply text-gray-900 dark:text-gray-100;
margin-bottom: 1.25rem;
line-height: 1.6;
font-size: 1rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
text-wrap: pretty;
}
.prose strong {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
}
.prose em {
font-style: italic;
@apply text-gray-900 dark:text-gray-100;
}
.prose code {
@apply bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border-gray-200 dark:border-gray-700;
padding: 0.2rem 0.4rem;
border-radius: 6px;
font-size: 0.9em;
font-family:
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
monospace;
font-weight: 500;
border: 1px solid;
}
@media (prefers-color-scheme: dark) {
.shiki,
.shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
.prose pre {
@apply bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border-gray-200 dark:border-gray-700;
border: 1px solid;
padding: 1.25rem;
border-radius: 12px;
overflow-x: auto;
margin: 1.5rem 0;
font-family:
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.prose pre code {
background-color: transparent;
padding: 0;
border: none;
border-radius: 0;
font-weight: 400;
}
.prose ul,
.prose ol {
@apply text-gray-900 dark:text-gray-100;
margin: 1.25rem 0;
padding-left: 1.75rem;
}
.prose ul {
list-style-type: none;
}
.prose ul > li {
position: relative;
}
.prose ul > li::before {
content: '•';
@apply text-gray-500 dark:text-gray-400;
font-weight: bold;
position: absolute;
left: -1.25rem;
top: 0;
}
.prose ol {
list-style-type: decimal;
}
.prose ol > li::marker {
@apply text-gray-500 dark:text-gray-400;
font-weight: 500;
}
.prose li {
margin: 0.75rem 0;
line-height: 1.6;
}
.prose li > p {
margin-bottom: 0.5rem;
}
.prose blockquote {
@apply border-l-4 border-blue-600 bg-blue-50 dark:bg-blue-900/20;
padding: 1.25rem 1.5rem;
margin: 2rem 0;
border-radius: 8px;
position: relative;
}
.prose blockquote p {
@apply text-gray-900 dark:text-gray-100;
font-style: italic;
margin-bottom: 0;
font-size: 1.1em;
line-height: 1.7;
}
.prose blockquote::before {
content: '"';
@apply text-gray-400 dark:text-gray-500;
font-size: 3rem;
font-weight: bold;
position: absolute;
top: -0.5rem;
left: 1rem;
opacity: 0.3;
}
.prose a {
@apply text-blue-600 dark:text-blue-400;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
border-radius: 4px;
padding: 0 2px;
}
.prose a:hover {
@apply text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20;
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
.prose a:active {
transform: scale(0.98);
}
/* Table styling */
.prose table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
@apply border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800;
border-radius: 8px;
overflow: hidden;
margin: 1.5rem 0;
}
.prose th {
@apply bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-600;
font-weight: 600;
padding: 0.75rem 1rem;
text-align: left;
}
.prose td {
padding: 0.75rem 1rem;
@apply border-b border-gray-100 dark:border-gray-700 text-gray-900 dark:text-gray-100;
}
.prose tr:last-child td {
border-bottom: none;
}
/* Horizontal rule */
.prose hr {
border: none;
@apply border-t border-gray-200 dark:border-gray-700;
margin: 3rem 0;
}
/* Image styling */
.prose img {
margin: 1.5rem 0;
}
/* Badge/Tag styling */
.prose .badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
@apply bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
}
/* Focus and accessibility */
.prose *:focus {
@apply outline-2 outline-blue-600 dark:outline-blue-400;
outline-offset: 2px;
}
/* Dark mode optimizations */
@media (prefers-color-scheme: dark) {
.prose img {
opacity: 0.9;
}
.prose pre {
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.3),
0 1px 2px 0 rgba(0, 0, 0, 0.2);
}
}
````
The changes include:
1. **Text colors**: Replaced `var(--color-text-1)` with `@apply text-gray-900 dark:text-gray-100`
2. **Background colors**: Updated fill colors to use gray palette with proper dark mode variants
3. **Border colors**: Changed separator colors to `border-gray-200 dark:border-gray-700`
4. **Link colors**: Updated to use blue color palette for links
5. **Code blocks**: Applied consistent gray backgrounds with proper contrast
6. **Tables**: Updated with proper gray styling and dark mode support
7. **Blockquotes**: Changed to use blue accent colors
8. **Focus states**: Updated to use blue outline colors
All styling now uses the Pastel Palette Tailwind color system while maintaining the same visual hierarchy and Apple-style design.
---

View File

@@ -27,7 +27,9 @@ export const ClusterPhotoGrid = ({
<h3 className="text-text text-sm font-semibold">
{t('explory.cluster.photos', { count: photos.length })}
</h3>
<div className="text-text-secondary text-xs">{t('explory.cluster.click.details')}</div>
<div className="text-text-secondary text-xs">
{t('explory.cluster.click.details')}
</div>
</div>
{/* 照片网格 */}
@@ -104,7 +106,9 @@ export const ClusterPhotoGrid = ({
<div className="text-text text-lg font-bold">
+{remainingCount}
</div>
<div className="text-text-secondary text-xs">{t('explory.cluster.more')}</div>
<div className="text-text-secondary text-xs">
{t('explory.cluster.more')}
</div>
</div>
</m.div>
)}

View File

@@ -85,7 +85,14 @@ export function convertExifGPSToDecimal(exif: PickedExif | null): {
return null
}
return { latitude, longitude, latitudeRef, longitudeRef, altitude, altitudeRef }
return {
latitude,
longitude,
latitudeRef,
longitudeRef,
altitude,
altitudeRef,
}
} catch (error) {
console.warn('Failed to parse GPS coordinates from EXIF:', error)
return null
@@ -132,7 +139,14 @@ export function convertPhotoToMarkerFromEXIF(
return null
}
const { latitude, longitude, latitudeRef, longitudeRef, altitude, altitudeRef } = gpsData
const {
latitude,
longitude,
latitudeRef,
longitudeRef,
altitude,
altitudeRef,
} = gpsData
return {
id: photo.id,

View File

@@ -4,10 +4,7 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Thumbhash } from '~/components/ui/thumbhash'
import {
useContextPhotos,
usePhotoViewer,
} from '~/hooks/usePhotoViewer'
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import {
CarbonIsoOutline,
MaterialSymbolsShutterSpeed,

View File

@@ -6,9 +6,7 @@ import { gallerySettingAtom } from '~/atoms/app'
import { DateRangeIndicator } from '~/components/ui/date-range-indicator'
import { useScrollViewElement } from '~/components/ui/scroll-areas/hooks'
import { useMobile } from '~/hooks/useMobile'
import {
useContextPhotos,
} from '~/hooks/usePhotoViewer'
import { useContextPhotos } from '~/hooks/usePhotoViewer'
import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
import { clsxm } from '~/lib/cn'

View File

@@ -6,10 +6,7 @@ import { PhotoViewer } from '~/components/ui/photo-viewer'
import { RootPortal } from '~/components/ui/portal'
import { RootPortalProvider } from '~/components/ui/portal/provider'
import { useTitle } from '~/hooks/common'
import {
useContextPhotos,
usePhotoViewer,
} from '~/hooks/usePhotoViewer'
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
export const Component = () => {
const photoViewer = usePhotoViewer()

View File

@@ -12,12 +12,17 @@
"build": "pnpm --filter @afilmory/ssr build",
"build:manifest": "pnpm --filter @afilmory/builder cli",
"build:update-remote-repo": "sh scripts/build-update-remote-repo.sh",
"create:doc": "tsx scripts/create-doc.ts",
"dev": "pnpm --filter @afilmory/ssr dev",
"docs:build": "pnpm --filter @afilmory/docs build",
"docs:dev": "pnpm --filter @afilmory/docs dev",
"docs:preview": "pnpm --filter @afilmory/docs preview",
"extract:font": "tsx scripts/extract-font-glyphs.ts",
"format": "prettier --write \"src/**/*.ts\" ",
"lint": "eslint --fix",
"preinstall": "sh scripts/preinstall.sh",
"prepare": "simple-git-hooks"
"prepare": "simple-git-hooks",
"update:lastmodified": "tsx scripts/update-lastmodified.ts"
},
"dependencies": {
"@afilmory/builder": "workspace:*",
@@ -27,6 +32,7 @@
"zod": "catalog:"
},
"devDependencies": {
"@clack/prompts": "^0.11.0",
"@innei/prettier": "0.15.0",
"@types/node": "24.0.4",
"consola": "3.4.2",
@@ -57,6 +63,9 @@
],
"*.{js,ts,cjs,mjs,jsx,tsx,json}": [
"eslint --fix"
],
"*.{md,mdx}": [
"pnpm update:lastmodified"
]
}
}

View File

@@ -1,4 +1,3 @@
import type { _Object } from '@aws-sdk/client-s3'
import { logger } from '../logger/index.js'

24
packages/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

226
packages/docs/README.md Normal file
View File

@@ -0,0 +1,226 @@
# @afilmory/docs
A modern documentation site generator built with Vite, React, and MDX. This package provides a file-system based routing documentation site with automatic route generation, syntax highlighting, and responsive design.
## Features
- **File-system based routing** - Automatic route generation from markdown files
- **MDX support** - Write JSX components directly in markdown
- **Syntax highlighting** - Code blocks with Shiki highlighting (light/dark themes)
- **Static site generation** - Build static HTML files for deployment
- **Hot reload** - Real-time updates during development
- **Responsive design** - Built with Tailwind CSS and Apple UIKit colors
- **TypeScript support** - Full type safety throughout the codebase
## Quick Start
### Development
Start the development server:
```bash
pnpm dev
```
This will start the Vite development server and watch for changes in the `contents/` directory.
### Building
Build the documentation site for production:
```bash
pnpm build
```
This runs three steps:
1. `build:client` - Builds the client-side React application
2. `build:static` - Generates static HTML files via SSR
3. `output` - Processes and finalizes the build output
### Preview
Preview the built site locally:
```bash
pnpm preview
```
## Project Structure
```
packages/docs/
├── contents/ # Documentation content (MDX/Markdown files)
│ ├── index.mdx # Homepage content
│ ├── getting-started.md
│ └── apps/ # Nested documentation sections
├── src/
│ ├── components/ # React components
│ ├── styles/ # CSS and styling
│ ├── routes.ts # Auto-generated routes (do not edit)
│ └── App.tsx # Main application component
├── plugins/
│ └── route-generater.ts # Custom Vite plugin for route generation
├── public/ # Static assets
└── scripts/ # Build scripts
```
## Writing Documentation
### File-based Routing
The documentation follows a file-system based routing convention:
- `contents/index.mdx``/` (homepage)
- `contents/getting-started.md``/getting-started`
- `contents/apps/index.md``/apps` (section index)
- `contents/apps/web.md``/apps/web`
### MDX Format
You can use standard Markdown with JSX components:
```mdx
---
title: Page Title
createdAt: 2025-01-20T10:00:00Z
lastModified: 2025-01-20T10:00:00Z
---
# Page Title
Regular markdown content here.
<div className="bg-blue-100 p-4 rounded">
Custom JSX component
</div>
## Code Examples
```typescript
const example = "syntax highlighted code";
```
```
### Frontmatter
Each documentation file can include frontmatter metadata:
```yaml
---
title: Page Title # Used for navigation and SEO
createdAt: 2025-01-20 # Creation date
lastModified: 2025-01-20 # Last modification date
description: Page description # Optional page description
---
```
## Development Guide
### Adding New Content
1. Create a new `.md` or `.mdx` file in the `contents/` directory
2. Add appropriate frontmatter metadata
3. The route will be automatically generated on save
4. The development server will hot-reload with your changes
### Custom Components
Create reusable components in `src/components/` and use them in MDX files:
```typescript
// src/components/InfoBox.tsx
export function InfoBox({ children }: { children: React.ReactNode }) {
return (
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
{children}
</div>
);
}
```
```mdx
<!-- In your MDX file -->
import { InfoBox } from '../src/components/InfoBox';
<InfoBox>
This is a custom info box component.
</InfoBox>
```
### Styling
The project uses:
- **Tailwind CSS** for utility-first styling
- **Apple UIKit colors** via `tailwindcss-uikit-colors`
- **Typography plugin** for prose styling
- **Custom scrollbar** styling
Use semantic color classes:
```css
/* Preferred */
.text-text-primary .bg-fill-secondary
/* Avoid */
.text-blue-500 .bg-gray-100
```
### Route Generation Plugin
The custom Vite plugin automatically generates routes from the file system:
- Watches the `contents/` directory for changes
- Generates `src/routes.ts` with route definitions
- Creates `src/routes.json` for metadata
- Handles index files and nested directories
## Configuration
### Vite Configuration
Key configuration in `vite.config.ts`:
- **MDX processing** with frontmatter support
- **Syntax highlighting** with Shiki
- **Route generation** plugin
- **Tailwind CSS** integration
- **Code inspector** for development
### Supported Languages
Code highlighting supports:
- JavaScript/TypeScript
- JSX/TSX
- MDX
- JSON
- Shell/Bash
## Deployment
The built site in `dist/` can be deployed to any static hosting service:
- **Vercel** - Zero config deployment
- **Netlify** - Drag and drop the `dist` folder
- **GitHub Pages** - Upload build artifacts
- **Cloudflare Pages** - Connect your repository
## Scripts Reference
- `pnpm dev` - Start development server
- `pnpm build` - Full production build
- `pnpm build:client` - Build client-side app only
- `pnpm build:static` - Generate static HTML via SSR
- `pnpm output` - Process build output
- `pnpm lint` - Run ESLint
- `pnpm preview` - Preview built site locally
## Contributing
When contributing to the documentation:
1. Use `pnpm create:doc` and follow instructions to create a new document.
2. Write your docs.
3. Test your changes locally with `pnpm dev`
4. Ensure the build passes with `pnpm build`
5. Use semantic commit messages
For more information about the Afilmory project architecture, see the main project documentation.

View File

@@ -0,0 +1,10 @@
---
title: Cloudflare Pages
description: Guide to deploying Afilmory via Cloudflare Pages.
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-08-08T17:39:40+08:00
---
# Cloudflare Pages Deployment
🚧 WIP

View File

@@ -0,0 +1,283 @@
---
title: Docker
description: Guide to deploying Afilmory via Docker.
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-08-31T11:08:35+08:00
---
# Docker Deployment
![Docker Deployment](https://github.com/Afilmory/assets/blob/main/docker-deploy.webp?raw=true)
## TL;DR
Fork https://github.com/Afilmory/docker, customize configuration and build image.
## 🚀 Quick Start
Using Docker to deploy Afilmory is a convenient and efficient way to manage your photo gallery application. This guide will walk you through the steps to set up and run Afilmory using Docker.
### Prerequisites
- Docker
- Docker Compose (optional but recommended)
- PostgreSQL database (for SSR app)
### 1. Configuration Files
Before building your Docker image, you'll need to configure the following files:
**`config.json`**
```json
{
"name": "Your Photo Gallery",
"title": "Your Photo Gallery",
"description": "Capturing beautiful moments in life",
"url": "https://your-domain.com",
"accentColor": "#fb7185",
"author": {
"name": "Your Name",
"url": "https://your-website.com",
"avatar": "https://your-avatar-url.com/avatar.png"
},
"social": {
"twitter": "@yourusername"
},
"extra": {
"accessRepo": true
}
}
```
**`builder.config.json`**
```json
{
"repo": {
"enable": false,
"url": "https://github.com/username/gallery-public"
},
"storage": {
"provider": "s3",
"bucket": "your-photos-bucket",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "cdn.yourdomain.com"
},
"performance": {
"worker": {
"enabled": true,
"maxWorkers": 4
}
}
}
```
**`.env`**
```bash
# Database Configuration
PG_CONNECTION_STRING=postgresql://user:password@postgres:5432/afilmory
# S3 Storage Configuration
S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key
# Git Repository (optional)
GIT_TOKEN=your_github_token
# Application Settings
NODE_ENV=production
PORT=3000
```
### 2. Dockerfile setup
Create a `Dockerfile` in your project root:
```dockerfile
# Dockerfile for Next.js app in a pnpm monorepo
# This Dockerfile should be built from the root of the monorepo:
# > docker build -t iris-ssr .
# > docker run -p 3000:3000 iris-ssr
# -----------------
# Base stage
# -----------------
FROM node:20-alpine AS base
WORKDIR /app
RUN corepack enable
# -----------------
# Builder stage
# -----------------
FROM base AS builder
RUN apk update && apk add --no-cache git perl
RUN git clone https://github.com/Afilmory/Afilmory --depth 1 .
COPY config.json ./
COPY builder.config.json ./
COPY .env ./
ARG S3_ACCESS_KEY_ID
ARG S3_SECRET_ACCESS_KEY
ARG GIT_TOKEN
ARG PG_CONNECTION_STRING
RUN sh ./scripts/preinstall.sh
# Install all dependencies
RUN pnpm install --frozen-lockfile
# Build the app.
# The build script in the ssr package.json handles building the web app first.
RUN pnpm --filter=@afilmory/ssr build
# -----------------
# Runner stage
# -----------------
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# ENV PORT and other configurations are now in the config files
# and passed through environment variables during runtime.
RUN apk add --no-cache curl wget
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/ssr/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/ssr/.next/static /app/apps/ssr/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/ssr/public /app/apps/ssr/public
# The standalone output includes the server.js file.
# The PORT environment variable is automatically used by Next.js.
EXPOSE 3000
CMD ["node", "apps/ssr/server.js"]
```
### 3. Docker Compose Setup
Create a `docker-compose.yml` file in your project root:
```yaml
version: '3.8'
services:
afilmory:
build: .
ports:
- '3000:3000'
environment:
- NODE_ENV=production
volumes:
- ./config.json:/app/config.json:ro
- ./builder.config.json:/app/builder.config.json:ro
- ./.env:/app/.env:ro
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: afilmory
POSTGRES_USER: afilmory
POSTGRES_PASSWORD: your_secure_password
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:
```
### 4. Building and Running
#### Option 1: Using Docker Compose (Recommended)
```bash
# Build and start all services
docker-compose up -d
# View logs
docker-compose logs -f afilmory
# Stop services
docker-compose down
```
#### Option 2: Manual Docker Build
```bash
# Build the image
docker build -t afilmory .
# Run the container
docker run -d \
--name afilmory \
-p 3000:3000 \
--env-file .env \
-v $(pwd)/config.json:/app/config.json:ro \
-v $(pwd)/builder.config.json:/app/builder.config.json:ro \
afilmory
```
## 📋 Configuration Details
### Storage Providers
Afilmory supports multiple storage providers. Configure them in `builder.config.json`:
**S3-Compatible Storage:**
```json
{
"storage": {
"provider": "s3",
"bucket": "your-bucket",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "cdn.example.com"
}
}
```
**GitHub Storage:**
```json
{
"storage": {
"provider": "github",
"owner": "username",
"repo": "photo-storage",
"branch": "main",
"path": "photos/"
}
}
```
### Performance Tuning
For optimal performance in Docker environments:
```json
{
"performance": {
"worker": {
"enabled": true,
"maxWorkers": 4
},
"cache": {
"thumbnails": true,
"manifests": true
}
}
}
```

View File

@@ -0,0 +1,10 @@
---
title: Github Action
description: Guide to deploying Afilmory via Github Action.
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-08-08T17:39:40+08:00
---
# Github Action Deployment
🚧 WIP

View File

@@ -0,0 +1,10 @@
---
title: Github Pages
description: Guide to deploying Afilmory via Github Pages.
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-08-08T17:39:40+08:00
---
# Github Pages Deployment
🚧 WIP

View File

@@ -0,0 +1,16 @@
---
title: Deployment
description: Guide to deploying Afilmory in production environments, including Docker and configuration options.
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-08-08T08:28:42.778+08:00
---
# Deployment
Deploying Afilmory can be accomplished through various methods, you can choose the one that best fits your needs. Below are the common deployment methods:
- [Docker Deployment](/deployment/docker)
- [Github Action Deployment](/deployment/github-action)
- [Github Pages Deployment](/deployment/github-pages)
- [Vercel Deployment](/deployment/vercel)
- [Cloudflare Pages Deployment](/deployment/cloudflare-pages)

View File

@@ -0,0 +1,10 @@
---
title: Vercel
description: Guide to deploying Afilmory via Vercel.
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-08-08T17:39:40+08:00
---
# Vercel Deployment
🚧 WIP

View File

@@ -0,0 +1,226 @@
---
title: Docs site
description: Docs site introduction and contribution guide
createdAt: 2025-09-11T17:05:55+08:00
lastModified: 2025-09-11T17:10:35+08:00
---
# Afilmory Docs Site
A modern documentation site generator built with Vite, React, and MDX. This package provides a file-system based routing documentation site with automatic route generation, syntax highlighting, and responsive design.
## Features
- **File-system based routing** - Automatic route generation from markdown files
- **MDX support** - Write JSX components directly in markdown
- **Syntax highlighting** - Code blocks with Shiki highlighting (light/dark themes)
- **Static site generation** - Build static HTML files for deployment
- **Hot reload** - Real-time updates during development
- **Responsive design** - Built with Tailwind CSS and Apple UIKit colors
- **TypeScript support** - Full type safety throughout the codebase
## Quick Start
### Development
Start the development server:
```bash
pnpm dev
```
This will start the Vite development server and watch for changes in the `contents/` directory.
### Building
Build the documentation site for production:
```bash
pnpm build
```
This runs three steps:
1. `build:client` - Builds the client-side React application
2. `build:static` - Generates static HTML files via SSR
3. `output` - Processes and finalizes the build output
### Preview
Preview the built site locally:
```bash
pnpm preview
```
## Project Structure
```plain
packages/docs/
├── contents/ # Documentation content (MDX/Markdown files)
│ ├── index.mdx # Homepage content
│ ├── getting-started.md
│ └── apps/ # Nested documentation sections
├── src/
│ ├── components/ # React components
│ ├── styles/ # CSS and styling
│ ├── routes.ts # Auto-generated routes (do not edit)
│ └── App.tsx # Main application component
├── plugins/
│ └── route-generater.ts # Custom Vite plugin for route generation
├── public/ # Static assets
└── scripts/ # Build scripts
```
## Writing Documentation
### File-based Routing
The documentation follows a file-system based routing convention:
- `contents/index.mdx` → `/` (homepage)
- `contents/getting-started.md` → `/getting-started`
- `contents/apps/index.md` → `/apps` (section index)
- `contents/apps/web.md` → `/apps/web`
### MDX Format
You can use standard Markdown with JSX components:
```mdx
---
title: Page Title
createdAt: 2025-01-20T10:00:00Z
lastModified: 2025-01-20T10:00:00Z
---
# Page Title
Regular markdown content here.
<div className="bg-blue-100 p-4 rounded">
Custom JSX component
</div>
```
### Frontmatter
Each documentation file can include frontmatter metadata:
```yaml
---
title: Page Title # Used for navigation and SEO
createdAt: 2025-01-20 # Creation date
lastModified: 2025-01-20 # Last modification date
description: Page description # Optional page description
---
```
## Development Guide
### Adding New Content
1. Create a new `.md` or `.mdx` file in the `contents/` directory
2. Add appropriate frontmatter metadata
3. The route will be automatically generated on save
4. The development server will hot-reload with your changes
### Custom Components
Create reusable components in `src/components/` and use them in MDX files:
```typescript
// src/components/InfoBox.tsx
export function InfoBox({ children }: { children: React.ReactNode }) {
return (
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
{children}
</div>
);
}
```
```mdx
<!-- In your MDX file -->
import { InfoBox } from '../src/components/InfoBox';
<InfoBox>
This is a custom info box component.
</InfoBox>
```
### Styling
The project uses:
- **Tailwind CSS** for utility-first styling
- **Apple UIKit colors** via `tailwindcss-uikit-colors`
- **Typography plugin** for prose styling
- **Custom scrollbar** styling
Use semantic color classes:
```css
/* Preferred */
.text-text-primary .bg-fill-secondary
/* Avoid */
.text-blue-500 .bg-gray-100
```
### Route Generation Plugin
The custom Vite plugin automatically generates routes from the file system:
- Watches the `contents/` directory for changes
- Generates `src/routes.ts` with route definitions
- Creates `src/routes.json` for metadata
- Handles index files and nested directories
## Configuration
### Vite Configuration
Key configuration in `vite.config.ts`:
- **MDX processing** with frontmatter support
- **Syntax highlighting** with Shiki
- **Route generation** plugin
- **Tailwind CSS** integration
- **Code inspector** for development
### Supported Languages
Code highlighting supports:
- JavaScript/TypeScript
- JSX/TSX
- MDX
- JSON
- Shell/Bash
## Deployment
The built site in `dist/` can be deployed to any static hosting service:
- **Vercel** - Zero config deployment
- **Netlify** - Drag and drop the `dist` folder
- **GitHub Pages** - Upload build artifacts
- **Cloudflare Pages** - Connect your repository
## Scripts Reference
- `pnpm dev` - Start development server
- `pnpm build` - Full production build
- `pnpm build:client` - Build client-side app only
- `pnpm build:static` - Generate static HTML via SSR
- `pnpm output` - Process build output
- `pnpm lint` - Run ESLint
- `pnpm preview` - Preview built site locally
## Contributing
When contributing to the documentation:
1. Use `pnpm create:doc` and follow instructions to create a new document.
2. Write your docs.
3. Test your changes locally with `pnpm dev`
4. Ensure the build passes with `pnpm build`
5. Use semantic commit messages

View File

@@ -0,0 +1,217 @@
---
title: Overview
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-07-20T22:35:03+08:00
---
# Overview
<p align="center">
<img src="https://github.com/Afilmory/assets/blob/main/afilmory-readme.webp?raw=true" alt="Afilmory" width="100%" />
</p>
Afilmory (/əˈfɪlməri/, "uh-FIL-muh-ree") is a term created for personal photography websites, blending Auto Focus (AF), aperture (light control), film (vintage medium), and memory (captured moments).
A modern photo gallery website built with React + TypeScript, supporting automatic photo synchronization from multiple storage sources (S3, GitHub), featuring high-performance WebGL rendering, masonry layout, EXIF information display, thumbnail generation, and more.
Live Photo Galleries:
- [afilmory.innei.in](https://afilmory.innei.in)
- [gallery.mxte.cc](https://gallery.mxte.cc)
- [photography.pseudoyu.com](https://photography.pseudoyu.com)
- [afilmory.magren.cc](https://afilmory.magren.cc)
## 🌟 Features
### Core Functionality
- 🖼️ **High-Performance WebGL Image Renderer** - Custom WebGL component with smooth zoom and pan operations
- 📱 **Responsive Masonry Layout** - Powered by Masonic, adapts to different screen sizes
- 🎨 **Modern UI Design** - Built with Tailwind CSS and Radix UI component library
- ⚡ **Incremental Sync** - Smart change detection, processes only new or modified photos
- 🌐 **i18n** - Multi-language support
- 🔗 **OpenGraph** - OpenGraph metadata for social media sharing
### Image Processing
- 🔄 **HEIC/HEIF Format Support** - Automatic conversion of Apple device HEIC format
- 📷 **TIFF Format Support** - Automatic conversion of TIFF format
- 🖼️ **Smart Thumbnail Generation** - Multi-size thumbnails for optimized loading performance
- 📊 **EXIF Information Display** - Complete shooting parameters including camera model, focal length, aperture, etc.
- 🌈 **Blurhash Placeholders** - Elegant image loading experience
- 📱 **Live Photo Support** - Detection and display of iPhone Live Photos
- ☀️ **HDR Image Support** - Display HDR images
### Advanced Features
- 🎛️ **Fujifilm Recipe** - Read and display Fujifilm camera film simulation settings
- 🔍 **Fullscreen Viewer** - Image viewer with gesture support
- 🏷️ **File System Tags** - Auto-generated tags based on file system
- ⚡ **Concurrent Processing** - Multi-process/multi-thread concurrent processing support
- 🗂️ **Multi-Storage Support** - S3, GitHub, and other storage backends
- 📷 **Share Image** - Share image to social media or embed iframe to your website
- 🗺️ **Interactive Map Explorer** - Geographic visualization of photos with GPS coordinates from EXIF data using MapLibre
## 🏗️ Technical Architecture
### Frontend Tech Stack
- **React 19** - Latest React version with Compiler
- **TypeScript** - Complete type safety
- **Vite** - Modern build tool
- **Tailwind CSS** - Atomic CSS framework
- **Radix UI** - Accessible component library
- **Jotai** - State management
- **TanStack Query** - Data fetching and caching
- **React Router 7** - Routing management
- **i18next** - Internationalization
### Build System
- **Node.js** - Server-side runtime
- **Sharp** - High-performance image processing
- **AWS SDK** - S3 storage operations
- **Worker Threads/Cluster** - Concurrent processing
- **EXIF-Reader** - EXIF data extraction
### Storage Architecture
Designed with adapter pattern, supporting multiple storage backends:
- **S3-Compatible Storage** - AWS S3, MinIO, Alibaba Cloud OSS, etc.
- **GitHub Storage** - Using GitHub repository as image storage
## 🚀 Quick Start
### Docker Deployment
[Docker Deployment](https://github.com/Afilmory/docker)
## ⚙️ Configuration Options
#### Remote Repository Configuration (`repo`)
To achieve incremental builds in CI, it is necessary to configure a cache repository, which will pull the cache before each build and upload the build results after the build.
```json
{
"repo": {
"enable": true,
"url": "https://github.com/username/gallery-assets"
}
}
```
This will automatically pull resources from the remote repository, avoiding rebuilds each time.
**In order to achieve uploading to the git repository, you need to provide a `GIT_TOKEN` and write it in the `.env` file.**
#### Storage Configuration (`storage`)
- `provider`: Storage provider (`s3` | `github`)
- `bucket`: S3 bucket name
- `region`: S3 region
- `endpoint`: S3 endpoint (optional)
- `prefix`: File prefix
- `customDomain`: Custom domain
- `excludeRegex`: Regular expression to exclude files (optional)
#### Build Options (`options`)
- `defaultConcurrency`: Default concurrency
- `enableLivePhotoDetection`: Enable Live Photo detection
- `showProgress`: Show build progress
- `showDetailedStats`: Show detailed statistics
#### Performance Configuration (`performance`)
- `worker.workerCount`: Number of worker processes
- `worker.timeout`: Worker timeout (milliseconds)
- `worker.useClusterMode`: Enable cluster mode
#### Logging Configuration (`logging`)
- `verbose`: Verbose logging
- `level`: Log level (`info` | `warn` | `error` | `debug`)
- `outputToFile`: Output to file
## 📋 CLI Commands
### Build Commands
```bash
# View help
pnpm run build:manifest -- --help
# Incremental update (default)
pnpm run build:manifest
# Force full update
pnpm run build:manifest -- --force
# Only regenerate thumbnails
pnpm run build:manifest -- --force-thumbnails
# Only regenerate manifest
pnpm run build:manifest -- --force-manifest
```
### Development Commands
```bash
# Start development server
pnpm dev
# Build production version
pnpm build
```
### Notes
- Ensure your S3 bucket already contains photo files
- If using remote repository, configure `builder.config.json` first
## 🔧 Advanced Usage
### Custom Storage Provider
Implement the `StorageProvider` interface to support new storage backends:
```typescript
import { StorageProvider } from './src/core/storage/interfaces'
class MyStorageProvider implements StorageProvider {
async getFile(key: string): Promise<Buffer | null> {
// Implement file retrieval logic
}
async listImages(): Promise<StorageObject[]> {
// Implement image list retrieval logic
}
// ... other methods
}
```
### Custom Image Processing
Add custom processors in the `src/core/image/` directory:
```typescript
export async function customImageProcessor(buffer: Buffer) {
// Custom image processing logic
return processedBuffer
}
```
## 📄 License
MIT License © 2025 Innei
## 🔗 Related Links
- [Live Demo](https://gallery.innei.in)
- [Personal Website](https://innei.in)
- [GitHub](https://github.com/innei)
If this project helps you, please give it a ⭐️ Star for support!

View File

@@ -0,0 +1,262 @@
---
title: Storage providers
description: Afilmory can work with multiple storage providers, including S3, Git and local file system
createdAt: 2025-08-12T15:09:08+08:00
lastModified: 2025-08-31T11:08:35+08:00
---
# Storage Providers
Afilmory's flexible storage architecture allows you to store your photos across different platforms. The photo processing engine (`@afilmory/builder`) abstracts storage operations through a unified interface, making it easy to switch between providers.
## Supported Providers
### S3-Compatible Storage
S3-compatible storage is the recommended option for production deployments, offering scalability, CDN integration, and reliable performance.
**Configuration in `builder.config.json`:**
```json
{
"storage": {
"provider": "s3",
"bucket": "your-photos-bucket",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "cdn.yourdomain.com"
}
}
```
**Environment Variables (`.env`):**
```bash
S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key
```
### GitHub Storage
GitHub storage leverages Git repositories for photo storage, ideal for static sites, small galleries, or when you want version control for your photos.
**Features:**
- ✅ Free storage space (GitHub repository limit 1GB)
- ✅ Global CDN support via raw.githubusercontent.com
- ✅ Version control for your photos
- ✅ Public access without authentication
- ⚠️ GitHub API rate limits apply
- ⚠️ Not suitable for large files or frequent updates
**Configuration in `builder.config.json`:**
```json
{
"storage": {
"provider": "github",
"github": {
"owner": "your-username",
"repo": "photo-storage",
"branch": "main",
"path": "photos",
"useRawUrl": true
}
}
}
```
**Environment Variables:**
```bash
GIT_TOKEN=ghp_your_github_personal_access_token
```
**Setup Steps:**
1. **Create GitHub Repository**
```bash
git clone https://github.com/your-username/photo-gallery.git
cd photo-gallery
mkdir photos
```
2. **Get GitHub Access Token** (optional but recommended)
- Visit GitHub Settings > Developer settings > Personal access tokens
- Create new Fine-grained personal access token
- Grant "Contents" permission (read/write) to your repository
3. **Required Permissions:**
- `Contents: Read and write` - for uploading processed photos
- `Metadata: Read` - for repository access
### Local File System
Local storage is suitable for development, testing, or self-hosted deployments where photos are stored on the same server.
**Features:**
- ✅ No external dependencies
- ✅ Fast access speeds
- ✅ Complete private control
- ✅ Recursive directory scanning
- ✅ Live Photos detection support
- ⚠️ Requires proper file system permissions
- ⚠️ Not suitable for distributed deployments
**Configuration in `builder.config.json`:**
```json
{
"storage": {
"provider": "local",
"basePath": "./photos",
"baseUrl": "http://localhost:3000/photos/"
}
}
```
## Photo Processing Workflow
### Image Processing Pipeline
When photos are processed, Afilmory performs these operations:
1. **Format Detection** - Identifies HEIC, TIFF, RAW, and standard formats
2. **EXIF Extraction** - Extracts camera metadata, GPS data, and Fujifilm film recipes
3. **Format Conversion** - Converts HEIC/TIFF to web-compatible formats (WebP, JPEG)
4. **Thumbnail Generation** - Creates multiple thumbnail sizes
5. **Blurhash Generation** - Creates placeholder images for smooth loading
6. **Manifest Creation** - Generates metadata JSON for the gallery
### Storage Organization
Afilmory organizes files in your storage with this structure:
```
photos/
├── 2024/
│ ├── 01-january/
│ │ ├── IMG_001.jpg
│ │ ├── IMG_001.mov # Live Photo video
│ │ └── IMG_002.heic
│ └── 02-february/
│ └── sunset.jpg
├── 2023/
│ └── vacation/
│ ├── beach.jpg
│ └── mountain.png
└── manifest.json # Photo metadata and index
```
### Processing Commands
Use these commands to manage your photo processing:
```bash
# Initial photo processing
pnpm run build:manifest
# Force rebuild all photos
pnpm run build:manifest -- --force
# Regenerate only thumbnails
pnpm run build:manifest -- --force-thumbnails
# Process specific directory
pnpm run build:manifest -- --path "2024/vacation"
```
## Environment-Specific Configurations
### Development Setup
For local development, use local storage:
```json
{
"storage": {
"provider": "local",
"basePath": "./dev-photos",
"baseUrl": "http://localhost:1924/photos"
}
}
```
### Production Setup
For production, use S3-compatible storage with CDN:
```json
{
"storage": {
"provider": "s3",
"bucket": "prod-photos-bucket",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "cdn.yourdomain.com"
},
"performance": {
"worker": {
"enabled": true,
"maxWorkers": 4
}
}
}
```
## API Limitations
### GitHub Storage Limits
- **Unauthenticated requests**: 60 requests/hour/IP
- **Authenticated requests**: 5,000 requests/hour/token
- **File size**: Maximum 100MB (via API)
- **Repository size**: Recommended not to exceed 1GB
### Storage Provider Comparison
| Feature | S3 | GitHub | Local |
|---------|----|----|--------|
| Storage Space | Pay-as-you-go | 1GB free | Depends on disk |
| CDN | Additional cost | Free global CDN | Manual setup |
| API Limits | Very high | Limited | None |
| Use Case | Production | Small projects, demos | Development, self-hosted |
| Setup Complexity | Medium | Simple | Minimal |
## Security Considerations
### S3 Bucket Policies
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket/photos/*"
}
]
}
```
### GitHub Repository Settings
- Set repository to public for image access
- Use fine-grained personal access tokens
- Limit token permissions to specific repositories
### Environment Variables
Store sensitive credentials securely:
```bash
# Use environment-specific .env files
# .env.production
S3_ACCESS_KEY_ID=prod_access_key
S3_SECRET_ACCESS_KEY=prod_secret_key
# .env.development
S3_ACCESS_KEY_ID=dev_access_key
S3_SECRET_ACCESS_KEY=dev_secret_key
```

View File

@@ -0,0 +1,62 @@
// @ts-check
import { globalIgnores } from 'eslint/config'
import { defineConfig } from 'eslint-config-hyoban'
export default defineConfig(
{
formatting: false,
lessOpinionated: true,
preferESM: false,
react: true,
tailwindCSS: true,
},
{
settings: {
tailwindcss: {
whitelist: ['center'],
},
},
rules: {
'unicorn/prefer-math-trunc': 'off',
'unicorn/no-static-only-class': 'off',
'@eslint-react/no-clone-element': 0,
// TailwindCSS v4 not support
'tailwindcss/no-custom-classname': 0,
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 0,
// NOTE: Disable this temporarily
'react-compiler/react-compiler': 0,
'no-restricted-syntax': 0,
'no-restricted-globals': [
'error',
{
name: 'location',
message:
"Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \n\n" +
'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.',
},
],
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
},
},
{
files: ['**/*.tsx'],
rules: {
'@stylistic/jsx-self-closing-comp': 'error',
},
},
globalIgnores(['dist']),
)

14
packages/docs/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><!--app-title--></title>
<!--app-head-->
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main-client.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
{
"name": "@afilmory/docs",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "pnpm build:client && pnpm build:static && pnpm output",
"build:client": "tsc -b && vite build",
"build:static": "vite build --ssr src/main-static.tsx --outDir dist/static",
"create:doc": "tsx ../../scripts/create-doc.ts",
"dev": "vite",
"lint": "eslint .",
"output": "tsx scripts/build.ts",
"preview": "pnpm dlx serve dist"
},
"dependencies": {
"@mdx-js/react": "^3.1.0",
"@mdx-js/rollup": "^3.1.0",
"@pastel-palette/colors": "1.0.0-beta.2",
"@pastel-palette/tailwindcss": "1.0.0-beta.2",
"@radix-ui/react-context-menu": "2.2.15",
"@radix-ui/react-scroll-area": "1.2.9",
"@tailwindcss/vite": "4.1.11",
"@types/mdast": "^4.0.4",
"lucide-react": "^0.525.0",
"mdast": "^3.0.0",
"motion": "12.19.1",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-mdx": "^3.1.0",
"remark-parse": "^11.0.0",
"tailwind-scrollbar-hide": "^4.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@shikijs/rehype": "^3.8.1",
"@tailwindcss/postcss": "catalog:",
"@tailwindcss/typography": "catalog:",
"@types/glob": "^9.0.0",
"@types/mdx": "^2.0.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/remark-heading-id": "^1.0.0",
"@vitejs/plugin-react": "^4.7.0",
"code-inspector-plugin": "0.20.12",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"glob": "^11.0.3",
"globals": "^16.3.0",
"shiki": "^3.8.1",
"tailwind-scrollbar": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-animate": "catalog:",
"tailwindcss-safe-area": "catalog:",
"tailwindcss-uikit-colors": "catalog:",
"tsx": "^4.20.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^7.0.6"
}
}

View File

@@ -0,0 +1,136 @@
import type { Heading, Root } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
/**
* 生成标题ID的函数
* 保持与 toc-extractor.ts 中的 generateHeadingId 函数完全一致
*/
function generateHeadingId(text: string): string {
return text
.toLowerCase()
.replaceAll(/[^\w\s-]/g, '') // 移除特殊字符
.replaceAll(/\s+/g, '-') // 空格替换为连字符
.replaceAll(/-+/g, '-') // 多个连字符合并为一个
.replaceAll(/^-|-$/g, '') // 移除开头和结尾的连字符
}
/**
* 从 MDAST 节点中提取文本内容
*/
function extractTextFromNode(node: any): string {
if (typeof node === 'string') {
return node
}
if (node.type === 'text') {
return node.value || ''
}
if (node.type === 'inlineCode') {
return node.value || ''
}
if (node.type === 'strong' || node.type === 'emphasis') {
return node.children?.map(extractTextFromNode).join('') || ''
}
if (node.type === 'link') {
return node.children?.map(extractTextFromNode).join('') || ''
}
if (node.children && Array.isArray(node.children)) {
return node.children
.map((element: any) => extractTextFromNode(element))
.join('')
}
return ''
}
interface RemarkHeadingOptions {
/**
* 是否覆盖已存在的 ID
* @default false
*/
overrideExisting?: boolean
/**
* ID 前缀
* @default ''
*/
prefix?: string
/**
* 自定义 ID 生成函数
* @default generateHeadingId
*/
generateId?: (text: string) => string
}
/**
* Remark 插件:为标题自动添加 ID
*
* 此插件会遍历 Markdown/MDX 文档中的所有标题节点,
* 为没有 ID 的标题自动生成 ID确保与 TOC 提取器生成的 ID 一致
*
* @param options 配置选项
*/
const remarkHeading: Plugin<[RemarkHeadingOptions?], Root> = (options = {}) => {
const {
overrideExisting = false,
prefix = '',
generateId = generateHeadingId,
} = options
return (tree: Root) => {
// 用于跟踪已使用的 ID避免重复
const usedIds = new Set<string>()
visit(tree, 'heading', (node: Heading) => {
// 检查是否已经有 ID
const existingId = node.data?.hProperties?.id as string | undefined
if (existingId && !overrideExisting) {
usedIds.add(existingId)
return
}
// 提取标题文本
const text = node.children
.map((element: any) => extractTextFromNode(element))
.join('')
if (!text.trim()) {
return // 跳过空标题
}
// 生成基础 ID
const baseId = prefix + generateId(text.trim())
let finalId = baseId
// 处理 ID 冲突,添加数字后缀
let counter = 1
while (usedIds.has(finalId)) {
finalId = `${baseId}-${counter}`
counter++
}
usedIds.add(finalId)
// 设置 ID
if (!node.data) {
node.data = {}
}
if (!node.data.hProperties) {
node.data.hProperties = {}
}
node.data.hProperties.id = finalId
})
}
}
export default remarkHeading
export { generateHeadingId, remarkHeading }

View File

@@ -0,0 +1,264 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { glob } from 'glob'
import type { Plugin } from 'vite'
interface RouteConfig {
path: string
component: string
title?: string
meta?: Record<string, unknown>
}
interface GenerateRoutesOptions {
contentsDir?: string
outputDir?: string
outputFile?: string
jsonFile?: string
indexFile?: string
}
const defaultOptions: Required<GenerateRoutesOptions> = {
contentsDir: 'contents',
outputDir: 'src',
outputFile: 'routes.ts',
jsonFile: 'routes.json',
indexFile: 'index',
}
/**
* 约定式路由生成器插件
*
* 根据 contents 目录下的文件自动生成路由配置
*
* 约定规则:
* - contents/index.md -> / (首页)
* - contents/guide.md -> /guide
* - contents/api/index.md -> /api (目录首页)
* - contents/api/auth.md -> /api/auth
* - contents/404.md -> /404 (404页面)
*/
export function routeGenerator(options: GenerateRoutesOptions = {}): Plugin {
const opts = { ...defaultOptions, ...options }
return {
name: 'route-generator',
enforce: 'pre',
async buildStart() {
await generateRoutes(opts)
},
async handleHotUpdate({ file, server }) {
// 监听 contents 目录文件变化,重新生成路由
if (file.includes(path.resolve(opts.contentsDir))) {
await generateRoutes(opts)
server.ws.send({
type: 'full-reload',
})
}
},
configureServer(server) {
// 开发模式下监听文件变化
server.middlewares.use('/__generate-routes', async (_req, res) => {
try {
await generateRoutes(opts)
res.end('Routes generated successfully')
} catch (error) {
res.statusCode = 500
res.end(`Error generating routes: ${error}`)
}
})
},
}
}
async function generateRoutes(options: Required<GenerateRoutesOptions>) {
const { contentsDir, outputDir, outputFile, jsonFile, indexFile } = options
try {
// 获取所有 markdown 文件
const pattern = path.join(contentsDir, '**/*.{md,mdx}')
const files = await glob(pattern, {
ignore: ['**/node_modules/**'],
absolute: false,
})
const routes: RouteConfig[] = []
for (const file of files) {
const route = await generateRouteFromFile(file, contentsDir, indexFile)
if (route) {
routes.push(route)
}
}
// 按路径排序,确保首页在最前面
routes.sort((a, b) => {
if (a.path === '/') return -1
if (b.path === '/') return 1
return a.path.localeCompare(b.path)
})
// 生成路由文件内容
const routeFileContent = generateRouteFileContent(routes)
// 确保输出目录存在
await fs.mkdir(outputDir, { recursive: true })
// 写入路由文件
const outputPath = path.join(outputDir, outputFile)
await fs.writeFile(outputPath, routeFileContent, 'utf-8')
// 生成 JSON 文件(不包含 component
const routesJson = generateRoutesJson(routes)
const jsonPath = path.join(outputDir, jsonFile)
await fs.writeFile(jsonPath, routesJson, 'utf-8')
console.info(`✓ Generated ${routes.length} routes to ${outputPath}`)
console.info(`✓ Generated routes JSON to ${jsonPath}`)
} catch (error) {
console.error('Error generating routes:', error)
throw error
}
}
async function generateRouteFromFile(
file: string,
contentsDir: string,
indexFile: string,
): Promise<RouteConfig | null> {
try {
// 移除 contents 前缀和文件扩展名
let routePath = file
.replace(new RegExp(`^${contentsDir}/`), '')
.replace(/\.(md|mdx)$/, '')
// 处理 index 文件
if (routePath === indexFile) {
routePath = '/'
} else if (routePath.endsWith(`/${indexFile}`)) {
const basePath = routePath.replace(`/${indexFile}`, '')
routePath = basePath ? `/${basePath}` : '/'
} else {
routePath = `/${routePath}`
}
// 生成组件导入路径,保留文件后缀
const componentPath = `../${file}`
// 读取文件内容获取元数据
const fileContent = await fs.readFile(file, 'utf-8')
const meta = extractFrontmatter(fileContent)
return {
path: routePath,
component: componentPath,
title:
(typeof meta.title === 'string' ? meta.title : undefined) ||
generateTitleFromPath(routePath),
meta,
}
} catch (error) {
console.warn(`Warning: Could not process file ${file}:`, error)
return null
}
}
function extractFrontmatter(content: string): Record<string, unknown> {
const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)/
const match = content.match(frontmatterRegex)
if (!match) {
return {}
}
try {
// 简单的 YAML 解析(仅支持基本的 key: value 格式)
const yamlContent = match[1]
const meta: Record<string, unknown> = {}
yamlContent.split('\n').forEach((line) => {
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim()
const value = line
.slice(colonIndex + 1)
.trim()
.replaceAll(/^["']|["']$/g, '')
meta[key] = value
}
})
return meta
} catch (error) {
console.warn('Failed to parse frontmatter:', error)
return {}
}
}
function generateTitleFromPath(path: string): string {
if (path === '/') return 'Home'
return path
.split('/')
.filter(Boolean)
.map((segment) =>
segment
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
)
.join(' - ')
}
function generateRouteFileContent(routes: RouteConfig[]): string {
const imports = routes
.map((route, index) => `import Route${index} from '${route.component}'`)
.join('\n')
const routeObjects = routes
.map(
(route, index) => ` {
path: '${route.path}',
component: Route${index},
title: '${route.title}',
meta: ${JSON.stringify(route.meta, null, 4).replaceAll('\n', '\n ')}
}`,
)
.join(',\n')
return `// Auto-generated route configuration
// This file is automatically generated by the route-generator plugin
// Do not edit manually - your changes will be overwritten
${imports}
export interface RouteConfig {
path: string
component: React.ComponentType
title: string
meta: Record<string, unknown>
}
export const routes: RouteConfig[] = [
${routeObjects}
]
export default routes
`
}
function generateRoutesJson(routes: RouteConfig[]): string {
const routesData = routes.map((route) => ({
path: route.path,
title: route.title,
meta: route.meta,
}))
return JSON.stringify(routesData, null, 2)
}
export default routeGenerator

View File

@@ -0,0 +1,399 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { glob } from 'glob'
import type { Plugin } from 'vite'
interface TocItem {
id: string
level: number
text: string
children?: TocItem[]
}
interface FileToc {
file: string
path: string
title: string
toc: TocItem[]
}
interface TocExtractorOptions {
contentsDir?: string
outputDir?: string
outputFile?: string
maxDepth?: number
indexFile?: string
}
const defaultOptions: Required<TocExtractorOptions> = {
contentsDir: 'contents',
outputDir: 'src',
outputFile: 'toc-data.ts',
maxDepth: 6,
indexFile: 'index',
}
/**
* TOC 提取器插件
*
* 通过正则表达式从 MD 和 MDX 文件中提取标题,生成目录结构
* 并保存到 TypeScript 文件中
*/
export function tocExtractor(options: TocExtractorOptions = {}): Plugin {
const opts = { ...defaultOptions, ...options }
return {
name: 'toc-extractor',
enforce: 'pre',
async buildStart() {
await generateTocData(opts)
},
async handleHotUpdate({ file, server }) {
// 监听 contents 目录文件变化,重新生成 TOC 数据
if (file.includes(path.resolve(opts.contentsDir))) {
await generateTocData(opts)
server.ws.send({
type: 'full-reload',
})
}
},
configureServer(server) {
// 开发模式下提供手动触发接口
server.middlewares.use('/__generate-toc', async (_req, res) => {
try {
await generateTocData(opts)
res.end('TOC data generated successfully')
} catch (error) {
res.statusCode = 500
res.end(`Error generating TOC data: ${error}`)
}
})
},
}
}
async function generateTocData(options: Required<TocExtractorOptions>) {
const { contentsDir, outputDir, outputFile, maxDepth, indexFile } = options
try {
const pattern = path.join(contentsDir, '**/*.{md,mdx}')
const files = await glob(pattern, {
ignore: ['**/node_modules/**'],
absolute: false,
})
const allTocData: FileToc[] = []
for (const file of files) {
const tocData = await extractTocFromFile(
file,
contentsDir,
maxDepth,
indexFile,
)
if (tocData) {
allTocData.push(tocData)
}
}
allTocData.sort((a, b) => a.path.localeCompare(b.path))
const tsContent = generateTocTsContent(allTocData)
await fs.mkdir(outputDir, { recursive: true })
const outputPath = path.join(outputDir, outputFile)
await fs.writeFile(outputPath, tsContent, 'utf-8')
console.info(
`✓ Generated TOC data for ${allTocData.length} files to ${outputPath}`,
)
} catch (error) {
console.error('Error generating TOC data:', error)
throw error
}
}
async function extractTocFromFile(
file: string,
contentsDir: string,
maxDepth: number,
indexFile: string,
): Promise<FileToc | null> {
try {
const content = await fs.readFile(file, 'utf-8')
const frontmatterTitle = extractFrontmatterTitle(content)
const headings = extractHeadings(content, maxDepth)
if (headings.length === 0 && !frontmatterTitle) {
return null
}
const toc = buildTocTree(headings)
const relativePath = path.relative(contentsDir, file)
const routePath = generateRoutePath(file, contentsDir, indexFile)
return {
file: relativePath,
path: routePath,
title:
frontmatterTitle ||
headings[0]?.text ||
path.basename(file, path.extname(file)),
toc,
}
} catch (error) {
console.warn(`Failed to process file ${file}:`, error)
return null
}
}
function generateRoutePath(
file: string,
contentsDir: string,
indexFile: string,
): string {
// 移除 contents 前缀和文件扩展名(与 route-generator 保持一致)
let routePath = file
.replace(new RegExp(`^${contentsDir}/`), '')
.replace(/\.(md|mdx)$/, '')
// 处理 index 文件(与 route-generator 保持一致)
if (routePath === indexFile) {
routePath = '/'
} else if (routePath.endsWith(`/${indexFile}`)) {
const basePath = routePath.replace(`/${indexFile}`, '')
routePath = basePath ? `/${basePath}` : '/'
} else {
routePath = `/${routePath}`
}
return routePath
}
function extractFrontmatterTitle(content: string): string | null {
// 匹配 frontmatter 中的 title 字段
const frontmatterMatch = content.match(/^---\n(.*?)\n---/s)
if (!frontmatterMatch) return null
const frontmatterContent = frontmatterMatch[1]
// eslint-disable-next-line regexp/no-super-linear-backtracking
const titleMatch = frontmatterContent.match(/^title:\s*(.+)$/m)
if (!titleMatch) return null
// 移除引号
return titleMatch[1].replaceAll(/^['"]|['"]$/g, '').trim()
}
/**
* 移除内容中的代码块
* 包括围栏代码块 (```...```) 和缩进代码块
*/
function removeCodeBlocks(content: string): string {
// 1. 移除围栏代码块 (```...``` 或 ~~~...~~~)
let result = content.replaceAll(/^```[\s\S]+?^```$/gm, '')
result = result.replaceAll(/^~~~[\s\S]+?^~~~$/gm, '')
// 2. 移除缩进代码块 (连续的4空格或1Tab缩进的行)
const lines = result.split('\n')
const filteredLines: string[] = []
let inCodeBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const isCodeLine = /^(?: {4}|\t)/.test(line) && line.trim() !== ''
const isEmptyLine = line.trim() === ''
if (isCodeLine) {
inCodeBlock = true
continue // 跳过代码行
}
if (inCodeBlock && isEmptyLine) {
// 在代码块中的空行,检查下一行是否还是代码
let nextNonEmptyIndex = i + 1
while (
nextNonEmptyIndex < lines.length &&
lines[nextNonEmptyIndex].trim() === ''
) {
nextNonEmptyIndex++
}
if (
nextNonEmptyIndex < lines.length &&
/^(?: {4}|\t)/.test(lines[nextNonEmptyIndex])
) {
// 下一个非空行还是代码,跳过这个空行
continue
} else {
// 代码块结束
inCodeBlock = false
}
}
if (!isCodeLine) {
inCodeBlock = false
}
filteredLines.push(line)
}
return filteredLines.join('\n')
}
function extractHeadings(content: string, maxDepth: number): TocItem[] {
const headings: TocItem[] = []
// 首先移除所有代码块
const contentWithoutCodeBlocks = removeCodeBlocks(content)
// 正则表达式匹配 Markdown 标题 (## Title 格式)
// eslint-disable-next-line regexp/no-super-linear-backtracking
const headingRegex = /^(#{1,6})\s+(.+)$/gm
let match
while ((match = headingRegex.exec(contentWithoutCodeBlocks)) !== null) {
const level = match[1].length
if (level > maxDepth) continue
const text = match[2].trim()
// 生成 ID转换为小写替换空格和特殊字符
const id = `heading-${generateHeadingId(text)}`
headings.push({
id,
level,
text,
})
}
return headings
}
function generateHeadingId(text: string): string {
return text
.toLowerCase()
.replaceAll(/[^\w\s-]/g, '') // 移除特殊字符
.replaceAll(/\s+/g, '-') // 空格替换为连字符
.replaceAll(/-+/g, '-') // 多个连字符合并为一个
.replaceAll(/^-|-$/g, '') // 移除开头和结尾的连字符
}
function buildTocTree(headings: TocItem[]): TocItem[] {
const result: TocItem[] = []
const stack: TocItem[] = []
for (const heading of headings) {
// 将当前标题的子元素初始化为空数组
heading.children = []
// 找到合适的父级
while (stack.length > 0 && stack.at(-1)!.level >= heading.level) {
stack.pop()
}
if (stack.length === 0) {
// 顶级标题
result.push(heading)
} else {
// 作为子标题
const parent = stack.at(-1)
if (parent && !parent.children) {
parent.children = []
}
if (parent && parent.children) {
parent.children.push(heading)
}
}
stack.push(heading)
}
return result
}
function generateTocTsContent(tocData: FileToc[]): string {
return `// This file is automatically generated by the toc-extractor plugin
// Do not edit manually
export interface TocItem {
id: string
level: number
text: string
children?: TocItem[]
}
export interface FileToc {
file: string
path: string
title: string
toc: TocItem[]
}
export const tocData: FileToc[] = ${JSON.stringify(tocData, null, 2)}
// Helper function to find TOC data by file path
export function getTocByFile(filePath: string): TocItem[] | undefined {
const item = tocData.find(item => item.file === filePath)
return item?.toc
}
// Helper function to find TOC data by route path
export function getTocByPath(routePath: string): TocItem[] | undefined {
const item = tocData.find(item => item.path === routePath)
return item?.toc
}
// Helper function to flatten TOC tree into a simple array
export function flattenToc(toc: TocItem[]): TocItem[] {
const result: TocItem[] = []
function traverse(items: TocItem[]) {
for (const item of items) {
result.push({
id: item.id,
level: item.level,
text: item.text,
})
if (item.children && item.children.length > 0) {
traverse(item.children)
}
}
}
traverse(toc)
return result
}
// Helper function to get all headings at a specific level
export function getHeadingsByLevel(toc: TocItem[], level: number): TocItem[] {
const result: TocItem[] = []
function traverse(items: TocItem[]) {
for (const item of items) {
if (item.level === level) {
result.push(item)
}
if (item.children && item.children.length > 0) {
traverse(item.children)
}
}
}
traverse(toc)
return result
}
`
}
export default tocExtractor

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { RouteConfig } from '../src/routes'
import routes from '../src/routes.json'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const docsRoot = join(__dirname, '..')
async function build() {
try {
// Import the static module using file URL to handle path resolution
const staticModulePath = pathToFileURL(
join(docsRoot, 'dist/static/main-static.js'),
).href
const staticModule = await import(staticModulePath)
// Read the base HTML template
const templatePath = join(docsRoot, 'dist/index.html')
const templateHtml = await readFile(templatePath, 'utf-8')
// Generate HTML for each route
for (const route of routes) {
const { html } = staticModule.render(route.path)
// Replace placeholders in template
const pageHtml = templateHtml
.replace('<!--app-html-->', html)
.replace(
'<!--app-title-->',
`${
(route.meta?.title as string) || route.title || 'Docs'
} | Afilmory Docs`,
)
.replace('<!--app-head-->', generateMetaTags(route))
// Determine output path
const outputPath = getOutputPath(route.path)
// Ensure directory exists
await mkdir(dirname(outputPath), { recursive: true })
// Write the HTML file
await writeFile(outputPath, pageHtml, 'utf-8')
console.info(`✅ Generated: ${route.path} -> ${outputPath}`)
}
console.info('✅ Successfully built all static HTML files')
} catch (error) {
console.error('❌ Build failed:', error)
process.exit(1)
}
}
function generateMetaTags(route: Omit<RouteConfig, 'component'>): string {
const meta = route.meta || {}
const tags: string[] = []
const description = meta.description as string
const title = meta.title as string
if (description) {
tags.push(
`<meta name="description" content="${description}">`,
`<meta property="og:description" content="${description}">`,
)
}
if (title) {
tags.push(`<meta property="og:title" content="${title}">`)
}
return tags.join('\n ')
}
function getOutputPath(routePath: string): string {
const distDir = join(docsRoot, 'dist')
if (routePath === '/') {
return join(distDir, 'index.html')
}
// Remove leading slash and create directory structure
const cleanPath = routePath.replace(/^\//, '')
return join(distDir, cleanPath, 'index.html')
}
build()

231
packages/docs/src/App.tsx Normal file
View File

@@ -0,0 +1,231 @@
import { AlignLeftIcon, ArrowRight } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { MDX } from './components'
import { DocumentFooter } from './components/DocumentFooter'
import { MobileTableOfContents } from './components/MobileTableOfContents'
import { Sidebar } from './components/Sidebar'
import { TableOfContents } from './components/TableOfContents'
import { getRandomKaomoji } from './utils/kaomoji'
import { getMatchedRoute } from './utils/routes'
function App({ url }: { url?: string }) {
const [currentPath, setCurrentPath] = useState(url || '/')
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const matchedRoute = getMatchedRoute(currentPath)
const mainContentRef = useRef<HTMLDivElement>(null)
const handleScrollMainContent = (top: number) => {
console.info('Scrolling to:', top)
if (mainContentRef.current) {
mainContentRef.current.scrollTo({
top,
behavior: 'smooth',
})
}
}
const handleNavigate = useCallback(
(path: string) => {
setCurrentPath(path)
setIsSidebarOpen(false) // 导航后关闭侧边栏
// 在实际应用中,这里会更新浏览器历史记录
if (typeof window !== 'undefined') {
window.history.pushState({}, '', path)
}
},
[setCurrentPath, setIsSidebarOpen],
)
const toggleSidebar = useCallback(() => {
setIsSidebarOpen(!isSidebarOpen)
}, [isSidebarOpen])
useEffect(() => {
if (matchedRoute) {
document.title = `${matchedRoute.title || 'Docs'} | Afilmory Docs`
} else {
document.title = '404 Page Not Found | Afilmory Docs'
}
}, [matchedRoute])
if (!matchedRoute) {
return (
<div className="bg-background flex h-screen">
{/* 桌面端侧边栏 */}
<div className="hidden lg:block">
<Sidebar currentPath={currentPath} onNavigate={handleNavigate} />
</div>
{/* 移动端侧边栏 */}
{isSidebarOpen && (
<>
<div
className="bg-opacity-50 fixed inset-0 z-40 bg-black lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
<div className="fixed top-0 left-0 z-50 h-full lg:hidden">
<Sidebar currentPath={currentPath} onNavigate={handleNavigate} />
</div>
</>
)}
<main className="bg-background flex flex-1 items-center justify-center">
{/* 移动端顶部栏 */}
<div className="bg-background fixed top-0 right-0 left-0 z-30 h-16 border-b border-gray-200 lg:hidden">
<div className="flex h-full items-center px-4">
<button
onClick={toggleSidebar}
className="text-text-primary hover:bg-background-secondary rounded-lg p-2 transition-colors"
type="button"
>
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<div className="flex-1 text-center">
<h1 className="text-text-primary text-lg font-semibold">
Afilmory Docs
</h1>
</div>
<div className="w-10" /> {/* 平衡按钮 */}
</div>
</div>
<div className="mx-4 mt-16 rounded-xl p-8 text-center lg:mt-0">
<div className="mb-6 flex items-center justify-center text-4xl">
{getRandomKaomoji()}
</div>
<h1 className="mb-1 text-3xl font-semibold">404</h1>
<p className="text-text-secondary text-lg">Page not found</p>
<button
onClick={() => handleNavigate('/')}
className="bg-accent mt-6 rounded-2xl px-4 py-2 text-white transition-opacity hover:opacity-90"
type="button"
>
Return Home <ArrowRight className="inline-block h-4 w-4" />
</button>
</div>
{/* 移动端 TOC (404页面不需要但为了一致性保留结构) */}
<MobileTableOfContents currentPath={currentPath} />
</main>
</div>
)
}
const Component = matchedRoute.component
const meta = matchedRoute.meta as {
createdAt?: string
lastModified?: string
}
return (
<div className="bg-background flex h-screen">
{/* 桌面端侧边栏 */}
<div className="hidden lg:block">
<Sidebar currentPath={currentPath} onNavigate={handleNavigate} />
</div>
<>
<div
className="bg-opacity-50 fixed inset-0 z-40 bg-black/10 lg:hidden"
style={{ display: isSidebarOpen ? 'block' : 'none' }}
onClick={() => setIsSidebarOpen(false)}
/>
<div
className="fixed top-0 left-0 z-50 h-full lg:hidden"
style={{
transform: isSidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.3s ease-in-out',
}}
>
<Sidebar currentPath={currentPath} onNavigate={handleNavigate} />
</div>
</>
{/* 主内容区域 */}
<main
className="bg-background relative flex-1 overflow-y-auto"
ref={mainContentRef}
>
<div className="bg-background border-border sticky top-0 z-30 h-16 border-b backdrop-blur-3xl lg:hidden">
<div className="flex h-full items-center px-4">
<button
onClick={toggleSidebar}
className="text-text-primary hover:bg-background-secondary rounded-lg p-2 transition-colors"
type="button"
>
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<div className="flex-1 text-center">
<a href="/" className="select-none">
<h1 className="text-text-primary text-lg font-semibold">
Afilmory Docs
</h1>
</a>
</div>
<div className="w-10" /> {/* 平衡按钮 */}
</div>
</div>
<div className="mx-auto flex max-w-7xl">
{/* 文档内容 */}
<div className="w-full flex-1 px-4 py-6 lg:px-8 lg:py-12">
<article className="prose prose-lg bg-background max-w-none rounded-xl p-4 lg:p-8">
<MDX content={<Component />} />
<DocumentFooter
createdAt={meta.createdAt}
lastModified={meta.lastModified}
/>
</article>
</div>
{/* 桌面端目录 */}
<div className="hidden w-64 px-4 pt-6 lg:pt-12 xl:block">
<h4 className="mb-3 flex items-center gap-2 text-sm font-normal text-gray-600">
<AlignLeftIcon className="mr-1 inline-block h-4 w-4" />
On this page
</h4>
<div className="scrollbar-hide sticky top-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<TableOfContents
currentPath={currentPath}
handleScroll={handleScrollMainContent}
/>
</div>
</div>
</div>
{/* 移动端 TOC */}
<MobileTableOfContents
currentPath={currentPath}
handleScroll={handleScrollMainContent}
/>
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,147 @@
import { Monitor, Moon, Sun } from 'lucide-react'
import { m } from 'motion/react'
import { useTheme } from 'next-themes'
interface DocumentMetaProps {
createdAt?: string
lastModified?: string
}
export function DocumentFooter({ createdAt, lastModified }: DocumentMetaProps) {
const { theme, setTheme } = useTheme()
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString)
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai',
}).format(date)
} catch {
return dateString
}
}
const themeOptions = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'system', icon: Monitor, label: 'System' },
{ value: 'dark', icon: Moon, label: 'Dark' },
]
const activeIndex = themeOptions.findIndex((option) => option.value === theme)
return (
<div className="border-separator mt-12 border-t pt-4 lg:mt-16">
<div className="flex justify-between py-1">
{!createdAt && !lastModified ? (
<div />
) : (
<table
className="text-sm"
style={{
width: 'auto',
minWidth: 0,
margin: '0 0',
border: 'none',
background: 'transparent',
}}
>
<tbody>
{createdAt && (
<tr>
<td
style={{
padding: '0',
border: 'none',
background: 'transparent',
}}
className="text-text-secondary pr-4 align-top font-medium whitespace-nowrap"
>
Created At
</td>
<td
style={{
padding: '0',
border: 'none',
background: 'transparent',
}}
>
<time
dateTime={createdAt}
className="text-text-secondary rounded px-2 py-1 font-mono text-xs"
>
{formatDate(createdAt)}
</time>
</td>
</tr>
)}
{lastModified && (
<tr>
<td
style={{
padding: '0',
border: 'none',
background: 'transparent',
}}
className="text-text-secondary pr-4 align-top font-medium whitespace-nowrap"
>
Last Modified
</td>
<td
style={{
padding: '0',
border: 'none',
background: 'transparent',
}}
>
<time
dateTime={lastModified}
className="text-text-secondary rounded px-2 py-1 font-mono text-xs"
>
{formatDate(lastModified)}
</time>
</td>
</tr>
)}
</tbody>
</table>
)}
<div className="bg-background-secondary border-border relative flex items-center gap-1 rounded-full border p-1">
<m.div
className="bg-background border-border/50 absolute rounded-full border shadow-sm"
initial={false}
animate={{
x: activeIndex * 36, // 32px button width + 4px gap
width: 32,
height: 32,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
/>
{themeOptions.map((option) => (
<button
type="button"
key={option.value}
onClick={() => setTheme(option.value)}
className={`relative z-10 rounded-full p-2 transition-colors ${
theme === option.value
? 'text-text'
: 'text-text-secondary hover:text-text'
}`}
aria-label={`Switch to ${option.label} theme`}
title={option.label}
>
<option.icon className="h-4 w-4" />
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
import { MDXProvider } from '@mdx-js/react'
import type { Element, MDXComponents } from 'mdx/types'
const components: MDXComponents = {}
export function MDX({ content }: { content: Element }) {
return <MDXProvider components={components}>{content}</MDXProvider>
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { TableOfContents } from './TableOfContents'
interface MobileTableOfContentsProps {
currentPath: string
handleScroll?: (top: number) => void
}
export function MobileTableOfContents({
currentPath,
handleScroll,
}: MobileTableOfContentsProps) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
{/* TOC 按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="hover:bg-material-opaque text-text-primary bg-material-opaque border-border fixed right-4 bottom-6 z-50 flex h-12 w-12 items-center justify-center rounded-full border-[1px] shadow-2xl backdrop-blur-xl transition-all duration-200 hover:shadow-xl xl:hidden"
aria-label="Toggle TOC"
type="button"
>
<svg
className={`h-5 w-5 transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
)}
</svg>
</button>
{/* TOC 面板 */}
{isOpen && (
<>
{/* 背景遮罩 */}
<div
className="fixed inset-0 z-40 bg-black/20 xl:hidden"
onClick={() => setIsOpen(false)}
/>
{/* TOC 内容 */}
<div className="animate-in slide-in-from-bottom-4 fade-in bg-background-quaternary fixed right-4 bottom-20 z-50 max-h-96 w-80 overflow-hidden rounded-2xl shadow-2xl duration-300 xl:hidden">
<div className="flex items-center justify-between border-b border-gray-500/50 px-4 py-3">
<h3 className="text-text-primary text-base font-semibold">
</h3>
</div>
<div className="scrollbar-hide max-h-80 overflow-y-auto p-4">
<TableOfContents
currentPath={currentPath}
onItemClick={() => setIsOpen(false)}
handleScroll={handleScroll}
/>
</div>
</div>
</>
)}
</>
)
}

View File

@@ -0,0 +1,205 @@
import { ChevronRight } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import type { RouteConfig } from '../routes'
import { routes } from '../routes'
import { getMatchedRoute } from '../utils/routes'
interface SidebarProps {
currentPath?: string
onNavigate?: (path: string) => void
}
interface NavigationItem {
path: string
title: string
children?: NavigationItem[]
}
// 构建嵌套的导航树结构
function buildNavigationTree(routes: RouteConfig[]): NavigationItem[] {
const tree: NavigationItem[] = []
const pathMap = new Map<string, NavigationItem>()
// 先创建所有节点
routes.forEach((route) => {
const item: NavigationItem = {
path: route.path,
title: route.title,
children: [],
}
pathMap.set(route.path, item)
})
// 构建树结构
routes.forEach((route) => {
const item = pathMap.get(route.path)!
const pathParts = route.path.split('/').filter(Boolean)
if (pathParts.length === 0) {
// 根路径
tree.push(item)
} else if (pathParts.length === 1) {
// 一级路径
tree.push(item)
} else {
// 多级路径,找到父级
const parentPath = `/${pathParts.slice(0, -1).join('/')}`
const parent = pathMap.get(parentPath)
if (parent) {
parent.children!.push(item)
} else {
// 如果没有找到父级,作为顶级项添加
tree.push(item)
}
}
})
return tree
}
interface NavigationItemProps {
item: NavigationItem
currentPath?: string
onNavigate?: (path: string) => void
level?: number
}
function NavigationItemComponent({
item,
currentPath,
onNavigate,
level = 0,
}: NavigationItemProps) {
// 检查是否应该展开:当前路径是该项目的子路径,或者当前路径就是该项目且有子项目
const shouldExpand = useCallback(() => {
if (!currentPath) return false
// 如果当前路径以该项目路径开头且不完全相等,说明是子路径
if (currentPath.startsWith(item.path) && currentPath !== item.path) {
// 确保是真正的子路径(避免 /api 匹配 /api-docs 这种情况)
const remainingPath = currentPath.slice(item.path.length)
return remainingPath.startsWith('/')
}
// 如果当前路径就是该项目路径,且有子项目,也展开
if (
currentPath === item.path &&
item.children &&
item.children.length > 0
) {
return true
}
return false
}, [currentPath, item.path, item.children])
const [isExpanded, setIsExpanded] = useState(shouldExpand)
const isActive = currentPath
? getMatchedRoute(currentPath)?.path === item.path
: false
const hasChildren = item.children && item.children.length > 0
// 当 currentPath 改变时,重新计算是否应该展开
useEffect(() => {
setIsExpanded(shouldExpand())
}, [shouldExpand])
const handleTitleClick = () => {
onNavigate?.(item.path)
}
const handleArrowClick = (e: React.MouseEvent) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}
return (
<div className="w-full px-2">
<div
className={`
flex w-full
items-center rounded-2xl transition-all duration-200 ease-in-out select-none
${
isActive
? 'text-blue bg-blue/15 font-medium'
: 'hover:bg-background-tertiary/50 text-gray-600 dark:text-gray-400'
}
${level > 0 ? 'pl-3' : ''}
`}
>
<button
onClick={handleTitleClick}
className="flex-1 truncate px-3 py-3 text-left text-base lg:py-2.5 lg:text-sm"
type="button"
>
{item.title}
</button>
{hasChildren && (
<button
onClick={handleArrowClick}
className="mr-1 rounded-xl p-3 transition-all duration-200 hover:bg-gray-100 lg:p-2 dark:hover:bg-gray-700"
type="button"
>
<ChevronRight
className={`h-5 w-5 text-gray-500 transition-transform duration-200 lg:h-4 lg:w-4 dark:text-gray-500 ${
isExpanded ? 'rotate-90' : ''
}`}
/>
</button>
)}
</div>
{hasChildren && isExpanded && (
<div className="mt-1 space-y-1">
{item.children!.map((child) => (
<NavigationItemComponent
key={child.path}
item={child}
currentPath={currentPath}
onNavigate={onNavigate}
level={level + 1}
/>
))}
</div>
)}
</div>
)
}
export function Sidebar({ currentPath, onNavigate }: SidebarProps) {
const navigationTree = buildNavigationTree(routes)
return (
<aside className="relative h-screen w-64 overflow-x-hidden overflow-y-auto bg-transparent p-2 ">
<div className="bg-material-thick border-border h-full w-full border-[1px] border-solid backdrop-blur-2xl md:border-none md:bg-transparent">
<div className=" flex items-center px-4 py-6">
<img
src="https://github.com/Afilmory/assets/blob/main/512-mac.png?raw=true"
alt="Afilmory"
className="h-14 w-14 rounded-t-lg"
/>
<div className="ml-3 flex-1">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Afilmory
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Documentation
</p>
</div>
</div>
<nav className="space-y-1">
{navigationTree.map((item) => (
<NavigationItemComponent
key={item.path}
item={item}
currentPath={currentPath}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
</aside>
)
}

View File

@@ -0,0 +1,306 @@
import { ScrollArea } from '@radix-ui/react-scroll-area'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { TocItem } from '../toc-data'
import { getTocByPath } from '../toc-data'
// Custom hook to track active TOC item position for the thumb indicator
function useTocThumb(
containerRef: React.RefObject<HTMLDivElement | null>,
activeId: string | null,
) {
const [pos, setPos] = useState<[number, number]>([0, 0])
useLayoutEffect(() => {
if (!containerRef.current || !activeId) return
const container = containerRef.current
const activeElement = container.querySelector(
`a[href="#${activeId}"]`,
) as HTMLElement
if (!activeElement) return
const top = activeElement.offsetTop
const height = activeElement.clientHeight
setPos([top, height])
}, [activeId, containerRef])
return pos
}
interface TableOfContentsProps {
currentPath: string
onItemClick?: () => void
handleScroll?: (top: number) => void
}
interface TocItemProps {
item: TocItem
activeId: string | null
level: number
onItemClick?: () => void
handleScroll?: (top: number) => void
}
// Helper functions for SVG indicator positioning
function getItemOffset(depth: number): number {
if (depth <= 2) return 16
if (depth === 3) return 32
return 48
}
function getLineOffset(depth: number): number {
return depth >= 3 ? 12 : 0
}
function TocItemComponent({
item,
activeId,
level,
onItemClick,
handleScroll,
}: TocItemProps) {
const isActive = activeId === item.id
const hasChildren = item.children && item.children.length > 0
return (
<li>
<a
href={`#${item.id}`}
className={`
relative block py-1.5 text-sm transition-colors
${isActive ? 'text-accent font-medium' : 'text-text-tertiary hover:text-text-primary'}
`}
style={{
paddingInlineStart: `${getItemOffset(level)}px`,
}}
onClick={(e) => {
e.preventDefault()
const element = document.querySelector(`#${item.id}`)
if (element && element instanceof HTMLElement) {
const elementTop = element.offsetTop
console.info('Navigating to:', element, 'Top:', elementTop)
handleScroll?.(elementTop - 74)
}
onItemClick?.()
}}
>
{item.text}
</a>
{hasChildren && (
<ul>
{item.children!.map((child) => (
<TocItemComponent
key={child.id}
item={child}
activeId={activeId}
level={level + 1}
onItemClick={onItemClick}
handleScroll={handleScroll}
/>
))}
</ul>
)}
</li>
)
}
export function TableOfContents({
currentPath,
onItemClick,
handleScroll,
}: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const pos = useTocThumb(containerRef, activeId)
const [svg, setSvg] = useState<{
path: string
width: number
height: number
}>()
// 获取当前页面的 TOC 数据
const currentToc = getTocByPath(currentPath)
// 计算 SVG 路径和尺寸
useLayoutEffect(() => {
if (!containerRef.current || !currentToc || currentToc.length === 0) return
const container = containerRef.current
function calculateSvg(): void {
if (container.clientHeight === 0) return
let w = 0
let h = 0
const d: string[] = []
const getAllItems = (
items: TocItem[],
currentLevel = 1,
): Array<{ item: TocItem; level: number }> => {
const result: Array<{ item: TocItem; level: number }> = []
for (const item of items) {
result.push({ item, level: currentLevel })
if (item.children) {
result.push(...getAllItems(item.children, currentLevel + 1))
}
}
return result
}
const allItems = currentToc ? getAllItems(currentToc) : []
for (const [i, { item, level }] of allItems.entries()) {
const element = container.querySelector(
`a[href="#${item.id}"]`,
) as HTMLElement
if (!element) continue
const styles = getComputedStyle(element)
const offset = getLineOffset(level) + 1
const top = element.offsetTop + Number.parseFloat(styles.paddingTop)
const bottom =
element.offsetTop +
element.clientHeight -
Number.parseFloat(styles.paddingBottom)
w = Math.max(offset, w)
h = Math.max(h, bottom)
d.push(`${i === 0 ? 'M' : 'L'}${offset} ${top}`, `L${offset} ${bottom}`)
}
setSvg({
path: d.join(' '),
width: w + 1,
height: h,
})
}
const observer = new ResizeObserver(calculateSvg)
calculateSvg()
observer.observe(container)
return () => {
observer.disconnect()
}
}, [currentToc])
// 监听滚动,高亮当前标题
useEffect(() => {
if (!currentToc || currentToc.length === 0) return
const observer = new IntersectionObserver(
(entries) => {
// 找到可见的标题中最上面的一个
const visibleEntries = entries.filter((entry) => entry.isIntersecting)
if (visibleEntries.length > 0) {
visibleEntries.sort((a, b) => {
const aRect = a.boundingClientRect
const bRect = b.boundingClientRect
return aRect.top - bRect.top
})
setActiveId(visibleEntries[0].target.id)
}
},
{
rootMargin: '-20px 0px -80% 0px',
threshold: 0.1,
},
)
const getAllIds = (items: TocItem[]): string[] => {
const ids: string[] = []
for (const item of items) {
ids.push(item.id)
if (item.children) {
ids.push(...getAllIds(item.children))
}
}
return ids
}
const allIds = getAllIds(currentToc)
// 观察所有标题元素
allIds.forEach((id) => {
const element = document.querySelector(`#${id}`)
if (element) {
observer.observe(element)
}
})
return () => {
observer.disconnect()
}
}, [currentToc])
// 如果当前页面没有TOC数据不显示
if (!currentToc || currentToc.length === 0) {
return null
}
return (
<nav className="space-y-1">
<ScrollArea className="max-h-screen-safe flex flex-col">
<div className="relative min-h-0 text-sm" ref={containerRef}>
{/* SVG 指示器背景 */}
{svg && (
<>
{/* 半透明灰色背景线 */}
<div
className="absolute start-0 top-0"
style={{
width: svg.width,
height: svg.height,
maskImage: `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none" /></svg>`,
)}")`,
}}
>
<div className="bg-text-quaternary/30 h-full" />
</div>
{/* 高亮的活跃指示器 */}
<div
className="absolute start-0 top-0"
style={{
width: svg.width,
height: svg.height,
maskImage: `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none" /></svg>`,
)}")`,
}}
>
<div
className="bg-accent transition-all"
style={{
marginTop: pos[0],
height: pos[1],
}}
/>
</div>
</>
)}
<ul className="text-sm">
{currentToc.map((item) => (
<TocItemComponent
key={item.id}
item={item}
activeId={activeId}
level={1}
onItemClick={onItemClick}
handleScroll={handleScroll}
/>
))}
</ul>
</div>
</ScrollArea>
</nav>
)
}

View File

@@ -0,0 +1,4 @@
export { MDX } from './MDX'
export { MobileTableOfContents } from './MobileTableOfContents'
export { Sidebar } from './Sidebar'
export { TableOfContents } from './TableOfContents'

View File

@@ -0,0 +1,72 @@
import { AlertCircle, AlertTriangle, CheckCircle, Info } from 'lucide-react'
import * as React from 'react'
type CalloutType = 'info' | 'warning' | 'error' | 'success'
interface CalloutProps {
type?: CalloutType
children: React.ReactNode
className?: string
}
const typeStyles: Record<CalloutType, string> = {
info: 'bg-blue-50/80 border border-blue-200/60 text-blue-900 backdrop-blur-sm',
warning:
'bg-amber-50/80 border border-amber-200/60 text-amber-900 backdrop-blur-sm',
error: 'bg-red-50/80 border border-red-200/60 text-red-900 backdrop-blur-sm',
success:
'bg-green-50/80 border border-green-200/60 text-green-900 backdrop-blur-sm',
}
const iconColors: Record<CalloutType, string> = {
info: 'text-blue-500',
warning: 'text-amber-500',
error: 'text-red-500',
success: 'text-green-500',
}
const typeIcons: Record<CalloutType, React.ReactNode> = {
info: <Info className="h-6 w-6" />,
warning: <AlertTriangle className="h-6 w-6" />,
error: <AlertCircle className="h-6 w-6" />,
success: <CheckCircle className="h-6 w-6" />,
}
export function Callout({
type = 'info',
children,
className = '',
}: CalloutProps) {
return (
<div
className={`
flex items-start gap-4
rounded-2xl px-6 py-4 my-6
shadow-sm shadow-black/5
${typeStyles[type]}
${className}
transition-all duration-200 ease-out
border-0
`
.replaceAll(/\s+/g, ' ')
.trim()}
role="alert"
>
<div className="min-w-0 flex-1 pt-5 ">
<div
className="inline-flex justify-start gap-5 text-sm leading-relaxed font-medium *:m-0"
style={{ lineHeight: '1.25rem' }}
>
{' '}
<span
className={`flex-shrink-0 ${iconColors[type]} flex items-center`}
style={{ height: '1.25rem' }}
>
{typeIcons[type]}
</span>
{children}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import './styles/index.css'
import { StrictMode } from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import App from './App.tsx'
import { Providers } from './providers.tsx'
const url_path = window.location.pathname
if (import.meta.env.DEV) {
createRoot(document.querySelector('#root')!).render(
<StrictMode>
<Providers>
<App url={url_path} />
</Providers>
</StrictMode>,
)
} else {
hydrateRoot(
document.querySelector('#root')!,
<StrictMode>
<Providers>
<App url={url_path} />
</Providers>
</StrictMode>,
)
}

View File

@@ -0,0 +1,18 @@
import './styles/index.css'
import { StrictMode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import App from './App'
import { Providers } from './providers'
export function render(url: string) {
const html = renderToStaticMarkup(
<StrictMode>
<Providers>
<App url={url} />
</Providers>
</StrictMode>,
)
return { html }
}

View File

@@ -0,0 +1,24 @@
import type { Transition } from 'motion/react'
import { domAnimation, LazyMotion, MotionConfig } from 'motion/react'
import { ThemeProvider } from 'next-themes'
import type { ReactNode } from 'react'
interface ProvidersProps {
children: ReactNode
}
const smoothPreset: Transition = {
type: 'spring',
duration: 0.4,
bounce: 0,
}
export function Providers({ children }: ProvidersProps) {
return (
<ThemeProvider defaultTheme="system" disableTransitionOnChange enableSystem>
<LazyMotion features={domAnimation}>
<MotionConfig transition={smoothPreset}>{children}</MotionConfig>
</LazyMotion>
</ThemeProvider>
)
}

View File

@@ -0,0 +1,91 @@
[
{
"path": "/",
"title": "Overview",
"meta": {
"title": "Overview",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-07-20T22:35:03+08:00"
}
},
{
"path": "/deployment",
"title": "Deployment",
"meta": {
"title": "Deployment",
"description": "Guide to deploying Afilmory in production environments, including Docker and configuration options.",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-08-08T08:28:42.778+08:00"
}
},
{
"path": "/deployment/cloudflare-pages",
"title": "Cloudflare Pages",
"meta": {
"title": "Cloudflare Pages",
"description": "Guide to deploying Afilmory via Cloudflare Pages.",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-08-08T17:39:40+08:00"
}
},
{
"path": "/deployment/docker",
"title": "Docker",
"meta": {
"title": "Docker",
"description": "Guide to deploying Afilmory via Docker.",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-08-31T11:08:35+08:00"
}
},
{
"path": "/deployment/github-action",
"title": "Github Action",
"meta": {
"title": "Github Action",
"description": "Guide to deploying Afilmory via Github Action.",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-08-08T17:39:40+08:00"
}
},
{
"path": "/deployment/github-pages",
"title": "Github Pages",
"meta": {
"title": "Github Pages",
"description": "Guide to deploying Afilmory via Github Pages.",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-08-08T17:39:40+08:00"
}
},
{
"path": "/deployment/vercel",
"title": "Vercel",
"meta": {
"title": "Vercel",
"description": "Guide to deploying Afilmory via Vercel.",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-08-08T17:39:40+08:00"
}
},
{
"path": "/docs-site",
"title": "Docs site",
"meta": {
"title": "Docs site",
"description": "Docs site introduction and contribution guide",
"createdAt": "2025-09-11T17:05:55+08:00",
"lastModified": "2025-09-11T17:05:55+08:00"
}
},
{
"path": "/storage",
"title": "Storage providers",
"meta": {
"title": "Storage providers",
"description": "Afilmory can work with multiple storage providers, including S3, Git and local file system",
"createdAt": "2025-08-12T15:09:08+08:00",
"lastModified": "2025-08-31T11:08:35+08:00"
}
}
]

125
packages/docs/src/routes.ts Normal file
View File

@@ -0,0 +1,125 @@
// Auto-generated route configuration
// This file is automatically generated by the route-generator plugin
// Do not edit manually - your changes will be overwritten
import Route2 from '../contents/deployment/cloudflare-pages.mdx'
import Route3 from '../contents/deployment/docker.mdx'
import Route4 from '../contents/deployment/github-action.mdx'
import Route5 from '../contents/deployment/github-pages.mdx'
import Route1 from '../contents/deployment/index.mdx'
import Route6 from '../contents/deployment/vercel.mdx'
import Route7 from '../contents/docs-site.mdx'
import Route0 from '../contents/index.mdx'
import Route8 from '../contents/storage/index.mdx'
export interface RouteConfig {
path: string
component: React.ComponentType
title: string
meta: Record<string, unknown>
}
export const routes: RouteConfig[] = [
{
path: '/',
component: Route0,
title: 'Overview',
meta: {
title: 'Overview',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-07-20T22:35:03+08:00',
},
},
{
path: '/deployment',
component: Route1,
title: 'Deployment',
meta: {
title: 'Deployment',
description:
'Guide to deploying Afilmory in production environments, including Docker and configuration options.',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-08-08T08:28:42.778+08:00',
},
},
{
path: '/deployment/cloudflare-pages',
component: Route2,
title: 'Cloudflare Pages',
meta: {
title: 'Cloudflare Pages',
description: 'Guide to deploying Afilmory via Cloudflare Pages.',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-08-08T17:39:40+08:00',
},
},
{
path: '/deployment/docker',
component: Route3,
title: 'Docker',
meta: {
title: 'Docker',
description: 'Guide to deploying Afilmory via Docker.',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-08-31T11:08:35+08:00',
},
},
{
path: '/deployment/github-action',
component: Route4,
title: 'Github Action',
meta: {
title: 'Github Action',
description: 'Guide to deploying Afilmory via Github Action.',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-08-08T17:39:40+08:00',
},
},
{
path: '/deployment/github-pages',
component: Route5,
title: 'Github Pages',
meta: {
title: 'Github Pages',
description: 'Guide to deploying Afilmory via Github Pages.',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-08-08T17:39:40+08:00',
},
},
{
path: '/deployment/vercel',
component: Route6,
title: 'Vercel',
meta: {
title: 'Vercel',
description: 'Guide to deploying Afilmory via Vercel.',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-08-08T17:39:40+08:00',
},
},
{
path: '/docs-site',
component: Route7,
title: 'Docs site',
meta: {
title: 'Docs site',
description: 'Docs site introduction and contribution guide',
createdAt: '2025-09-11T17:05:55+08:00',
lastModified: '2025-09-11T17:05:55+08:00',
},
},
{
path: '/storage',
component: Route8,
title: 'Storage providers',
meta: {
title: 'Storage providers',
description:
'Afilmory can work with multiple storage providers, including S3, Git and local file system',
createdAt: '2025-08-12T15:09:08+08:00',
lastModified: '2025-08-31T11:08:35+08:00',
},
},
]
export default routes

View File

@@ -0,0 +1,349 @@
@import './tailwind.css';
/* Apple-style documentation prose styling */
.prose {
@apply text-gray-900 dark:text-gray-100;
max-width: none;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
system-ui, sans-serif;
font-feature-settings:
'kern' 1,
'liga' 1,
'calt' 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 移动端优化 */
@media (max-width: 768px) {
.prose {
font-size: 0.9rem;
line-height: 1.65;
}
}
.prose h1 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 700;
font-size: 2.5rem;
line-height: 1.15;
letter-spacing: -0.025em;
margin-bottom: 1.5rem;
margin-top: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
/* 移动端标题优化 */
@media (max-width: 768px) {
.prose h1 {
font-size: 1.875rem;
margin-bottom: 1rem;
}
}
.prose h2 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 2rem;
line-height: 1.2;
letter-spacing: -0.02em;
margin-top: 3rem;
margin-bottom: 1.25rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
/* 移动端H2标题优化 */
@media (max-width: 768px) {
.prose h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
}
.prose h3 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1.5rem;
line-height: 1.3;
letter-spacing: -0.015em;
margin-top: 2rem;
margin-bottom: 1rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
/* 移动端H3标题优化 */
@media (max-width: 768px) {
.prose h3 {
font-size: 1.25rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
}
.prose h4 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1.25rem;
line-height: 1.35;
letter-spacing: -0.01em;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
.prose h5 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1.125rem;
line-height: 1.4;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
.prose h6 {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
font-size: 1rem;
line-height: 1.5;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
.prose p {
@apply text-text;
margin-bottom: 1.25rem;
line-height: 1.6;
font-size: 1rem;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
text-wrap: pretty;
}
.prose strong {
@apply text-gray-900 dark:text-gray-100;
font-weight: 600;
}
.prose em {
font-style: italic;
@apply text-gray-900 dark:text-gray-100;
}
.prose code {
@apply text-text-secondary;
padding: 0.2rem 0.4rem;
border-radius: 6px;
font-size: 0.9em;
font-family:
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
monospace;
font-weight: 500;
}
&:where([data-theme='dark'], [data-theme='dark'] *) {
.shiki,
.shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
.prose pre {
@apply text-text border-border border-solid border-[1px];
padding: 1.25rem;
border-radius: 12px;
overflow-x: auto;
margin: 1.5rem 0;
font-family:
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.prose pre code {
background-color: transparent;
padding: 0;
border: none;
border-radius: 0;
font-weight: 400;
}
.prose ul,
.prose ol {
@apply text-gray-900 dark:text-gray-100;
margin: 1.25rem 0;
padding-left: 1.75rem;
}
.prose ul {
list-style-type: none;
}
.prose ul > li {
position: relative;
}
.prose ul > li::before {
content: '•';
@apply text-gray-500 dark:text-gray-400;
font-weight: bold;
position: absolute;
left: -1.25rem;
top: 0;
}
.prose ol {
list-style-type: decimal;
}
.prose ol > li::marker {
@apply text-gray-500 dark:text-gray-400;
font-weight: 500;
}
.prose li {
margin: 0.75rem 0;
line-height: 1.6;
}
.prose li > p {
margin-bottom: 0.5rem;
}
.prose blockquote {
@apply border-l-4 border-blue-600 bg-blue-50 dark:bg-blue-900/20;
padding: 1.25rem 1.5rem;
margin: 2rem 0;
border-radius: 8px;
position: relative;
}
.prose blockquote p {
@apply text-gray-900 dark:text-gray-100;
font-style: italic;
margin-bottom: 0;
font-size: 1.1em;
line-height: 1.7;
}
.prose blockquote::before {
content: '"';
@apply text-gray-400 dark:text-gray-500;
font-size: 3rem;
font-weight: bold;
position: absolute;
top: -0.5rem;
left: 1rem;
opacity: 0.3;
}
.prose a {
@apply text-link;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
border-radius: 4px;
padding: 0 2px;
}
.prose a:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
.prose a:active {
transform: scale(0.98);
}
/* Table styling */
.prose table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
@apply border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800;
border-radius: 8px;
overflow: hidden;
margin: 1.5rem 0;
}
.prose th {
@apply bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-600;
font-weight: 600;
padding: 0.75rem 1rem;
text-align: left;
}
.prose td {
padding: 0.75rem 1rem;
@apply border-b border-gray-100 dark:border-gray-700 text-gray-900 dark:text-gray-100;
}
.prose tr:last-child td {
border-bottom: none;
}
/* Horizontal rule */
.prose hr {
border: none;
@apply border-t border-gray-200 dark:border-gray-700;
margin: 3rem 0;
}
/* Image styling */
.prose img {
margin: 1.5rem 0;
}
/* Badge/Tag styling */
.prose .badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
@apply bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
}
/* Focus and accessibility */
.prose *:focus {
@apply outline-2 outline-blue-600 dark:outline-blue-400;
outline-offset: 2px;
}
.mermaid-diagram {
@apply mx-auto my-8 flex items-center justify-center;
}
.mermaid-diagram img {
@apply max-h-[300px] lg:max-h-[400px] w-auto max-w-full;
min-height: 0;
}
.mermaid-dark {
display: none;
}
@media (prefers-color-scheme: dark) {
.mermaid-light {
display: none;
}
.mermaid-dark {
display: block;
}
}

View File

@@ -0,0 +1,9 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin 'tailwind-scrollbar';
@plugin 'tailwindcss-animate';
@plugin 'tailwindcss-safe-area';
@import 'tailwind-scrollbar-hide/v4';
@import '@pastel-palette/tailwindcss/dist/theme-oklch.css';
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

View File

@@ -0,0 +1,666 @@
// This file is automatically generated by the toc-extractor plugin
// Do not edit manually
export interface TocItem {
id: string
level: number
text: string
children?: TocItem[]
}
export interface FileToc {
file: string
path: string
title: string
toc: TocItem[]
}
export const tocData: FileToc[] = [
{
file: 'index.mdx',
path: '/',
title: 'Overview',
toc: [
{
id: 'heading-overview',
level: 1,
text: 'Overview',
children: [
{
id: 'heading-features',
level: 2,
text: '🌟 Features',
children: [
{
id: 'heading-core-functionality',
level: 3,
text: 'Core Functionality',
children: [],
},
{
id: 'heading-image-processing',
level: 3,
text: 'Image Processing',
children: [],
},
{
id: 'heading-advanced-features',
level: 3,
text: 'Advanced Features',
children: [],
},
],
},
{
id: 'heading-technical-architecture',
level: 2,
text: '🏗️ Technical Architecture',
children: [
{
id: 'heading-frontend-tech-stack',
level: 3,
text: 'Frontend Tech Stack',
children: [],
},
{
id: 'heading-build-system',
level: 3,
text: 'Build System',
children: [],
},
{
id: 'heading-storage-architecture',
level: 3,
text: 'Storage Architecture',
children: [],
},
],
},
{
id: 'heading-quick-start',
level: 2,
text: '🚀 Quick Start',
children: [
{
id: 'heading-docker-deployment',
level: 3,
text: 'Docker Deployment',
children: [],
},
],
},
{
id: 'heading-configuration-options',
level: 2,
text: '⚙️ Configuration Options',
children: [
{
id: 'heading-remote-repository-configuration-repo',
level: 4,
text: 'Remote Repository Configuration (`repo`)',
children: [],
},
{
id: 'heading-storage-configuration-storage',
level: 4,
text: 'Storage Configuration (`storage`)',
children: [],
},
{
id: 'heading-build-options-options',
level: 4,
text: 'Build Options (`options`)',
children: [],
},
{
id: 'heading-performance-configuration-performance',
level: 4,
text: 'Performance Configuration (`performance`)',
children: [],
},
{
id: 'heading-logging-configuration-logging',
level: 4,
text: 'Logging Configuration (`logging`)',
children: [],
},
],
},
{
id: 'heading-cli-commands',
level: 2,
text: '📋 CLI Commands',
children: [
{
id: 'heading-build-commands',
level: 3,
text: 'Build Commands',
children: [],
},
{
id: 'heading-development-commands',
level: 3,
text: 'Development Commands',
children: [],
},
{
id: 'heading-notes',
level: 3,
text: 'Notes',
children: [],
},
],
},
{
id: 'heading-advanced-usage',
level: 2,
text: '🔧 Advanced Usage',
children: [
{
id: 'heading-custom-storage-provider',
level: 3,
text: 'Custom Storage Provider',
children: [],
},
{
id: 'heading-custom-image-processing',
level: 3,
text: 'Custom Image Processing',
children: [],
},
],
},
{
id: 'heading-license',
level: 2,
text: '📄 License',
children: [],
},
{
id: 'heading-related-links',
level: 2,
text: '🔗 Related Links',
children: [],
},
],
},
],
},
{
file: 'deployment/index.mdx',
path: '/deployment',
title: 'Deployment',
toc: [
{
id: 'heading-deployment',
level: 1,
text: 'Deployment',
children: [],
},
],
},
{
file: 'deployment/cloudflare-pages.mdx',
path: '/deployment/cloudflare-pages',
title: 'Cloudflare Pages',
toc: [
{
id: 'heading-cloudflare-pages-deployment',
level: 1,
text: 'Cloudflare Pages Deployment',
children: [],
},
],
},
{
file: 'deployment/docker.mdx',
path: '/deployment/docker',
title: 'Docker',
toc: [
{
id: 'heading-docker-deployment',
level: 1,
text: 'Docker Deployment',
children: [
{
id: 'heading-tldr',
level: 2,
text: 'TL;DR',
children: [],
},
{
id: 'heading-quick-start',
level: 2,
text: '🚀 Quick Start',
children: [
{
id: 'heading-prerequisites',
level: 3,
text: 'Prerequisites',
children: [],
},
{
id: 'heading-1-configuration-files',
level: 3,
text: '1. Configuration Files',
children: [],
},
{
id: 'heading-2-dockerfile-setup',
level: 3,
text: '2. Dockerfile setup',
children: [],
},
{
id: 'heading-3-docker-compose-setup',
level: 3,
text: '3. Docker Compose Setup',
children: [],
},
{
id: 'heading-4-building-and-running',
level: 3,
text: '4. Building and Running',
children: [
{
id: 'heading-option-1-using-docker-compose-recommended',
level: 4,
text: 'Option 1: Using Docker Compose (Recommended)',
children: [],
},
{
id: 'heading-option-2-manual-docker-build',
level: 4,
text: 'Option 2: Manual Docker Build',
children: [],
},
],
},
],
},
{
id: 'heading-configuration-details',
level: 2,
text: '📋 Configuration Details',
children: [
{
id: 'heading-storage-providers',
level: 3,
text: 'Storage Providers',
children: [],
},
{
id: 'heading-performance-tuning',
level: 3,
text: 'Performance Tuning',
children: [],
},
],
},
],
},
],
},
{
file: 'deployment/github-action.mdx',
path: '/deployment/github-action',
title: 'Github Action',
toc: [
{
id: 'heading-github-action-deployment',
level: 1,
text: 'Github Action Deployment',
children: [],
},
],
},
{
file: 'deployment/github-pages.mdx',
path: '/deployment/github-pages',
title: 'Github Pages',
toc: [
{
id: 'heading-github-pages-deployment',
level: 1,
text: 'Github Pages Deployment',
children: [],
},
],
},
{
file: 'deployment/vercel.mdx',
path: '/deployment/vercel',
title: 'Vercel',
toc: [
{
id: 'heading-vercel-deployment',
level: 1,
text: 'Vercel Deployment',
children: [],
},
],
},
{
file: 'docs-site.mdx',
path: '/docs-site',
title: 'Docs site',
toc: [
{
id: 'heading-afilmory-docs-site',
level: 1,
text: 'Afilmory Docs Site',
children: [
{
id: 'heading-features',
level: 2,
text: 'Features',
children: [],
},
{
id: 'heading-quick-start',
level: 2,
text: 'Quick Start',
children: [
{
id: 'heading-development',
level: 3,
text: 'Development',
children: [],
},
{
id: 'heading-building',
level: 3,
text: 'Building',
children: [],
},
{
id: 'heading-preview',
level: 3,
text: 'Preview',
children: [],
},
],
},
{
id: 'heading-project-structure',
level: 2,
text: 'Project Structure',
children: [],
},
{
id: 'heading-writing-documentation',
level: 2,
text: 'Writing Documentation',
children: [
{
id: 'heading-file-based-routing',
level: 3,
text: 'File-based Routing',
children: [],
},
{
id: 'heading-mdx-format',
level: 3,
text: 'MDX Format',
children: [],
},
{
id: 'heading-frontmatter',
level: 3,
text: 'Frontmatter',
children: [],
},
],
},
{
id: 'heading-development-guide',
level: 2,
text: 'Development Guide',
children: [
{
id: 'heading-adding-new-content',
level: 3,
text: 'Adding New Content',
children: [],
},
{
id: 'heading-custom-components',
level: 3,
text: 'Custom Components',
children: [],
},
{
id: 'heading-styling',
level: 3,
text: 'Styling',
children: [],
},
{
id: 'heading-route-generation-plugin',
level: 3,
text: 'Route Generation Plugin',
children: [],
},
],
},
{
id: 'heading-configuration',
level: 2,
text: 'Configuration',
children: [
{
id: 'heading-vite-configuration',
level: 3,
text: 'Vite Configuration',
children: [],
},
{
id: 'heading-supported-languages',
level: 3,
text: 'Supported Languages',
children: [],
},
],
},
{
id: 'heading-deployment',
level: 2,
text: 'Deployment',
children: [],
},
{
id: 'heading-scripts-reference',
level: 2,
text: 'Scripts Reference',
children: [],
},
{
id: 'heading-contributing',
level: 2,
text: 'Contributing',
children: [],
},
],
},
],
},
{
file: 'storage/index.mdx',
path: '/storage',
title: 'Storage providers',
toc: [
{
id: 'heading-storage-providers',
level: 1,
text: 'Storage Providers',
children: [
{
id: 'heading-supported-providers',
level: 2,
text: 'Supported Providers',
children: [
{
id: 'heading-s3-compatible-storage',
level: 3,
text: 'S3-Compatible Storage',
children: [],
},
{
id: 'heading-github-storage',
level: 3,
text: 'GitHub Storage',
children: [],
},
{
id: 'heading-local-file-system',
level: 3,
text: 'Local File System',
children: [],
},
],
},
{
id: 'heading-photo-processing-workflow',
level: 2,
text: 'Photo Processing Workflow',
children: [
{
id: 'heading-image-processing-pipeline',
level: 3,
text: 'Image Processing Pipeline',
children: [],
},
{
id: 'heading-storage-organization',
level: 3,
text: 'Storage Organization',
children: [],
},
{
id: 'heading-processing-commands',
level: 3,
text: 'Processing Commands',
children: [],
},
],
},
{
id: 'heading-environment-specific-configurations',
level: 2,
text: 'Environment-Specific Configurations',
children: [
{
id: 'heading-development-setup',
level: 3,
text: 'Development Setup',
children: [],
},
{
id: 'heading-production-setup',
level: 3,
text: 'Production Setup',
children: [],
},
],
},
{
id: 'heading-api-limitations',
level: 2,
text: 'API Limitations',
children: [
{
id: 'heading-github-storage-limits',
level: 3,
text: 'GitHub Storage Limits',
children: [],
},
{
id: 'heading-storage-provider-comparison',
level: 3,
text: 'Storage Provider Comparison',
children: [],
},
],
},
{
id: 'heading-security-considerations',
level: 2,
text: 'Security Considerations',
children: [
{
id: 'heading-s3-bucket-policies',
level: 3,
text: 'S3 Bucket Policies',
children: [],
},
{
id: 'heading-github-repository-settings',
level: 3,
text: 'GitHub Repository Settings',
children: [],
},
{
id: 'heading-environment-variables',
level: 3,
text: 'Environment Variables',
children: [],
},
],
},
],
},
],
},
]
// Helper function to find TOC data by file path
export function getTocByFile(filePath: string): TocItem[] | undefined {
const item = tocData.find((item) => item.file === filePath)
return item?.toc
}
// Helper function to find TOC data by route path
export function getTocByPath(routePath: string): TocItem[] | undefined {
const item = tocData.find((item) => item.path === routePath)
return item?.toc
}
// Helper function to flatten TOC tree into a simple array
export function flattenToc(toc: TocItem[]): TocItem[] {
const result: TocItem[] = []
function traverse(items: TocItem[]) {
for (const item of items) {
result.push({
id: item.id,
level: item.level,
text: item.text,
})
if (item.children && item.children.length > 0) {
traverse(item.children)
}
}
}
traverse(toc)
return result
}
// Helper function to get all headings at a specific level
export function getHeadingsByLevel(toc: TocItem[], level: number): TocItem[] {
const result: TocItem[] = []
function traverse(items: TocItem[]) {
for (const item of items) {
if (item.level === level) {
result.push(item)
}
if (item.children && item.children.length > 0) {
traverse(item.children)
}
}
}
traverse(toc)
return result
}

View File

@@ -0,0 +1,24 @@
export const kaomojiList = [
'Σ(lliд゚ノ)ノ',
'(((゚Д゚;)))',
'。・゚・(つд`゚)・゚・',
'(;へ:)',
'(;´д`)ゞ',
'(; ̄Д ̄)',
'_',
'_',
'(╥﹏╥)',
'(ノД`)・゜・。',
'(つд⊂)',
'(つω`。)',
'(;д;)',
'(_)',
'(;ω;)',
'(;д;)',
'(;д;`)',
]
export function getRandomKaomoji() {
const index = Math.floor(Math.random() * kaomojiList.length)
return kaomojiList[index]
}

View File

@@ -0,0 +1,18 @@
import type { RouteConfig } from '../routes'
import routes from '../routes'
export const getMatchedRoute = (
currentPath: string,
): RouteConfig | undefined => {
return routes.find((route) => {
const normalizedCurrentPath =
currentPath.endsWith('/') && currentPath !== '/'
? currentPath.slice(0, -1)
: currentPath
const normalizedRoutePath =
route.path.endsWith('/') && route.path !== '/'
? route.path.slice(0, -1)
: route.path
return normalizedRoutePath === normalizedCurrentPath
})
}

1
packages/docs/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "contents"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,77 @@
import path from 'node:path'
import mdx from '@mdx-js/rollup'
import shikiRehype from '@shikijs/rehype'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { codeInspectorPlugin } from 'code-inspector-plugin'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import { defineConfig } from 'vite'
import remarkHeading from './plugins/remark-heading'
import { routeGenerator } from './plugins/route-generater'
import { tocExtractor } from './plugins/toc-extractor'
// https://vite.dev/config/
export default defineConfig({
plugins: [
tocExtractor({
contentsDir: 'contents',
outputDir: 'src',
outputFile: 'toc-data.ts',
}),
routeGenerator({
contentsDir: 'contents',
outputDir: 'src',
outputFile: 'routes.ts',
indexFile: 'index',
}),
tailwindcss(),
{
enforce: 'pre',
...mdx({
// files inside contents will be processed as MDX
include: ['contents/**/*.{md,mdx}'],
providerImportSource: '@mdx-js/react',
remarkPlugins: [
[remarkHeading, { prefix: 'heading-' }],
remarkFrontmatter,
remarkGfm,
],
rehypePlugins: [
[
shikiRehype,
{
themes: { light: 'github-light', dark: 'github-dark' },
inline: 'tailing-curly-colon',
langs: [
'javascript',
'typescript',
'jsx',
'tsx',
'mdx',
'json',
'shell',
'bash',
'yaml',
'dockerfile',
'css',
],
},
],
],
}),
},
react(),
codeInspectorPlugin({
bundler: 'vite',
hotKeys: ['altKey'],
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
})

3347
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,17 @@ catalog:
'@t3-oss/env-core': 0.13.8
dotenv-expand: 12.0.2
typescript: 5.8.3
'tailwind-variants': '1.0.0'
'@tailwindcss/postcss': '4.1.11'
'@tailwindcss/typography': '0.5.16'
'tailwind-scrollbar': '4.0.2'
'tailwindcss': '4.1.11'
'tailwindcss-animate': '1.0.7'
'tailwindcss-safe-area': '0.6.0'
'tailwindcss-uikit-colors': '1.0.0-alpha.1'
'@egoist/tailwindcss-icons': '1.9.0'
'@iconify-json/mingcute': '1.2.3'
tailwind-variants: 1.0.0
'@tailwindcss/postcss': 4.1.11
'@tailwindcss/typography': 0.5.16
tailwind-scrollbar: 4.0.2
tailwindcss: 4.1.11
tailwindcss-animate: 1.0.7
tailwindcss-safe-area: 0.6.0
tailwindcss-uikit-colors: 1.0.0-alpha.1
'@egoist/tailwindcss-icons': 1.9.0
'@iconify-json/mingcute': 1.2.3
onlyBuiltDependencies:
- esbuild
- puppeteer

448
scripts/create-doc.ts Normal file
View File

@@ -0,0 +1,448 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as clack from '@clack/prompts'
import { cancel, isCancel } from '@clack/prompts'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
interface DocOptions {
title: string
description?: string
category?: string
filename: string
template: 'basic' | 'guide' | 'api' | 'deployment'
}
/**
* Generate current timestamp in Asia/Shanghai timezone
*/
function getCurrentTimestamp(): string {
return new Date()
.toLocaleString('en-GB', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(
/(\d{2})\/(\d{2})\/(\d{4}), (\d{2}:\d{2}:\d{2})/,
'$3-$2-$1T$4+08:00',
)
}
/**
* Get available categories by scanning existing directories
*/
function getCategories(): string[] {
const contentsDir = join(__dirname, '..', 'packages', 'docs', 'contents')
try {
const { readdirSync, statSync } = require('node:fs')
return readdirSync(contentsDir)
.filter((item) => {
const fullPath = join(contentsDir, item)
return statSync(fullPath).isDirectory()
})
.sort()
} catch {
return ['deployment', 'guides', 'api', 'tutorial']
}
}
/**
* Generate content template based on type
*/
function generateTemplate(options: DocOptions): string {
const timestamp = getCurrentTimestamp()
const frontmatter = `---
title: ${options.title}${
options.description
? `
description: ${options.description}`
: ''
}
createdAt: ${timestamp}
lastModified: ${timestamp}
---`
switch (options.template) {
case 'guide': {
return `${frontmatter}
# ${options.title}
## Overview
Brief description of what this guide covers.
## Prerequisites
- Requirement 1
- Requirement 2
## Step 1: Getting Started
Description of the first step.
\`\`\`bash
# Example command
pnpm install
\`\`\`
## Step 2: Configuration
Description of configuration step.
\`\`\`json
{
"example": "configuration"
}
\`\`\`
## Step 3: Implementation
Implementation details.
## Troubleshooting
Common issues and solutions.
## Next Steps
- Link to related guides
- Additional resources
`
}
case 'api': {
return `${frontmatter}
# ${options.title}
## Overview
API documentation for ${options.title}.
## Authentication
Details about authentication requirements.
## Endpoints
### GET /api/example
Description of the endpoint.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| \`id\` | string | Yes | The unique identifier |
**Response:**
\`\`\`json
{
"success": true,
"data": {
"id": "example",
"name": "Example"
}
}
\`\`\`
## Error Handling
Common error responses and their meanings.
## Examples
Code examples for different programming languages.
`
}
case 'deployment': {
return `${frontmatter}
# ${options.title}
## Overview
Guide for deploying using ${options.title}.
## Prerequisites
- System requirements
- Account setup
## Installation
Step-by-step installation process.
\`\`\`bash
# Installation commands
\`\`\`
## Configuration
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| \`EXAMPLE_VAR\` | Example variable | Yes |
### Configuration File
\`\`\`json
{
"example": "configuration"
}
\`\`\`
## Deployment Steps
1. Step one
2. Step two
3. Step three
## Verification
How to verify the deployment was successful.
## Troubleshooting
Common deployment issues and solutions.
`
}
default: {
return `${frontmatter}
# ${options.title}
## Introduction
Brief introduction to the topic.
## Content
Main content goes here.
## Examples
\`\`\`bash
# Example command
echo "Hello World"
\`\`\`
## Conclusion
Summary and next steps.
`
}
}
}
/**
* Validate filename
*/
function validateFilename(filename: string): string | undefined {
if (!filename.trim()) {
return 'Filename is required'
}
const cleanFilename = filename.trim().toLowerCase()
// Check for valid characters
if (!/^[a-z0-9-]+$/.test(cleanFilename)) {
return 'Filename can only contain lowercase letters, numbers, and hyphens'
}
// Check length
if (cleanFilename.length < 2 || cleanFilename.length > 50) {
return 'Filename must be between 2 and 50 characters'
}
}
/**
* Main CLI function
*/
async function main() {
clack.intro('📝 Create New Documentation')
// Get document title
const title = await clack.text({
message: 'What is the document title?',
placeholder: 'My Awesome Guide',
validate: (value) => {
if (!value.trim()) return 'Title is required'
},
})
if (isCancel(title)) {
cancel('Operation cancelled.')
process.exit(0)
}
// Get document description (optional)
const description = await clack.text({
message: 'Provide a brief description (optional):',
placeholder: 'A comprehensive guide to...',
})
if (isCancel(description)) {
cancel('Operation cancelled.')
process.exit(0)
}
// Get template type
const template = await clack.select({
message: 'Choose a template:',
options: [
{ value: 'basic', label: 'Basic - Simple document structure' },
{ value: 'guide', label: 'Guide - Step-by-step tutorial' },
{ value: 'api', label: 'API - API documentation' },
{ value: 'deployment', label: 'Deployment - Deployment guide' },
],
})
if (isCancel(template)) {
cancel('Operation cancelled.')
process.exit(0)
}
// Get existing categories
const categories = getCategories()
// Choose category or create new
const categoryChoice = await clack.select({
message: 'Choose a category:',
options: [
...categories.map((cat) => ({ value: cat, label: cat })),
{ value: '__new__', label: '✨ Create new category' },
{ value: '__root__', label: '📁 Root level (no category)' },
],
})
if (isCancel(categoryChoice)) {
cancel('Operation cancelled.')
process.exit(0)
}
let category: string | undefined
if (categoryChoice === '__new__') {
const newCategory = await clack.text({
message: 'Enter new category name:',
placeholder: 'my-category',
validate: (value) => validateFilename(value),
})
if (isCancel(newCategory)) {
cancel('Operation cancelled.')
process.exit(0)
}
category = newCategory.trim().toLowerCase()
} else if (categoryChoice !== '__root__') {
category = categoryChoice
}
// Get filename
const defaultFilename = title
.toLowerCase()
.replaceAll(/[^\w\s-]/g, '')
.replaceAll(/\s+/g, '-')
.replaceAll(/-+/g, '-')
.replaceAll(/^-|-$/g, '')
const filename = await clack.text({
message: 'Enter filename (without .mdx extension):',
placeholder: defaultFilename,
defaultValue: defaultFilename,
validate: validateFilename,
})
if (isCancel(filename)) {
cancel('Operation cancelled.')
process.exit(0)
}
// Confirm before creating
const confirm = await clack.confirm({
message: `Create document at ${category ? `${category}/` : ''}${filename}.mdx?`,
})
if (isCancel(confirm) || !confirm) {
cancel('Operation cancelled.')
process.exit(0)
}
// Create the document
const spinner = clack.spinner()
spinner.start('Creating document...')
try {
const contentsDir = join(__dirname, '..', 'packages', 'docs', 'contents')
const targetDir = category ? join(contentsDir, category) : contentsDir
const filePath = join(targetDir, `${filename}.mdx`)
// Create directory if it doesn't exist
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true })
}
// Check if file already exists
if (existsSync(filePath)) {
spinner.stop('File already exists!')
clack.log.error(
`Document ${category ? `${category}/` : ''}${filename}.mdx already exists`,
)
process.exit(1)
}
// Generate content
const options: DocOptions = {
title,
description: description || undefined,
category,
filename,
template: template as DocOptions['template'],
}
const content = generateTemplate(options)
// Write file
writeFileSync(filePath, content, 'utf-8')
spinner.stop('Document created successfully!')
clack.note(
`Location: packages/docs/contents/${category ? `${category}/` : ''}${filename}.mdx\n` +
`Template: ${template}\n` +
`Title: ${title}`,
'Document Details',
)
clack.outro('✨ Happy writing!')
} catch (error) {
spinner.stop('Failed to create document')
clack.log.error(
`Error: ${error instanceof Error ? error.message : String(error)}`,
)
process.exit(1)
}
}
// Run the CLI
main().catch((error) => {
console.error('Unexpected error:', error)
process.exit(1)
})

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* Update lastModified field in MDX files
*/
function updateLastModified(filePath: string): boolean {
try {
const content = readFileSync(filePath, 'utf-8')
// Check if the file has frontmatter
if (!content.startsWith('---')) {
console.info(`Skipping ${filePath}: no frontmatter found`)
return false
}
// Parse frontmatter
const frontmatterEnd = content.indexOf('---', 3)
if (frontmatterEnd === -1) {
console.info(`Skipping ${filePath}: invalid frontmatter format`)
return false
}
const frontmatter = content.slice(0, frontmatterEnd + 3)
const body = content.slice(frontmatterEnd + 3)
// Check if lastModified field exists
if (!frontmatter.includes('lastModified:')) {
console.info(`Skipping ${filePath}: no lastModified field found`)
return false
}
// Generate new timestamp
const currentDate = new Date()
.toLocaleString('en-GB', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(
/(\d{2})\/(\d{2})\/(\d{4}), (\d{2}:\d{2}:\d{2})/,
'$3-$2-$1T$4+08:00',
)
// Update lastModified field
const updatedFrontmatter = frontmatter.replace(
/lastModified:\s[^\n]+$/m,
`lastModified: ${currentDate}`,
)
const updatedContent = updatedFrontmatter + body
writeFileSync(filePath, updatedContent, 'utf-8')
console.info(`✅ Updated ${filePath} lastModified to ${currentDate}`)
return true
} catch (error) {
console.error(`❌ Failed to update ${filePath}:`, error)
return false
}
}
/**
* Get modified documentation files
*/
function getModifiedDocsFiles(): string[] {
try {
// Get staged files
const stagedFiles = execSync('git diff --cached --name-only', {
encoding: 'utf-8',
})
.trim()
.split('\n')
.filter(Boolean)
// Filter for md/mdx files in docs contents directory
const docsFiles = stagedFiles.filter(
(file) =>
file.startsWith('packages/docs/contents/') &&
(file.endsWith('.md') || file.endsWith('.mdx')),
)
return docsFiles.map((file) => join(__dirname, '..', file))
} catch (error) {
console.error('Failed to get modified files:', error)
return []
}
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2)
// If file paths are provided as arguments, process them directly
if (args.length > 0) {
let hasUpdates = false
for (const filePath of args) {
if (updateLastModified(filePath)) {
hasUpdates = true
}
}
if (hasUpdates) {
console.info('\n📝 Please check updates and re-add to staging area:')
console.info('git add packages/docs/contents/')
}
return
}
// Get modified documentation files
const modifiedFiles = getModifiedDocsFiles()
if (modifiedFiles.length === 0) {
console.info('✨ No documentation files need updating')
return
}
console.info(`🔍 Found ${modifiedFiles.length} modified documentation files:`)
modifiedFiles.forEach((file) => console.info(` - ${file}`))
let hasUpdates = false
for (const filePath of modifiedFiles) {
if (updateLastModified(filePath)) {
hasUpdates = true
}
}
if (hasUpdates) {
console.info(
'\n📝 Auto-updated lastModified fields, re-adding to staging area...',
)
try {
execSync('git add packages/docs/contents/', { stdio: 'inherit' })
console.info('✅ Successfully re-added to staging area')
} catch (error) {
console.error('❌ Failed to re-add to staging area:', error)
console.info('Please run manually: git add packages/docs/contents/')
}
}
}
// Run main function
main()