diff --git a/.github/workflows/site-build.yml b/.github/workflows/site-build.yml
index 2d2207109d..ebd5c4ca68 100644
--- a/.github/workflows/site-build.yml
+++ b/.github/workflows/site-build.yml
@@ -24,7 +24,9 @@ jobs:
with:
node-version: "22"
cache: npm
- cache-dependency-path: package-lock.json
+ cache-dependency-path: |
+ package-lock.json
+ website/package-lock.json
- name: Install JS dependencies
run: npm ci
@@ -32,6 +34,10 @@ jobs:
- name: Build web SPAs
run: npm run build
+ - name: Build documentation site
+ working-directory: website
+ run: npm ci && npm run build
+
- name: Prepare deploy bundle
run: |
set -euo pipefail
@@ -43,7 +49,7 @@ jobs:
mkdir -p _bundle/public/admin
cp -a web/dist/admin/. _bundle/public/admin/
mkdir -p _bundle/public/docs
- cp -a web/dist/docs/. _bundle/public/docs/
+ cp -a website/dist/. _bundle/public/docs/
mkdir -p _bundle/worker
cp -a cloudflare_site/worker/. _bundle/worker/
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000000..5762cdad4a
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,6 @@
+---
+layout: page
+title: Fullsend Docs
+---
+
+
diff --git a/website/static/img/favicon.png b/docs/public/img/favicon.png
similarity index 100%
rename from website/static/img/favicon.png
rename to docs/public/img/favicon.png
diff --git a/website/static/img/logo.png b/docs/public/img/logo.png
similarity index 100%
rename from website/static/img/logo.png
rename to docs/public/img/logo.png
diff --git a/docs/site-deployment.md b/docs/site-deployment.md
index ce26015f1d..e249d7707a 100644
--- a/docs/site-deployment.md
+++ b/docs/site-deployment.md
@@ -2,13 +2,13 @@
## Overview
-This repository publishes a static documentation site. The root landing page is [`web/public/index.html`](../web/public/index.html); the interactive document graph is [`web/public/graph.html`](../web/public/graph.html) (served at `/graph.html`). **Vite** builds the **admin** SPA under **`web/dist/admin/`** (see [`web/admin/README.md`](../web/admin/README.md)). The **docs site** is built by **Docusaurus** from the **`website/`** directory, reading markdown from **`docs/`** and producing static HTML in **`website/build/`**. CI copies **`assets/`**, **`admin/`**, and **`docs/`** into **`_bundle/public/`** so the Worker serves **`/admin/`** and **`/docs/`** from the same static asset tree. OAuth/CORS hardening for that Worker is summarized in [`docs/admin-oauth-worker.md`](admin-oauth-worker.md) (path-specific CORS for `/api/github/user`, no separate “OAuth enabled” env flag).
+This repository publishes a static documentation site. The root landing page is [`web/public/index.html`](../web/public/index.html); the interactive document graph is [`web/public/graph.html`](../web/public/graph.html) (served at `/graph.html`). **Vite** builds the **admin** SPA under **`web/dist/admin/`** (see [`web/admin/README.md`](../web/admin/README.md)). The **docs site** is built by **VitePress** from the **`website/`** directory, reading markdown from **`docs/`** and producing static HTML in **`website/dist/`**. CI copies **`assets/`**, **`admin/`**, and **`docs/`** into **`_bundle/public/`** so the Worker serves **`/admin/`** and **`/docs/`** from the same static asset tree. OAuth/CORS hardening for that Worker is summarized in [`docs/admin-oauth-worker.md`](admin-oauth-worker.md) (path-specific CORS for `/api/github/user`, no separate “OAuth enabled” env flag).
-**Build Site** runs **`npm ci`** and **`npm run build`** at the repository root, then packs **`public/`** (static files, including `assets/` and `admin/` from `web/dist/`, plus `docs/` from `website/build/`) and **`worker/`** (TypeScript Worker from the same checkout—PR head on PR builds) under **`_bundle/`** in one artifact. **Deploy Site** checks out **only the default branch** (trusted [`cloudflare_site/wrangler.toml`](../cloudflare_site/wrangler.toml); never PR-controlled config on the secret-bearing runner), downloads the artifact to **`_bundle/`**, then **copies only** **`_bundle/public/`** and **`_bundle/worker/`** into **`cloudflare_site/`** (so a malicious artifact cannot overwrite `wrangler.toml` or other repo files), then runs Wrangler. Deployment uses **Cloudflare Workers with [static assets](https://developers.cloudflare.com/workers/static-assets/)** (not the legacy **Pages direct-upload** / `wrangler pages deploy` flow).
+**Build Site** runs **`npm ci`** and **`npm run build`** at the repository root, then packs **`public/`** (static files, including `assets/` and `admin/` from `web/dist/`, plus `docs/` from `website/dist/`) and **`worker/`** (TypeScript Worker from the same checkout—PR head on PR builds) under **`_bundle/`** in one artifact. **Deploy Site** checks out **only the default branch** (trusted [`cloudflare_site/wrangler.toml`](../cloudflare_site/wrangler.toml); never PR-controlled config on the secret-bearing runner), downloads the artifact to **`_bundle/`**, then **copies only** **`_bundle/public/`** and **`_bundle/worker/`** into **`cloudflare_site/`** (so a malicious artifact cannot overwrite `wrangler.toml` or other repo files), then runs Wrangler. Deployment uses **Cloudflare Workers with [static assets](https://developers.cloudflare.com/workers/static-assets/)** (not the legacy **Pages direct-upload** / `wrangler pages deploy` flow).
Two GitHub Actions workflows:
-- **Build Site** — on `pull_request` and `push` to `main`, checks out the PR head when relevant, installs Node dependencies, builds the admin SPA (Vite) and docs site (Docusaurus), assembles **`_bundle/public/`** and **`_bundle/worker/`**, uploads artifact **`site`** (`_bundle/` contents).
+- **Build Site** — on `pull_request` and `push` to `main`, checks out the PR head when relevant, installs Node dependencies, builds the admin SPA (Vite) and docs site (VitePress), assembles **`_bundle/public/`** and **`_bundle/worker/`**, uploads artifact **`site`** (`_bundle/` contents).
- **Deploy Site** — on successful **Build Site** via `workflow_run`, checks out the repo default ref (trusted Wrangler project files), downloads artifact **`site`** into **`_bundle/`**, copies **`public/`** and **`worker/`** into **`cloudflare_site/`**, then:
- **push to `main`:** `wrangler deploy` → production Worker traffic.
- **pull_request:** `wrangler versions upload --preview-alias pr-` → preview URL on `*.workers.dev` without changing production (alias falls back to `pr-` only when the same fork branch matches more than one open PR).
@@ -65,7 +65,7 @@ Disable **GitHub Pages** under **Settings → Pages** if it was only used for th
**Full stack (recommended for admin OAuth):** from the repository root, run **`npm run dev`** so Vite serves the SPA and Wrangler runs the site Worker with shared process env — see [`web/admin/README.md`](../web/admin/README.md).
-**Static tree + Worker (closer to production asset layout):** install dependencies, build admin SPA and Docusaurus site, copy the same layout CI uses under `cloudflare_site/public/`, then run Wrangler:
+**Static tree + Worker (closer to production asset layout):** install dependencies, build admin SPA and VitePress docs site, copy the same layout CI uses under `cloudflare_site/public/`, then run Wrangler:
```bash
npm ci
@@ -78,7 +78,7 @@ cp web/public/index.html cloudflare_site/public/index.html
cp web/public/graph.html cloudflare_site/public/graph.html
cp -a web/dist/assets/. cloudflare_site/public/assets/
cp -a web/dist/admin/. cloudflare_site/public/admin/
-cp -a website/build/. cloudflare_site/public/docs/
+cp -a website/dist/. cloudflare_site/public/docs/
cd cloudflare_site && npx wrangler@4 dev
```
diff --git a/package.json b/package.json
index 619ead9aa0..e4e8761cc6 100644
--- a/package.json
+++ b/package.json
@@ -13,13 +13,13 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest run --config vite.config.ts && vitest run --config cloudflare_site/worker/vitest.config.mts",
- "check": "svelte-check --tsconfig web/admin/tsconfig.json && svelte-check --tsconfig web/docs/tsconfig.json",
- "lint": "eslint web/admin/src/ web/docs/src/",
- "lint:fix": "eslint web/admin/src/ web/docs/src/ --fix",
- "format": "prettier --write 'web/{admin,docs}/src/**/*.{svelte,ts,js,css}'",
- "format:check": "prettier --check 'web/{admin,docs}/src/**/*.{svelte,ts,js,css}'",
- "stylelint": "stylelint 'web/{admin,docs}/src/**/*.{svelte,css}'",
- "stylelint:fix": "stylelint 'web/{admin,docs}/src/**/*.{svelte,css}' --fix"
+ "check": "svelte-check --tsconfig web/admin/tsconfig.json",
+ "lint": "eslint web/admin/src/",
+ "lint:fix": "eslint web/admin/src/ --fix",
+ "format": "prettier --write 'web/admin/src/**/*.{svelte,ts,js,css}'",
+ "format:check": "prettier --check 'web/admin/src/**/*.{svelte,ts,js,css}'",
+ "stylelint": "stylelint 'web/admin/src/**/*.{svelte,css}'",
+ "stylelint:fix": "stylelint 'web/admin/src/**/*.{svelte,css}' --fix"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.14.7",
diff --git a/vite.config.ts b/vite.config.ts
index 17c737669f..bebb651cb4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,7 +5,6 @@ import { normalizePath } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vitest/config";
import type { Plugin } from "vite";
-import { fullsendDocsPlugin } from "./web/docs/build/vitePluginDocs";
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.join(repoRoot, "web");
@@ -22,8 +21,6 @@ function spaFallbackPlugin(): Plugin {
req.url = "/index.html";
} else if (url.startsWith("/admin/") && !path.extname(url)) {
req.url = "/admin/index.html";
- } else if (url.startsWith("/docs/") && !path.extname(url)) {
- req.url = "/docs/index.html";
}
next();
});
@@ -94,11 +91,6 @@ export default defineConfig(({ command }) => ({
),
],
}),
- svelte({
- configFile: path.join(webRoot, "docs/svelte.config.js"),
- include: normalizePath(path.join(webRoot, "docs/**/*.svelte")),
- }),
- fullsendDocsPlugin(repoRoot),
spaFallbackPlugin(),
adminDevEnvLogPlugin(),
adminRequestLogPlugin(),
@@ -107,7 +99,6 @@ export default defineConfig(({ command }) => ({
rollupOptions: {
input: {
admin: path.join(webRoot, "admin/index.html"),
- docs: path.join(webRoot, "docs/index.html"),
},
},
},
@@ -118,11 +109,8 @@ export default defineConfig(({ command }) => ({
},
test: {
environment: "jsdom",
- environmentMatchGlobs: [["docs/build/**/*.test.ts", "node"]],
include: [
"admin/src/**/*.test.ts",
- "docs/build/**/*.test.ts",
- "docs/src/**/*.test.ts",
],
passWithNoTests: true,
},
diff --git a/website/.gitignore b/website/.gitignore
index 06cc35fcbe..0e1d48ce3e 100644
--- a/website/.gitignore
+++ b/website/.gitignore
@@ -1,4 +1,4 @@
node_modules/
-build/
-.docusaurus/
-.playwright-cli/
+dist/
+.vitepress/cache/
+.vitepress/.temp/
diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts
new file mode 100644
index 0000000000..323c4d50c3
--- /dev/null
+++ b/website/.vitepress/config.ts
@@ -0,0 +1,315 @@
+import { defineConfig } from 'vitepress'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const docsDir = path.resolve(__dirname, '..', '..', 'docs')
+
+function getMarkdownFiles(dir: string, base: string): { text: string; link: string }[] {
+ const fullDir = path.resolve(docsDir, dir)
+ if (!fs.existsSync(fullDir)) return []
+ const items: { text: string; link: string }[] = []
+ for (const entry of fs.readdirSync(fullDir).sort()) {
+ const entryPath = path.resolve(fullDir, entry)
+ if (entry.endsWith('.md') && entry !== 'README.md') {
+ const slug = entry.replace(/\.md$/, '')
+ const content = fs.readFileSync(entryPath, 'utf-8')
+ const fmTitleMatch = content.match(/^title:\s*["']?(.+?)["']?\s*$/m)
+ const titleMatch = content.match(/^#\s+(.+)$/m)
+ items.push({ text: fmTitleMatch?.[1] || titleMatch?.[1] || slug, link: `/${base}/${slug}` })
+ } else if (fs.statSync(entryPath).isDirectory()) {
+ const readme = path.resolve(entryPath, 'README.md')
+ if (fs.existsSync(readme)) {
+ const content = fs.readFileSync(readme, 'utf-8')
+ const titleMatch = content.match(/^#\s+(.+)$/m)
+ items.push({ text: titleMatch?.[1] || entry, link: `/${base}/${entry}/` })
+ }
+ }
+ }
+ return items
+}
+
+// Escape Vue-incompatible syntax ({ }, {{ }}, ) in markdown
+// before markdown-it processes it. Code fence tracking uses backtick-count
+// matching per CommonMark spec to correctly handle nested fences.
+function escapeVueSyntax(src: string): string {
+ const lines = src.split('\n')
+ let fenceLen = 0
+ let fenceChar = ''
+ return lines.map(line => {
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
+ if (fenceMatch) {
+ const ch = fenceMatch[1][0]
+ const len = fenceMatch[1].length
+ if (fenceLen === 0) {
+ fenceLen = len
+ fenceChar = ch
+ return line
+ }
+ if (ch === fenceChar && len >= fenceLen && line.trim() === ch.repeat(len)) {
+ fenceLen = 0
+ fenceChar = ''
+ return line
+ }
+ return line
+ }
+ if (fenceLen > 0) return line
+ return escapeLine(line)
+ }).join('\n')
+}
+
+const KNOWN_TAGS = /^<\/?(?:a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|legend|li|link|main|map|mark|menu|meta|meter|nav|noscript|object|ol|optgroup|option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|search|section|select|slot|small|source|span|strong|style|sub|summary|sup|table|tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|video|wbr|svg|path|g|circle|rect|line|polyline|polygon|text|defs|use|symbol)[\s>\/!]/i
+
+function escapeLine(line: string): string {
+ let result = ''
+ let inInlineCode = false
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i]
+ if (ch === '`') {
+ inInlineCode = !inInlineCode
+ result += '`'
+ } else if (!inInlineCode && ch === '{') {
+ result += '{'
+ } else if (!inInlineCode && ch === '}') {
+ result += '}'
+ } else if (!inInlineCode && ch === '<') {
+ const rest = line.slice(i)
+ if (KNOWN_TAGS.test(rest) || /^