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

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, '.'),
},
},
})