diff --git a/Makefile b/Makefile
index 4f8f914..36a771c 100644
--- a/Makefile
+++ b/Makefile
@@ -7,9 +7,9 @@ NODE_MAJOR ?= 22
export PATH := $(GO_BIN):$(PATH)
.PHONY: install install-openresty install-redis install-postgresql install-pgvector install-zhparser init-db \
- build build-server build-homepage build-panel build-dl \
- start start-openresty start-server start-homepage start-panel start-dl \
- stop stop-server stop-homepage stop-panel stop-dl stop-openresty restart
+ build build-server build-homepage build-panel build-dl build-docs \
+ start start-openresty start-server start-homepage start-panel start-dl start-docs \
+ stop stop-server stop-homepage stop-panel stop-dl stop-docs stop-openresty restart
# -----------------------------------------------------------------------------
# Dependency installation
@@ -94,7 +94,7 @@ init-db:
# Build targets
# -----------------------------------------------------------------------------
-build: build-cli build-server build-homepage build-panel build-dl
+build: build-cli build-server build-homepage build-panel build-dl build-docs
build-cli:
$(MAKE) -C client build
@@ -110,12 +110,15 @@ build-panel:
build-dl:
$(MAKE) -C ui/dl build
+build-docs:
+ $(MAKE) -C ui/docs build
+
# -----------------------------------------------------------------------------
# Run targets
# -----------------------------------------------------------------------------
-start: start-openresty start-server start-homepage start-panel start-dl
+start: start-openresty start-server start-homepage start-panel start-dl start-docs
start-server:
$(MAKE) -C server start
@@ -128,8 +131,11 @@ start-panel:
start-dl:
$(MAKE) -C ui/dl start
+start-docs:
+ $(MAKE) -C ui/docs start
-stop: stop-server stop-homepage stop-panel stop-dl stop-openresty
+
+stop: stop-server stop-homepage stop-panel stop-dl stop-docs stop-openresty
stop-server:
$(MAKE) -C server stop
@@ -142,6 +148,9 @@ stop-panel:
stop-dl:
$(MAKE) -C ui/dl stop
+stop-docs:
+ $(MAKE) -C ui/docs stop
+
start-openresty:
ifeq ($(OS),Darwin)
diff --git a/ui/docs/.github/workflows/build-deploy.yml b/ui/docs/.github/workflows/build-deploy.yml
new file mode 100644
index 0000000..d9a4e5a
--- /dev/null
+++ b/ui/docs/.github/workflows/build-deploy.yml
@@ -0,0 +1,44 @@
+name: Build and Deploy Docs
+
+on:
+ push:
+ paths:
+ - 'docs/**'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ - name: Install deps
+ run: cd docs && npm ci
+ - name: Import chapters
+ run: cd docs && node scripts/import-from-chapters.js
+ - name: Build site
+ run: cd docs && npm run build
+ - name: Generate PDFs
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y pandoc wkhtmltopdf rsync
+ cd docs/out
+ mkdir -p pdf
+ find . -name index.html -type f | while read file; do
+ slug=$(dirname "$file")
+ [ "$slug" = "." ] && slug="index"
+ wkhtmltopdf "$file" "pdf/${slug}.pdf"
+ done
+ - name: Deploy
+ env:
+ SSH_HOST: ${{ secrets.SSH_HOST }}
+ SSH_USER: ${{ secrets.SSH_USER }}
+ SSH_KEY: ${{ secrets.SSH_KEY }}
+ WEB_ROOT: ${{ secrets.WEB_ROOT }}
+ run: |
+ mkdir -p ~/.ssh
+ echo "$SSH_KEY" > ~/.ssh/id_rsa
+ chmod 600 ~/.ssh/id_rsa
+ rsync -avz -e "ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no" docs/out/ $SSH_USER@$SSH_HOST:$WEB_ROOT
diff --git a/ui/docs/.gitignore b/ui/docs/.gitignore
new file mode 100644
index 0000000..1c5ca90
--- /dev/null
+++ b/ui/docs/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.next
+out
+.DS_Store
+*.log
diff --git a/ui/docs/Makefile b/ui/docs/Makefile
new file mode 100644
index 0000000..39e858f
--- /dev/null
+++ b/ui/docs/Makefile
@@ -0,0 +1,88 @@
+SHELL := /bin/bash
+NODE_VERSION := $(shell node -v 2>/dev/null || echo "Not Found")
+YARN := $(shell command -v yarn 2>/dev/null)
+MAGICK := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev/null)
+OS := $(shell uname -s)
+
+.PHONY: init dev build export clean info icon start stop restart test
+
+icon:
+ @echo "๐จ Generating favicon and icon images..."
+ @if [ -z "$(MAGICK)" ]; then \
+ echo "โ ImageMagick not found."; \
+ if [ "$(OS)" = "Darwin" ]; then \
+ echo "๐ Try: brew install imagemagick"; \
+ elif [ -f /etc/debian_version ]; then \
+ echo "๐ Try: sudo apt install imagemagick"; \
+ elif [ -f /etc/redhat-release ]; then \
+ echo "๐ Try: sudo dnf install imagemagick"; \
+ fi; \
+ exit 1; \
+ fi
+ @mkdir -p public/icons
+ @$(MAGICK) ../logo.png -resize 32x32 public/icons/cloudnative_32.png
+ @$(MAGICK) ../logo.png -resize 64x64 -background none -define icon:auto-resize=64,48,32,16 public/favicon.ico
+ @echo "โ
Icons generated successfully."
+
+init:
+ @echo "๐ง Installing dependencies for docs..."
+ @if [ -z "$(YARN)" ]; then \
+ echo "โ ๏ธ Yarn not found. Attempting to install..."; \
+ if [ "$(OS)" = "Darwin" ]; then \
+ if command -v brew >/dev/null 2>&1; then \
+ brew install yarn; \
+ else \
+ echo "โ Homebrew not found. Please install Yarn manually."; exit 1; \
+ fi; \
+ elif [ -f /etc/debian_version ]; then \
+ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - && \
+ echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list && \
+ sudo apt update && sudo apt install -y yarn; \
+ elif [ -f /etc/redhat-release ]; then \
+ curl --silent --location https://dl.yarnpkg.com/rpm/yarn.repo | sudo tee /etc/yum.repos.d/yarn.repo && \
+ sudo yum install -y yarn; \
+ else \
+ echo "โ Unsupported OS. Please install Yarn manually."; exit 1; \
+ fi; \
+ fi
+ yarn install
+
+dev:
+ @echo "๐ Starting Next.js dev server (docs)..."
+ yarn next dev -p 3003
+
+start:
+ @echo "๐ Starting Next.js dev server (docs) in background..."
+ @nohup yarn next dev -p 3003 >/tmp/docs.log 2>&1 & echo $$! > docs.pid
+
+stop:
+ @echo "๐ Stopping Next.js dev server (docs)..."
+ @if [ -f docs.pid ]; then \
+ kill `cat docs.pid` >/dev/null 2>&1 || true; \
+ rm docs.pid; \
+ else \
+ echo "No running server"; \
+ fi
+
+restart: stop start
+
+test:
+ @echo "๐ Running tests..."
+ @yarn test || echo "No tests configured"
+
+build: init
+ yarn config set npmRegistryServer https://registry.npmmirror.com
+ @echo "๐จ Building docs..."
+ yarn next build
+
+export:
+ @echo "๐ฆ Exporting docs static site to ./out ..."
+ yarn next export
+
+clean:
+ @echo "๐งน Cleaning .next and out directories..."
+ rm -rf .next out
+
+info:
+ @echo "๐งพ Node.js version: $(NODE_VERSION)"
+
diff --git a/ui/docs/README.md b/ui/docs/README.md
new file mode 100644
index 0000000..1e5ea8b
--- /dev/null
+++ b/ui/docs/README.md
@@ -0,0 +1,12 @@
+# docs.svc.plus
+
+Static documentation site built with Next.js, TailwindCSS and Contentlayer.
+
+## Scripts
+- `npm run build` โ build static site
+- `node scripts/import-from-chapters.js` โ sync markdown from svc-design/documents
+
+## Deployment
+GitHub Actions builds the site, generates PDFs with pandoc/wkhtmltopdf and deploys via rsync. See `.github/workflows/build-deploy.yml`.
+
+Nginx config: `nginx/docs.svc.plus.conf`
diff --git a/ui/docs/app/[...slug]/page.tsx b/ui/docs/app/[...slug]/page.tsx
new file mode 100644
index 0000000..808821a
--- /dev/null
+++ b/ui/docs/app/[...slug]/page.tsx
@@ -0,0 +1,33 @@
+import { allDocs } from 'contentlayer/generated'
+import { notFound } from 'next/navigation'
+import Link from 'next/link'
+import { useReadingProgress } from '../../components/Progress'
+
+export function generateStaticParams() {
+ return allDocs.map((doc) => ({ slug: doc.slug.split('/') }))
+}
+
+export default function DocPage({ params }: { params: { slug: string[] } }) {
+ const slug = params.slug.join('/')
+ const doc = allDocs.find((d) => d.slug === slug)
+ useReadingProgress(slug)
+ if (!doc) return notFound()
+ const docs = allDocs.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+ const index = docs.findIndex((d) => d.slug === slug)
+ const prev = docs[index - 1]
+ const next = docs[index + 1]
+ const pdfUrl = `/pdf/${slug}.pdf`
+ return (
+
+ {doc.title}
+
+
+ {prev ? โ {prev.title} : }
+ {next ? {next.title} โ : }
+
+
+
+ )
+}
diff --git a/ui/docs/app/layout.tsx b/ui/docs/app/layout.tsx
new file mode 100644
index 0000000..2413e30
--- /dev/null
+++ b/ui/docs/app/layout.tsx
@@ -0,0 +1,24 @@
+import '../styles/globals.css'
+import { Sidebar } from '../components/Sidebar'
+import { TopScroller } from '../components/TopScroller'
+import { Footer } from '../components/Footer'
+
+export const metadata = {
+ title: 'SVC Docs',
+ description: 'svc.plus documents',
+}
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/ui/docs/app/page.tsx b/ui/docs/app/page.tsx
new file mode 100644
index 0000000..2631e6f
--- /dev/null
+++ b/ui/docs/app/page.tsx
@@ -0,0 +1,18 @@
+import Link from 'next/link'
+import { allDocs } from 'contentlayer/generated'
+
+export default function Home() {
+ const docs = allDocs.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+ return (
+
+
SVC Docs
+
+ {docs.map((doc) => (
+ -
+ {doc.title}
+
+ ))}
+
+
+ )
+}
diff --git a/ui/docs/components/Footer.tsx b/ui/docs/components/Footer.tsx
new file mode 100644
index 0000000..d2d64da
--- /dev/null
+++ b/ui/docs/components/Footer.tsx
@@ -0,0 +1,7 @@
+export function Footer() {
+ return (
+
+ )
+}
diff --git a/ui/docs/components/Progress.ts b/ui/docs/components/Progress.ts
new file mode 100644
index 0000000..c221857
--- /dev/null
+++ b/ui/docs/components/Progress.ts
@@ -0,0 +1,17 @@
+'use client'
+
+import { useEffect } from 'react'
+
+export function useReadingProgress(key: string) {
+ useEffect(() => {
+ const saved = localStorage.getItem(`progress:${key}`)
+ if (saved) {
+ window.scrollTo(0, parseInt(saved, 10))
+ }
+ const handler = () => {
+ localStorage.setItem(`progress:${key}`, String(window.scrollY))
+ }
+ window.addEventListener('scroll', handler)
+ return () => window.removeEventListener('scroll', handler)
+ }, [key])
+}
diff --git a/ui/docs/components/Sidebar.tsx b/ui/docs/components/Sidebar.tsx
new file mode 100644
index 0000000..a6504d9
--- /dev/null
+++ b/ui/docs/components/Sidebar.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { allDocs } from 'contentlayer/generated'
+
+export function Sidebar() {
+ const pathname = usePathname()
+ const docs = allDocs.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+ return (
+
+ )
+}
diff --git a/ui/docs/components/TopScroller.tsx b/ui/docs/components/TopScroller.tsx
new file mode 100644
index 0000000..747edae
--- /dev/null
+++ b/ui/docs/components/TopScroller.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { allDocs } from 'contentlayer/generated'
+import { useRef, useEffect } from 'react'
+
+export function TopScroller() {
+ const pathname = usePathname()
+ const ref = useRef(null)
+ const docs = allDocs.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+
+ useEffect(() => {
+ const active = ref.current?.querySelector('a.active') as HTMLElement
+ active?.scrollIntoView({ inline: 'center' })
+ }, [pathname])
+
+ return (
+
+ {docs.map((doc) => (
+
+ {doc.title}
+
+ ))}
+
+ )
+}
diff --git a/ui/docs/content/.gitkeep b/ui/docs/content/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/ui/docs/contentlayer.config.ts b/ui/docs/contentlayer.config.ts
new file mode 100644
index 0000000..f49c6b9
--- /dev/null
+++ b/ui/docs/contentlayer.config.ts
@@ -0,0 +1,33 @@
+import { defineDocumentType, makeSource } from 'contentlayer/source-files'
+import rehypeAutolink from 'rehype-autolink-headings'
+import rehypeSlug from 'rehype-slug'
+import remarkGfm from 'remark-gfm'
+
+export const Doc = defineDocumentType(() => ({
+ name: 'Doc',
+ filePathPattern: `**/*.md`,
+ contentType: 'markdown',
+ fields: {
+ title: { type: 'string', required: true },
+ order: { type: 'number', required: false },
+ },
+ computedFields: {
+ slug: {
+ type: 'string',
+ resolve: (doc) => doc._raw.flattenedPath,
+ },
+ url: {
+ type: 'string',
+ resolve: (doc) => `/${doc._raw.flattenedPath}`,
+ },
+ },
+}))
+
+export default makeSource({
+ contentDirPath: 'content',
+ documentTypes: [Doc],
+ markdown: {
+ remarkPlugins: [remarkGfm],
+ rehypePlugins: [rehypeSlug, [rehypeAutolink, { behavior: 'wrap' }]],
+ },
+})
diff --git a/ui/docs/next.config.js b/ui/docs/next.config.js
new file mode 100644
index 0000000..7f4b7d6
--- /dev/null
+++ b/ui/docs/next.config.js
@@ -0,0 +1,6 @@
+const { withContentlayer } = require('next-contentlayer')
+
+module.exports = withContentlayer({
+ output: 'export',
+ images: { unoptimized: true },
+})
diff --git a/ui/docs/nginx/docs.svc.plus.conf b/ui/docs/nginx/docs.svc.plus.conf
new file mode 100644
index 0000000..af62600
--- /dev/null
+++ b/ui/docs/nginx/docs.svc.plus.conf
@@ -0,0 +1,14 @@
+server {
+ listen 80;
+ server_name docs.svc.plus;
+ root /var/www/docs.svc.plus;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|pdf)$ {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri $uri/ =404;
+ }
+}
diff --git a/ui/docs/package.json b/ui/docs/package.json
new file mode 100644
index 0000000..e3d0466
--- /dev/null
+++ b/ui/docs/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "svc-docs",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "contentlayer build && next build",
+ "start": "next start",
+ "lint": "eslint .",
+ "test": "echo 'no tests yet'"
+ },
+ "dependencies": {
+ "@tailwindcss/typography": "^0.5.10",
+ "autoprefixer": "^10.4.16",
+ "contentlayer": "^0.3.4",
+ "next": "^14.1.0",
+ "next-contentlayer": "^0.3.4",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "rehype-autolink-headings": "^7.1.1",
+ "rehype-slug": "^6.1.0",
+ "remark-gfm": "^3.0.1",
+ "tailwindcss": "^3.4.1"
+ },
+ "devDependencies": {
+ "postcss": "^8.4.31",
+ "typescript": "^5.3.3"
+ }
+}
diff --git a/ui/docs/postcss.config.js b/ui/docs/postcss.config.js
new file mode 100644
index 0000000..33ad091
--- /dev/null
+++ b/ui/docs/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/ui/docs/scripts/import-from-chapters.js b/ui/docs/scripts/import-from-chapters.js
new file mode 100644
index 0000000..1a87b8d
--- /dev/null
+++ b/ui/docs/scripts/import-from-chapters.js
@@ -0,0 +1,27 @@
+import { execSync } from 'child_process'
+import { mkdtempSync, readdirSync, copyFileSync, existsSync, rmSync } from 'fs'
+import { join } from 'path'
+import { tmpdir } from 'os'
+
+const repo = 'https://github.com/svc-design/documents'
+const include = process.env.INCLUDE ? process.env.INCLUDE.split(',') : []
+const exclude = process.env.EXCLUDE ? process.env.EXCLUDE.split(',') : []
+
+const tmp = mkdtempSync(join(tmpdir(), 'documents-'))
+execSync(`git clone --depth 1 ${repo} ${tmp}`, { stdio: 'inherit' })
+
+const targets = readdirSync(tmp)
+for (const dir of targets) {
+ if (include.length && !include.includes(dir)) continue
+ if (exclude.includes(dir)) continue
+ const chapters = join(tmp, dir, 'CN', 'chapters')
+ if (!existsSync(chapters)) continue
+ const files = readdirSync(chapters).filter((f) => f.endsWith('.md'))
+ for (const file of files) {
+ const src = join(chapters, file)
+ const dest = join('content', dir, file)
+ execSync(`mkdir -p ${join('content', dir)}`)
+ copyFileSync(src, dest)
+ }
+}
+rmSync(tmp, { recursive: true, force: true })
diff --git a/ui/docs/styles/globals.css b/ui/docs/styles/globals.css
new file mode 100644
index 0000000..2c2035c
--- /dev/null
+++ b/ui/docs/styles/globals.css
@@ -0,0 +1,7 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ @apply bg-white text-gray-900;
+}
diff --git a/ui/docs/tailwind.config.ts b/ui/docs/tailwind.config.ts
new file mode 100644
index 0000000..93b7b65
--- /dev/null
+++ b/ui/docs/tailwind.config.ts
@@ -0,0 +1,12 @@
+import type { Config } from 'tailwindcss'
+import typography from '@tailwindcss/typography'
+
+const config: Config = {
+ content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './content/**/*.md'],
+ theme: {
+ extend: {},
+ },
+ plugins: [typography],
+}
+
+export default config
diff --git a/ui/docs/tsconfig.json b/ui/docs/tsconfig.json
new file mode 100644
index 0000000..d267e04
--- /dev/null
+++ b/ui/docs/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": false,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".contentlayer/generated"
+ ],
+ "exclude": ["node_modules"]
+}