diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff7924 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules/ +.pnp/ +.pnp.js + +# Build output +dist/ +dist-ssr/ +*.local + +# Cache +.vite/ +.cache/ +*.tsbuildinfo + +# Env +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Test coverage +coverage/ + +# Supabase generated types +supabase/.temp/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d59e908 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +# Build output +dist/ +build/ + +# Dependencies +node_modules/ + +# Cache +.cache/ +*.cache + +# Misc +coverage/ +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b59fc1b --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# PineAgents Web + +Static site (Vite + React) for the PineAgents product — the public web display port (PineAgentsWeb) for the PineAgents OPC 超级个体平台. Built output is served with a minimal Node server that supports SPA fallback (e.g. direct access to `/docs/channels`). + +> 项目身份:`pineagents-web`。页面内视觉/文案仍沿用既有 QwenPaw 素材,未做品牌改造。 + +## Prerequisites + +- Node.js 18+ +- pnpm (recommended) or npm + +## Install + +```bash +pnpm install +# or +npm install +``` + +## Development + +```bash +pnpm run dev +# or +npm run dev +``` + +Dev server runs at `http://localhost:5173` (or the next free port). + +## Build + +```bash +pnpm run build +# or +npm run build +``` + +Output is in `dist/`. + +## Preview (local only) + +- **Vite preview** (quick local check): + `pnpm run preview` + +- **Production-style server** (same as prod, no PM2): + `pnpm run preview:prod` + Serves `dist/` at `http://localhost:8088` with SPA fallback. + +## Production with PM2 + +Use PM2 to run the preview server in production: auto-restart on crash, logs, and easy start/stop. + +### 1. Install PM2 globally (one-time) + +```bash +npm install -g pm2 +``` + +If the script runs without global PM2, it will try to install it automatically. + +### 2. Build and start + +```bash +cd website +pnpm run build +pm2 start ecosystem.config.cjs +``` + +Or from repo root (install + build + PM2 start/reload): + +```bash +bash scripts/website_build.sh +``` + +Or use the helper script (installs PM2 if missing, then starts): + +```bash +bash scripts/start.sh +``` + +Default port: **8088**. Override with `PORT=3000 pm2 start ecosystem.config.cjs` or by editing `ecosystem.config.cjs`. + +### 3. PM2 commands + +| Command | Description | +| ---------------------------------------------- | ----------------------------------------- | +| `pm2 status` | List apps and status | +| `pm2 logs pineagents-web` | Stream stdout/stderr logs | +| `pm2 restart pineagents-web` | Restart the app | +| `pm2 reload ecosystem.config.cjs --update-env` | Reload with latest config (zero-downtime) | +| `pm2 stop pineagents-web` | Stop the app | +| `pm2 delete pineagents-web` | Remove from PM2 (stop + delete) | + +### 4. After code/build changes + +Rebuild then reload so PM2 serves the new `dist/`: + +```bash +pnpm run build +pm2 reload ecosystem.config.cjs --update-env +``` + +Or from repo root: + +```bash +bash scripts/website_build.sh +``` + +## Config + +- **Port**: Set in `ecosystem.config.cjs` (`env.PORT` / `args`) or env `PORT`. Default `8088`. +- **App name**: `pineagents-web` in `ecosystem.config.cjs` (used in `pm2 logs/restart`). diff --git a/scripts/build-search-index.mjs b/scripts/build-search-index.mjs new file mode 100644 index 0000000..8633ede --- /dev/null +++ b/scripts/build-search-index.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Build search-index.json from docs/*.zh.md and *.en.md for client-side search. + * Run before vite build so dist gets the index. + */ +import { readdir, readFile, writeFile } from "fs/promises"; +import { join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const docsDir = join(__dirname, "..", "public", "docs"); +const outPath = join(__dirname, "..", "public", "search-index.json"); + +const EXCERPT_LEN = 800; + +function slugifyHeading(text) { + const s = text + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-zA-Z0-9_\-\u4e00-\u9fa5]/g, ""); + return s || "section"; +} + +function stripMarkdownForExcerpt(md) { + return md + .replace(//g, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/^#+\s+.+$/gm, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[*_`#]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function parseDoc(md) { + const lines = md.split("\n"); + let title = ""; + const headings = []; + let body = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const h2 = /^##\s+(.+)$/.exec(line); + const h3 = /^###\s+(.+)$/.exec(line); + if (i === 0 && line.startsWith("# ")) { + title = line + .slice(2) + .replace(/#+\s*$/, "") + .trim(); + continue; + } + if (h2) { + const text = h2[1].replace(/#+\s*$/, "").trim(); + headings.push({ level: 2, text, id: slugifyHeading(text) }); + continue; + } + if (h3) { + const text = h3[1].replace(/#+\s*$/, "").trim(); + headings.push({ level: 3, text, id: slugifyHeading(text) }); + continue; + } + if (!title && line.trim()) title = line.replace(/#+\s*$/, "").trim(); + body.push(line); + } + + const fullBody = body.join("\n"); + const excerpt = stripMarkdownForExcerpt(fullBody).slice(0, EXCERPT_LEN); + + return { title: title || "Untitled", headings, excerpt }; +} + +async function main() { + const files = await readdir(docsDir); + const entries = []; + + for (const f of files) { + const zhMatch = f.match(/^(.+)\.zh\.md$/); + const enMatch = f.match(/^(.+)\.en\.md$/); + const lang = zhMatch ? "zh" : enMatch ? "en" : null; + const slug = zhMatch?.[1] ?? enMatch?.[1]; + if (!lang || !slug) continue; + + const path = join(docsDir, f); + const md = await readFile(path, "utf-8"); + const { title, headings, excerpt } = parseDoc(md); + entries.push({ + slug, + lang, + title, + headings: headings.map((h) => ({ text: h.text, id: h.id })), + excerpt, + }); + } + + await writeFile(outPath, JSON.stringify(entries), "utf-8"); + console.log(`Wrote ${entries.length} doc entries to ${outPath}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/spa-fallback-pages.mjs b/scripts/spa-fallback-pages.mjs new file mode 100644 index 0000000..1d8cf33 --- /dev/null +++ b/scripts/spa-fallback-pages.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * After vite build, copy index.html into docs/ and docs// so that + * static hosts (e.g. GitHub Pages) serve the SPA for each route without + * needing a Node server. Must match DOC_SLUGS in src/pages/Docs.tsx. + */ +import { mkdir, readFile, writeFile } from "fs/promises"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const distDir = join(__dirname, "..", "dist"); + +const DOC_SLUGS = [ + "intro", + "quickstart", + "console", + "channels", + "skills", + "memory", + "compact", + "commands", + "heartbeat", + "config", + "backup", + "cli", + "creator", + "community", + "contributing", +]; + +async function main() { + const indexHtml = await readFile(join(distDir, "index.html"), "utf-8"); + const BLOG_SLUGS = [ + "introducing-qwenpaw-driver", + "qwenpaw-developer-day-collection", + "play-with-qwenpaw-pet", + ]; + const paths = [ + "docs", + "docs/search", + ...DOC_SLUGS.map((s) => `docs/${s}`), + "blog", + ...BLOG_SLUGS.map((s) => `blog/${s}`), + ]; + for (const p of paths) { + const out = join(distDir, p, "index.html"); + await mkdir(dirname(out), { recursive: true }); + await writeFile(out, indexHtml); + } + console.log("[spa-fallback-pages] Wrote index.html for /, /docs, /docs/*"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});