mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
Afilmory Docs (#66)
This commit is contained in:
@@ -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
24
packages/docs/.gitignore
vendored
Normal 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
226
packages/docs/README.md
Normal 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.
|
||||
10
packages/docs/contents/deployment/cloudflare-pages.mdx
Normal file
10
packages/docs/contents/deployment/cloudflare-pages.mdx
Normal 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
|
||||
283
packages/docs/contents/deployment/docker.mdx
Normal file
283
packages/docs/contents/deployment/docker.mdx
Normal 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
10
packages/docs/contents/deployment/github-action.mdx
Normal file
10
packages/docs/contents/deployment/github-action.mdx
Normal 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
|
||||
10
packages/docs/contents/deployment/github-pages.mdx
Normal file
10
packages/docs/contents/deployment/github-pages.mdx
Normal 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
|
||||
16
packages/docs/contents/deployment/index.mdx
Normal file
16
packages/docs/contents/deployment/index.mdx
Normal 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)
|
||||
10
packages/docs/contents/deployment/vercel.mdx
Normal file
10
packages/docs/contents/deployment/vercel.mdx
Normal 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
|
||||
226
packages/docs/contents/docs-site.mdx
Normal file
226
packages/docs/contents/docs-site.mdx
Normal 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
|
||||
217
packages/docs/contents/index.mdx
Normal file
217
packages/docs/contents/index.mdx
Normal 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!
|
||||
262
packages/docs/contents/storage/index.mdx
Normal file
262
packages/docs/contents/storage/index.mdx
Normal 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
|
||||
```
|
||||
62
packages/docs/eslint.config.js
Normal file
62
packages/docs/eslint.config.js
Normal 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
14
packages/docs/index.html
Normal 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>
|
||||
67
packages/docs/package.json
Normal file
67
packages/docs/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
136
packages/docs/plugins/remark-heading.ts
Normal file
136
packages/docs/plugins/remark-heading.ts
Normal 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 }
|
||||
264
packages/docs/plugins/route-generater.ts
Normal file
264
packages/docs/plugins/route-generater.ts
Normal 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
|
||||
399
packages/docs/plugins/toc-extractor.ts
Normal file
399
packages/docs/plugins/toc-extractor.ts
Normal 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
|
||||
1
packages/docs/public/vite.svg
Normal file
1
packages/docs/public/vite.svg
Normal 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 |
92
packages/docs/scripts/build.ts
Normal file
92
packages/docs/scripts/build.ts
Normal 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
231
packages/docs/src/App.tsx
Normal 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
|
||||
1
packages/docs/src/assets/react.svg
Normal file
1
packages/docs/src/assets/react.svg
Normal 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 |
147
packages/docs/src/components/DocumentFooter.tsx
Normal file
147
packages/docs/src/components/DocumentFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
packages/docs/src/components/MDX.tsx
Normal file
8
packages/docs/src/components/MDX.tsx
Normal 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>
|
||||
}
|
||||
77
packages/docs/src/components/MobileTableOfContents.tsx
Normal file
77
packages/docs/src/components/MobileTableOfContents.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
205
packages/docs/src/components/Sidebar.tsx
Normal file
205
packages/docs/src/components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
306
packages/docs/src/components/TableOfContents.tsx
Normal file
306
packages/docs/src/components/TableOfContents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
packages/docs/src/components/index.ts
Normal file
4
packages/docs/src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { MDX } from './MDX'
|
||||
export { MobileTableOfContents } from './MobileTableOfContents'
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { TableOfContents } from './TableOfContents'
|
||||
72
packages/docs/src/components/markdown/Callout.tsx
Normal file
72
packages/docs/src/components/markdown/Callout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
packages/docs/src/main-client.tsx
Normal file
28
packages/docs/src/main-client.tsx
Normal 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>,
|
||||
)
|
||||
}
|
||||
18
packages/docs/src/main-static.tsx
Normal file
18
packages/docs/src/main-static.tsx
Normal 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 }
|
||||
}
|
||||
24
packages/docs/src/providers.tsx
Normal file
24
packages/docs/src/providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
packages/docs/src/routes.json
Normal file
91
packages/docs/src/routes.json
Normal 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
125
packages/docs/src/routes.ts
Normal 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
|
||||
349
packages/docs/src/styles/index.css
Normal file
349
packages/docs/src/styles/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/docs/src/styles/tailwind.css
Normal file
9
packages/docs/src/styles/tailwind.css
Normal 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] *));
|
||||
666
packages/docs/src/toc-data.ts
Normal file
666
packages/docs/src/toc-data.ts
Normal 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
|
||||
}
|
||||
24
packages/docs/src/utils/kaomoji.ts
Normal file
24
packages/docs/src/utils/kaomoji.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const kaomojiList = [
|
||||
'Σ(lliд゚ノ)ノ',
|
||||
'(((゚Д゚;)))',
|
||||
'。・゚・(つд`゚)・゚・',
|
||||
'(;へ:)',
|
||||
'(;´д`)ゞ',
|
||||
'(; ̄Д ̄)',
|
||||
'(;_;)',
|
||||
'(T_T)',
|
||||
'(╥﹏╥)',
|
||||
'(ノД`)・゜・。',
|
||||
'(つд⊂)',
|
||||
'(つω`。)',
|
||||
'(;д;)',
|
||||
'(;_;)',
|
||||
'(;ω;)',
|
||||
'(;д;)',
|
||||
'(;д;`)',
|
||||
]
|
||||
|
||||
export function getRandomKaomoji() {
|
||||
const index = Math.floor(Math.random() * kaomojiList.length)
|
||||
return kaomojiList[index]
|
||||
}
|
||||
18
packages/docs/src/utils/routes.ts
Normal file
18
packages/docs/src/utils/routes.ts
Normal 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
1
packages/docs/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
33
packages/docs/tsconfig.app.json
Normal file
33
packages/docs/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
packages/docs/tsconfig.json
Normal file
7
packages/docs/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
31
packages/docs/tsconfig.node.json
Normal file
31
packages/docs/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
77
packages/docs/vite.config.ts
Normal file
77
packages/docs/vite.config.ts
Normal 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, '.'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user