Finalize docs static view component (#253)

This commit is contained in:
shenlan 2025-09-19 09:34:06 +08:00 committed by GitHub
parent 6e423d5f66
commit 7bd2ce088e
50 changed files with 1434 additions and 278 deletions

41
.github/workflows/static-export.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: Static Export
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui/homepage
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
cache-dependency-path: ui/homepage/yarn.lock
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run export scripts
run: yarn prebuild
- name: Build static bundle
run: yarn build:static
- name: Sync to S3
run: aws s3 sync ./out s3://$S3_BUCKET --delete
env:
S3_BUCKET: ${{ secrets.STATIC_EXPORT_BUCKET }}
- name: Invalidate CloudFront cache
run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"

View File

@ -4,17 +4,102 @@ Guide for local development setup.
## Nginx 配置
`cn-homepage.svc.plus``artifact.svc.plus` 之间部署静态站点与下载服务时,可使用如下 Nginx 配置:
Compose 环境同样支持动态代理和静态托管两种部署方式:
### 动态渲染Node.js 代理)
```nginx
server {
listen 80;
server_name www.svc.plus cn-homepage.svc.plus;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name www.svc.plus cn-homepage.svc.plus;
ssl_certificate /etc/ssl/svc.plus.pem;
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api/askai {
access_by_lua_block {
local redis = require "resty.redis"
local r = redis:new()
r:set_timeout(200)
local ok, err = r:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "Redis connect error: ", err)
return ngx.exit(500)
end
local user = ngx.var.arg_user or ngx.var.remote_addr
local today = os.date("%Y%m%d")
local key = "limit:user:" .. user .. ":" .. today
local count, err = r:incr(key)
if count == 1 then r:expire(key, 86400) end
if count > 200 then
ngx.status = 429
ngx.header["Content-Type"] = "text/plain; charset=utf-8"
ngx.say("Too Many Requests: daily limit reached")
return ngx.exit(429)
end
}
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /_next/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /favicon.ico {
proxy_pass http://127.0.0.1:3000;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ /\. {
deny all;
}
}
```
### 静态导出(无 Node 依赖)
```nginx
# 1. HTTP 自动跳转到 HTTPS
server {
listen 80;
server_name cn-homepage.svc.plus;
return 301 https://cn-homepage.svc.plus$request_uri;
}
# 2. HTTPS 静态站部署 svc.plus
server {
listen 443 ssl http2;
server_name cn-homepage.svc.plus;
@ -24,62 +109,54 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 3. 指向静态构建输出目录
root /var/www/XControl/ui/homepage/out;
index index.html;
# 4. 页面访问(含 SPA fallback
error_page 404 /404/index.html;
error_page 500 502 503 504 /500/index.html;
location / {
try_files $uri $uri/ /index.html;
}
# 5. 静态资源缓存优化
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
expires 30d;
access_log off;
add_header Cache-Control "public";
}
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
expires 30d;
access_log off;
add_header Cache-Control "public";
}
# 6. 转发后端 API
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 7. 隐藏 . 文件(如 .DS_Store
location ~ /\. {
deny all;
}
location ~ /\. {
deny all;
}
}
# 7. HTTPS 独立下载服务 artifact.svc.plus
server {
listen 443 ssl http2;
server_name artifact.svc.plus;
# SSL 配置
ssl_certificate /etc/ssl/svc.plus.pem;
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 映射统一目录
root /data/update-server;
index index.html;
# 显示目录索引,方便预览或手动下载
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
# 允许所有子路径访问(包括你预留的)
location / {
add_header Accept-Ranges bytes;
try_files $uri $uri/ =404;
}
# 静态构建产物缓存优化
location ~* \.(dmg|zip|tar\.gz|deb|rpm|exe|pkg|AppImage|apk|ipa)$ {
expires 7d;
access_log off;
@ -88,7 +165,6 @@ server {
try_files $uri =404;
}
# 隐藏 . 文件
location ~ /\. {
deny all;
}

View File

@ -4,17 +4,106 @@ How to deploy XControl via Helm.
## Nginx 配置
在使用 Helm 部署后,若需要通过 `cn-homepage.svc.plus``artifact.svc.plus` 提供静态页面与下载服务,可参考以下 Nginx 配置:
Helm 部署既可以运行 Next.js Node 进程,也可以直接托管静态导出的页面。以下分别给出两种配置示例,便于在不同集群环境复用。
### 动态渲染Node.js 代理)
如果希望在 Pod 内运行 Next.js 服务器,请将以下内容写入 `/usr/local/openresty/nginx/conf/sites-available/cn-homepage.svc.plus.conf`
```nginx
server {
listen 80;
server_name www.svc.plus cn-homepage.svc.plus;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name www.svc.plus cn-homepage.svc.plus;
ssl_certificate /etc/ssl/svc.plus.pem;
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api/askai {
access_by_lua_block {
local redis = require "resty.redis"
local r = redis:new()
r:set_timeout(200)
local ok, err = r:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "Redis connect error: ", err)
return ngx.exit(500)
end
local user = ngx.var.arg_user or ngx.var.remote_addr
local today = os.date("%Y%m%d")
local key = "limit:user:" .. user .. ":" .. today
local count, err = r:incr(key)
if count == 1 then r:expire(key, 86400) end
if count > 200 then
ngx.status = 429
ngx.header["Content-Type"] = "text/plain; charset=utf-8"
ngx.say("Too Many Requests: daily limit reached")
return ngx.exit(429)
end
}
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /_next/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /favicon.ico {
proxy_pass http://127.0.0.1:3000;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ /\. {
deny all;
}
}
```
### 静态导出(无 Node 依赖)
完成 `yarn build:static` 后,可直接托管 `ui/homepage/out` 目录:
```nginx
# 1. HTTP 自动跳转到 HTTPS
server {
listen 80;
server_name cn-homepage.svc.plus;
return 301 https://cn-homepage.svc.plus$request_uri;
}
# 2. HTTPS 静态站部署 svc.plus
server {
listen 443 ssl http2;
server_name cn-homepage.svc.plus;
@ -24,62 +113,54 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 3. 指向静态构建输出目录
root /var/www/XControl/ui/homepage/out;
index index.html;
# 4. 页面访问(含 SPA fallback
error_page 404 /404/index.html;
error_page 500 502 503 504 /500/index.html;
location / {
try_files $uri $uri/ /index.html;
}
# 5. 静态资源缓存优化
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
expires 30d;
access_log off;
add_header Cache-Control "public";
}
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
expires 30d;
access_log off;
add_header Cache-Control "public";
}
# 6. 转发后端 API
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 7. 隐藏 . 文件(如 .DS_Store
location ~ /\. {
deny all;
}
location ~ /\. {
deny all;
}
}
# 7. HTTPS 独立下载服务 artifact.svc.plus
server {
listen 443 ssl http2;
server_name artifact.svc.plus;
# SSL 配置
ssl_certificate /etc/ssl/svc.plus.pem;
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 映射统一目录
root /data/update-server;
index index.html;
# 显示目录索引,方便预览或手动下载
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
# 允许所有子路径访问(包括你预留的)
location / {
add_header Accept-Ranges bytes;
try_files $uri $uri/ =404;
}
# 静态构建产物缓存优化
location ~* \.(dmg|zip|tar\.gz|deb|rpm|exe|pkg|AppImage|apk|ipa)$ {
expires 7d;
access_log off;
@ -88,7 +169,6 @@ server {
try_files $uri =404;
}
# 隐藏 . 文件
location ~ /\. {
deny all;
}

View File

@ -13,17 +13,102 @@ helm K8s 生产部署(节点 + PG + 控制器
## Nginx 配置
以下示例展示了在 `cn-homepage.svc.plus` 上提供静态首页以及在 `artifact.svc.plus` 上提供下载服务的参考 Nginx 配置:
Systemd 部署通常只有一台主机,可按需选择动态或静态策略:
### 动态渲染Node.js 代理)
```nginx
server {
listen 80;
server_name www.svc.plus cn-homepage.svc.plus;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name www.svc.plus cn-homepage.svc.plus;
ssl_certificate /etc/ssl/svc.plus.pem;
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api/askai {
access_by_lua_block {
local redis = require "resty.redis"
local r = redis:new()
r:set_timeout(200)
local ok, err = r:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "Redis connect error: ", err)
return ngx.exit(500)
end
local user = ngx.var.arg_user or ngx.var.remote_addr
local today = os.date("%Y%m%d")
local key = "limit:user:" .. user .. ":" .. today
local count, err = r:incr(key)
if count == 1 then r:expire(key, 86400) end
if count > 200 then
ngx.status = 429
ngx.header["Content-Type"] = "text/plain; charset=utf-8"
ngx.say("Too Many Requests: daily limit reached")
return ngx.exit(429)
end
}
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /_next/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /favicon.ico {
proxy_pass http://127.0.0.1:3000;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ /\. {
deny all;
}
}
```
### 静态导出(无 Node 依赖)
```nginx
# 1. HTTP 自动跳转到 HTTPS
server {
listen 80;
server_name cn-homepage.svc.plus;
return 301 https://cn-homepage.svc.plus$request_uri;
}
# 2. HTTPS 静态站部署 svc.plus
server {
listen 443 ssl http2;
server_name cn-homepage.svc.plus;
@ -33,62 +118,54 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 3. 指向静态构建输出目录
root /var/www/XControl/ui/homepage/out;
index index.html;
# 4. 页面访问(含 SPA fallback
error_page 404 /404/index.html;
error_page 500 502 503 504 /500/index.html;
location / {
try_files $uri $uri/ /index.html;
}
# 5. 静态资源缓存优化
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
expires 30d;
access_log off;
add_header Cache-Control "public";
}
# 6. 转发后端 API
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 7. 隐藏 . 文件(如 .DS_Store
location ~ /\. {
deny all;
}
}
# 7. HTTPS 独立下载服务 artifact.svc.plus
server {
listen 443 ssl http2;
server_name artifact.svc.plus;
# SSL 配置
ssl_certificate /etc/ssl/svc.plus.pem;
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 映射统一目录
root /data/update-server;
index index.html;
# 显示目录索引,方便预览或手动下载
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
# 允许所有子路径访问(包括你预留的)
location / {
add_header Accept-Ranges bytes;
try_files $uri $uri/ =404;
}
# 静态构建产物缓存优化
location ~* \.(dmg|zip|tar\.gz|deb|rpm|exe|pkg|AppImage|apk|ipa)$ {
expires 7d;
access_log off;
@ -97,7 +174,6 @@ server {
try_files $uri =404;
}
# 隐藏 . 文件
location ~ /\. {
deny all;
}

View File

@ -0,0 +1,21 @@
# Cloud IaC 静态化设计
## 页面范围
- `/cloud_iac`
- `/cloud_iac/[provider]`
- `/cloud_iac/[provider]/[service]`
## 数据来源
- `ui/homepage/lib/iac/catalog.ts` 提供所有 Provider 与服务分类。
- `public/_build/cloud_iac_index.json` 在构建前由 `scripts/export-slugs.ts` 导出 Provider / Service 组合。
## 静态导出策略
1. 在三个页面的模块顶端声明 `export const dynamic = 'error'`,禁止运行时回退到动态渲染。
2. 在 `/cloud_iac/[provider]``/cloud_iac/[provider]/[service]` 中实现 `generateStaticParams()`,从 `cloud_iac_index.json` 读取静态参数并导出 `dynamicParams = false`
3. 使用 `cloud_iac_index.json``catalog.ts` 的静态常量渲染页面内容,避免请求数据库或外部 API。
4. 将任何 `generateMetadata` 的逻辑提前到模块作用域,生成一次性常量 `metadata`
## 子任务拆分
- **数据导出**:在 `scripts/export-slugs.ts` 中读取 `catalog.ts`,生成 Provider 和 Service 对应的 JSON 列表。
- **页面改造**:为三个页面补充 `dynamic = 'error'` 与静态参数生成逻辑,更新到使用 JSON 数据。
- **校验**:在 `scripts/check-build.js` 中增加对 `cloud_iac_index.json` 的完整性检查,确保 Provider 与 Service 数量均大于 0。

View File

@ -0,0 +1,17 @@
# Demo 页面静态化设计
## 页面范围
- `/demo`
## 数据来源
- `app/demo/feature.config.ts` 控制功能开关。
- `public/_build/cloud_iac_index.json` 不直接使用,但构建流程需要确保检查通过。
## 静态导出策略
1. 在 `app/demo/page.tsx` 开头声明 `export const dynamic = 'error'`,确保页面只能在构建期生成。
2. 页面仍通过 `feature.config` 判断开关,关闭时在构建期直接产出 404。
3. 保持现有 `DemoContent` 客户端组件逻辑不变。
## 子任务拆分
- **页面改造**:添加 `dynamic = 'error'` 常量。
- **构建校验**:在 `scripts/check-build.js` 中确认 Demo 所需的构建产物存在,避免缺失时继续构建。

View File

@ -0,0 +1,22 @@
# Docs 页面静态化设计
## 页面范围
- `/docs`
- `/docs/[name]`
## 数据来源
- Markdown 内容位于 `ui/homepage/content/`,由 `scripts/scan-md.ts` 解析生成 `public/_build/docs_index.json`
- 下载路径映射由 `public/_build/docs_paths.json` 提供。
## 静态导出策略
1. 将 `app/docs/page.tsx``app/docs/[name]/page.tsx` 顶部声明 `export const dynamic = 'error'`
2. 使用 `generateStaticParams()``docs_index.json` 读取所有 `slug`,并导出 `dynamicParams = false`
3. 在 `app/docs/resources.ts` 中移除运行时 `fetch`,改为读取静态 JSON 常量,确保构建期即可拿到完整数据。
4. 将原有 `generateMetadata` 动态逻辑转换为静态 `metadata` 常量。
5. 所有时间展示改用 `app/components/ClientTime.tsx`,并在父级标记 `suppressHydrationWarning`
## 子任务拆分
- **数据脚本**:编写 `scripts/scan-md.ts` 解析 Markdown 并输出 `docs_index.json`、更新 `docs_paths.json`
- **页面更新**:改造两个页面以消费静态 JSON同时添加静态参数逻辑。
- **组件调整**:把 `formatDate` 等服务端时间处理迁移至客户端组件。
- **构建校验**:在 `scripts/check-build.js` 中验证 `docs_index.json` 至少包含一个条目,且字段完整。

View File

@ -0,0 +1,21 @@
# Download 页面静态化设计
## 页面范围
- `/download`
- `/download/[...segments]`
## 数据来源
- `public/dl-index/all.json``manifest.json` 描述下载目录。
- `public/_build/docs_paths.json`(由 `scripts/export-slugs.ts` 写入)提供所有需要静态生成的路径。
## 静态导出策略
1. 在两个页面模块顶端添加 `export const dynamic = 'error'`,禁止运行时回退。
2. `/download/[...segments]` 使用 `generateStaticParams()``docs_paths.json` 读取路径列表并拆分为数组,导出 `dynamicParams = false`
3. 在服务端组件内不直接调用 `new Date()`,改用字符串比较或 `Date.parse`;时间展示统一下沉到 `ClientTime` 客户端组件。
4. 404 分支保持不变,依旧在构建期根据静态数据输出。
## 子任务拆分
- **数据脚本**:在 `scripts/export-slugs.ts` 中收集下载目录路径并输出 `docs_paths.json`
- **页面改造**:更新 `generateStaticParams()`、添加 `dynamicParams = false` 和时间处理逻辑。
- **组件调整**:在需要展示更新时间的位置引入 `ClientTime` 并加上 `suppressHydrationWarning`
- **校验**`scripts/check-build.js` 校验 `docs_paths.json` 非空且至少包含一级路径。

View File

@ -0,0 +1,16 @@
# Login 页面静态化设计
## 页面范围
- `/login`
## 数据来源
- `app/login/feature.config.ts` 控制是否启用登录重定向。
## 静态导出策略
1. 在页面顶部加入 `export const dynamic = 'error'`,强制构建期生成。
2. 保持 `redirect('/panel/ldp')` 行为,构建时生成对 `/panel/ldp` 的静态跳转。
3. 若功能开关关闭,构建时产出 404。
## 子任务拆分
- **页面改造**:添加 `dynamic = 'error'`
- **校验**:在 `scripts/check-build.js` 中验证登录功能开关开启时需要的静态页面全部存在,确保部署时不会缺失。

View File

@ -0,0 +1,22 @@
# Panel 区域静态化设计
## 页面范围
- `/panel`
- `/panel/account`
- `/panel/agent`
- `/panel/xray`
- `/panel/subscription`
- `/panel/api`
- `/panel/ldp`
## 数据来源
- 静态展示内容,依赖组件位于 `app/panel/components/`
## 静态导出策略
1. 在所有页面文件开头增加 `export const dynamic = 'error'`,替换现有 `force-static` 配置。
2. 所有文案均为静态字符串,无需额外数据源。
3. 保留现有的卡片组件布局,确保与动态版本一致。
## 子任务拆分
- **页面改造**:为 7 个页面文件添加/替换 `dynamic = 'error'`
- **脚本校验**`scripts/check-build.js` 校验输出目录中存在 `/panel` 相关静态文件。

View File

@ -0,0 +1,15 @@
# Register 页面静态化设计
## 页面范围
- `/register`
## 数据来源
- `app/register/feature.config.ts` 用于控制是否开放注册入口。
## 静态导出策略
1. 在页面顶部添加 `export const dynamic = 'error'`
2. 页面内容为静态卡片,保持 JSX 不变;关闭开关时在构建期直接产出 404。
## 子任务拆分
- **页面改造**:新增 `dynamic = 'error'`
- **校验**`scripts/check-build.js` 检测注册页面在开关开启时是否生成。

54
scripts/check-build.js Normal file
View File

@ -0,0 +1,54 @@
const fs = require('fs')
const path = require('path')
const HOMEPAGE_ROOT = path.resolve(__dirname, '..', 'ui', 'homepage')
function readJson(relativePath) {
const fullPath = path.join(HOMEPAGE_ROOT, 'public', '_build', relativePath)
if (!fs.existsSync(fullPath)) {
throw new Error(`Missing build artifact: ${relativePath}`)
}
const raw = fs.readFileSync(fullPath, 'utf8')
return JSON.parse(raw)
}
function ensureArray(value, name) {
if (!Array.isArray(value)) {
throw new Error(`${name} must be an array`)
}
return value
}
function main() {
const docsIndex = ensureArray(readJson('docs_index.json'), 'docs_index.json')
if (docsIndex.length === 0) {
throw new Error('docs_index.json is empty')
}
if (docsIndex.some((doc) => typeof doc.slug !== 'string' || !doc.slug)) {
throw new Error('docs_index.json contains entries without slug')
}
const cloudIac = readJson('cloud_iac_index.json')
const providers = ensureArray(cloudIac.providers, 'cloud_iac_index.providers')
const services = ensureArray(cloudIac.services, 'cloud_iac_index.services')
if (providers.length === 0) {
throw new Error('cloud_iac_index.providers is empty')
}
if (services.length === 0) {
throw new Error('cloud_iac_index.services is empty')
}
const docsPaths = ensureArray(readJson('docs_paths.json'), 'docs_paths.json')
if (docsPaths.length === 0) {
throw new Error('docs_paths.json is empty')
}
console.log('[check-build] All build artifacts look good.')
}
try {
main()
} catch (error) {
console.error('[check-build] validation failed:', error.message)
process.exit(1)
}

92
scripts/export-slugs.ts Normal file
View File

@ -0,0 +1,92 @@
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
import { CATALOG, PROVIDERS } from '../ui/homepage/lib/iac/catalog'
import type { ProviderKey } from '../ui/homepage/lib/iac/types'
import type { DirListing } from '../ui/homepage/types/download'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(__dirname, '..')
const HOMEPAGE_ROOT = path.join(PROJECT_ROOT, 'ui', 'homepage')
const OUTPUT_DIR = path.join(HOMEPAGE_ROOT, 'public', '_build')
function unique<T>(items: Iterable<T>): T[] {
return Array.from(new Set(items))
}
function buildCloudIacIndex() {
const providers = PROVIDERS.map((provider) => ({ key: provider.key, label: provider.label }))
const services: { provider: ProviderKey; service: string; category: string }[] = []
for (const category of CATALOG) {
if (!category.iac) continue
for (const [provider, integration] of Object.entries(category.iac)) {
if (!integration || typeof integration.detailSlug !== 'string') continue
services.push({
provider: provider as ProviderKey,
service: integration.detailSlug,
category: category.key,
})
}
}
services.sort((a, b) => {
if (a.provider === b.provider) return a.service.localeCompare(b.service)
return a.provider.localeCompare(b.provider)
})
return { providers, services }
}
async function loadDownloadManifest(): Promise<DirListing[]> {
const manifestPath = path.join(HOMEPAGE_ROOT, 'public', 'dl-index', 'all.json')
try {
const raw = await fs.readFile(manifestPath, 'utf8')
const parsed = JSON.parse(raw) as DirListing[]
if (Array.isArray(parsed)) {
return parsed
}
return []
} catch (error) {
console.warn('[export-slugs] Unable to read download manifest:', error)
return []
}
}
function extractDownloadPaths(listings: DirListing[]): string[] {
const paths: string[] = []
for (const listing of listings) {
if (!listing || typeof listing.path !== 'string') continue
const trimmed = listing.path.replace(/\/+$/g, '')
if (trimmed.length > 0) {
paths.push(trimmed)
}
}
return unique(paths).sort()
}
async function main() {
await fs.mkdir(OUTPUT_DIR, { recursive: true })
const cloudIacIndex = buildCloudIacIndex()
const downloadListings = await loadDownloadManifest()
const downloadPaths = extractDownloadPaths(downloadListings)
await fs.writeFile(
path.join(OUTPUT_DIR, 'cloud_iac_index.json'),
JSON.stringify(cloudIacIndex, null, 2),
'utf8',
)
await fs.writeFile(
path.join(OUTPUT_DIR, 'docs_paths.json'),
JSON.stringify(downloadPaths, null, 2),
'utf8',
)
}
main().catch((error) => {
console.error('[export-slugs] failed:', error)
process.exit(1)
})

View File

@ -39,6 +39,5 @@ async function main() {
}
main().catch(err => {
console.error(err)
process.exit(1)
console.warn('[fetch-dl-index] skipped due to error:', err)
})

82
scripts/scan-md.ts Normal file
View File

@ -0,0 +1,82 @@
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(__dirname, '..')
const HOMEPAGE_ROOT = path.join(PROJECT_ROOT, 'ui', 'homepage')
const CONTENT_DIR = path.join(HOMEPAGE_ROOT, 'content')
const OUTPUT_PATH = path.join(HOMEPAGE_ROOT, 'public', '_build', 'docs_index.json')
type DocEntry = {
slug: string
title: string
description: string
updatedAt: string
pathSegments: string[]
}
function extractTitle(lines: string[], fallback: string): string {
const heading = lines.find((line) => /^#\s+/.test(line))
if (!heading) return fallback
return heading.replace(/^#\s+/, '').trim() || fallback
}
function extractDescription(lines: string[], title: string): string {
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || /^#\s+/.test(trimmed)) {
continue
}
return trimmed
}
return `${title}.`
}
async function readMarkdownFile(file: string): Promise<DocEntry | null> {
if (!file.endsWith('.md')) return null
const slug = file.replace(/\.md$/, '')
const filePath = path.join(CONTENT_DIR, file)
const [source, stats] = await Promise.all([fs.readFile(filePath, 'utf8'), fs.stat(filePath)])
const lines = source.split(/\r?\n/)
const title = extractTitle(lines, slug)
const description = extractDescription(lines, title)
return {
slug,
title,
description,
updatedAt: stats.mtime.toISOString(),
pathSegments: slug.split('/').filter(Boolean),
}
}
async function collectDocs(): Promise<DocEntry[]> {
try {
const files = await fs.readdir(CONTENT_DIR)
const entries: DocEntry[] = []
for (const file of files) {
const entry = await readMarkdownFile(file)
if (entry) {
entries.push(entry)
}
}
return entries.sort((a, b) => a.slug.localeCompare(b.slug))
} catch (error) {
console.warn('[scan-md] Unable to scan markdown directory:', error)
return []
}
}
async function main() {
await fs.mkdir(path.dirname(OUTPUT_PATH), { recursive: true })
const docs = await collectDocs()
await fs.writeFile(OUTPUT_PATH, JSON.stringify(docs, null, 2), 'utf8')
}
main().catch((error) => {
console.error('[scan-md] failed:', error)
process.exit(1)
})

1
ui/homepage/.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

View File

@ -0,0 +1,22 @@
export const dynamic = 'error'
import Link from 'next/link'
export default function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 py-24 text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-purple-600">404</p>
<h1 className="mt-4 text-4xl font-bold text-gray-900">Page not found</h1>
<p className="mt-3 max-w-md text-sm text-gray-600">
The page you were looking for could not be generated during the static export. Please return to the homepage and try a
different link.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center rounded-full bg-purple-600 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700"
>
Back to homepage
</Link>
</main>
)
}

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
@ -6,33 +8,34 @@ import { CATALOG, PROVIDERS } from '@lib/iac/catalog'
import type { CatalogItem, ProviderKey } from '@lib/iac/types'
import feature from '../../feature.config'
import cloudIacIndex from '../../../../public/_build/cloud_iac_index.json'
type PageParams = {
provider: string
service: string
}
type CloudIacIndex = {
providers: { key: ProviderKey; label: string }[]
services: { provider: ProviderKey; service: string }[]
}
const CLOUD_IAC_INDEX = cloudIacIndex as CloudIacIndex
const PROVIDER_MAP = new Map(PROVIDERS.map((provider) => [provider.key, provider.label] as const))
function findCategoryBySlug(provider: ProviderKey, slug: string): CatalogItem | undefined {
return CATALOG.find((item) => item.iac?.[provider]?.detailSlug === slug)
}
export function generateMetadata({ params }: { params: PageParams }): Metadata {
const providerKey = params.provider as ProviderKey
const providerLabel = PROVIDER_MAP.get(providerKey)
const category = providerLabel ? findCategoryBySlug(providerKey, params.service) : undefined
if (!providerLabel || !category) {
return {
title: 'Cloud IaC Catalog',
}
}
export function generateStaticParams() {
return CLOUD_IAC_INDEX.services.map((item) => ({ provider: item.provider, service: item.service }))
}
const productName = category.products[providerKey] ?? category.title
return {
title: `${providerLabel} · ${productName} · Cloud IaC`,
description: `${providerLabel}${productName} IaC 详情页,提供 GitOps 配置、资源预览与成本估算。`,
}
export const dynamicParams = false
export const metadata: Metadata = {
title: 'Cloud IaC Catalog',
}
export default function CloudIacServicePage({ params }: { params: PageParams }) {

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
@ -6,26 +8,28 @@ import { CATALOG, PROVIDERS } from '@lib/iac/catalog'
import type { ProviderKey } from '@lib/iac/types'
import feature from '../feature.config'
import cloudIacIndex from '../../../public/_build/cloud_iac_index.json'
type PageParams = {
provider: string
}
type CloudIacIndex = {
providers: { key: ProviderKey; label: string }[]
}
const CLOUD_IAC_INDEX = cloudIacIndex as CloudIacIndex
const PROVIDER_MAP = new Map(PROVIDERS.map((provider) => [provider.key, provider.label] as const))
export function generateMetadata({ params }: { params: PageParams }): Metadata {
const providerKey = params.provider as ProviderKey
const providerLabel = PROVIDER_MAP.get(providerKey)
if (!providerLabel) {
return {
title: 'Cloud IaC Catalog',
}
}
export function generateStaticParams() {
return CLOUD_IAC_INDEX.providers.map((provider) => ({ provider: provider.key }))
}
return {
title: `${providerLabel} · Cloud IaC Catalog`,
description: `${providerLabel} 核心云服务的 IaC 编排目录,涵盖计算、网络、存储、数据库等常用能力。`,
}
export const dynamicParams = false
export const metadata: Metadata = {
title: 'Cloud IaC Catalog',
}
export default function CloudIacProviderPage({ params }: { params: PageParams }) {

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'

View File

@ -0,0 +1,38 @@
'use client'
import { useMemo } from 'react'
type ClientTimeProps = {
isoString: string
locale?: string
options?: Intl.DateTimeFormatOptions
fallback?: string
}
function formatTimestamp(
isoString: string,
locale?: string,
options?: Intl.DateTimeFormatOptions,
fallback?: string,
): { display: string; dateTime: string } {
if (!isoString) {
return { display: fallback ?? '--', dateTime: '' }
}
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) {
return { display: fallback ?? isoString, dateTime: isoString }
}
const formatter = new Intl.DateTimeFormat(locale ?? undefined, options)
return { display: formatter.format(date), dateTime: date.toISOString() }
}
export default function ClientTime({ isoString, locale, options, fallback }: ClientTimeProps) {
const { display, dateTime } = useMemo(
() => formatTimestamp(isoString, locale, options, fallback),
[isoString, locale, options, fallback],
)
return <time dateTime={dateTime || undefined}>{display}</time>
}

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import { notFound } from 'next/navigation'
import feature from './feature.config'

View File

@ -0,0 +1,107 @@
'use client'
import { useMemo, useState } from 'react'
import { ExternalLink, FileText, Monitor } from 'lucide-react'
export type ViewMode = 'pdf' | 'html'
export interface DocViewOption {
id: ViewMode
label: string
description: string
url: string
icon: ViewMode
}
const ICON_MAP: Record<ViewMode, typeof FileText> = {
pdf: FileText,
html: Monitor,
}
interface DocViewSectionProps {
docTitle: string
options: DocViewOption[]
}
export default function DocViewSection({ docTitle, options }: DocViewSectionProps) {
const [activeId, setActiveId] = useState<ViewMode | undefined>(options[0]?.id)
const activeView = useMemo(() => {
if (!options.length) return undefined
return options.find((option) => option.id === activeId) ?? options[0]
}, [options, activeId])
const ActiveIcon = activeView ? ICON_MAP[activeView.icon] : undefined
return (
<section className="overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="p-6">
{options.length > 0 && (
<div className="mt-0 grid gap-3 sm:grid-cols-2">
{options.map((view) => {
const isActive = activeView?.id === view.id
const Icon = ICON_MAP[view.icon]
const optionClassName = [
'flex items-start gap-3 rounded-2xl border p-4 text-left transition hover:border-purple-400 hover:shadow',
isActive ? 'border-purple-500 bg-purple-50/60 shadow' : 'border-gray-200',
].join(' ')
return (
<button
key={view.id}
type="button"
onClick={() => setActiveId(view.id)}
className={optionClassName}
>
<span
className={`flex h-8 w-8 items-center justify-center rounded-full ${
isActive ? 'bg-purple-600 text-white' : 'bg-purple-100 text-purple-600'
}`}
>
<Icon className="h-4 w-4" />
</span>
<span className="space-y-1">
<span className="flex items-center gap-2 text-sm font-semibold text-gray-900">
{view.label}
{isActive && <span className="text-xs font-medium text-purple-600">Selected</span>}
</span>
<span className="text-xs text-gray-600">{view.description}</span>
</span>
</button>
)
})}
</div>
)}
{activeView && (
<div className="mt-6 flex flex-wrap items-center gap-3 text-sm">
<span className="inline-flex items-center gap-2 rounded-full bg-purple-100 px-4 py-2 font-medium text-purple-700">
{ActiveIcon && <ActiveIcon className="h-4 w-4 text-purple-600" />}
<span className="text-purple-700">{activeView.label} view selected</span>
</span>
<a
href={activeView.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:border-purple-400 hover:text-purple-600"
>
<ExternalLink className="h-4 w-4" /> Open in new tab
</a>
</div>
)}
</div>
{activeView ? (
<iframe
key={activeView.id}
src={activeView.url}
className="h-[80vh] w-full"
title={`${docTitle} (${activeView.label})`}
/>
) : (
<div className="p-10 text-center text-gray-500">
This resource does not provide an embeddable format yet. Use the download buttons above instead.
</div>
)}
</section>
)
}

View File

@ -1,29 +1,15 @@
import Link from 'next/link'
export const dynamic = 'error'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { ExternalLink, FileText, Monitor, type LucideIcon } from 'lucide-react'
import Breadcrumbs, { Crumb } from '../../../components/download/Breadcrumbs'
import { formatDate } from '../../../lib/format'
import { getDocResource, getDocResources } from '../resources'
import { getDocResource } from '../resources'
import feature from '../feature.config'
type ViewMode = 'pdf' | 'html'
interface ViewOption {
id: ViewMode
label: string
description: string
url: string
icon: LucideIcon
}
function normalizeViewParam(viewParam: string | string[] | undefined): ViewMode | undefined {
if (!viewParam) return undefined
const value = Array.isArray(viewParam) ? viewParam[0] : viewParam
if (value === 'pdf' || value === 'html') return value
return undefined
}
import ClientTime from '../../components/ClientTime'
import docsIndex from '../../../public/_build/docs_index.json'
import type { DocResource } from '../resources'
import DocViewSection, { type DocViewOption } from './DocViewSection'
function buildBreadcrumbs(slug: string, docTitle: string): Crumb[] {
return [
@ -32,36 +18,26 @@ function buildBreadcrumbs(slug: string, docTitle: string): Crumb[] {
]
}
export async function generateStaticParams() {
const DOCS_INDEX = docsIndex as DocResource[]
export const generateStaticParams = () => {
if (!feature.enabled) {
return []
}
const docs = await getDocResources()
return docs.map((doc) => ({ name: doc.slug }))
return DOCS_INDEX.map((doc) => ({ name: doc.slug }))
}
export async function generateMetadata({ params }: { params: { name: string } }): Promise<Metadata> {
if (!feature.enabled) {
return { title: 'Documentation' }
}
export const dynamicParams = false
const doc = await getDocResource(params.name)
if (!doc) {
return { title: 'Documentation' }
}
return {
title: `${doc.title} | Documentation`,
description: doc.description,
}
export const metadata: Metadata = {
title: 'Documentation',
}
export default async function DocPage({
params,
searchParams,
}: {
params: { name: string }
searchParams?: { view?: string | string[] }
}) {
if (!feature.enabled) {
notFound()
@ -72,14 +48,14 @@ export default async function DocPage({
notFound()
}
const viewOptions: ViewOption[] = []
const viewOptions: DocViewOption[] = []
if (doc.pdfUrl) {
viewOptions.push({
id: 'pdf',
label: 'PDF',
description: 'Best for printing and full fidelity diagrams.',
url: doc.pdfUrl,
icon: FileText,
icon: 'pdf',
})
}
if (doc.htmlUrl) {
@ -88,15 +64,11 @@ export default async function DocPage({
label: 'HTML',
description: 'Responsive reader mode optimised for browsers.',
url: doc.htmlUrl,
icon: Monitor,
icon: 'html',
})
}
const normalizedView = normalizeViewParam(searchParams?.view)
const activeView = viewOptions.find((view) => view.id === normalizedView) ?? viewOptions[0]
const breadcrumbs = buildBreadcrumbs(doc.slug, doc.title)
const ActiveIcon = activeView?.icon
return (
<main className="px-4 py-8 md:px-8">
@ -129,78 +101,18 @@ export default async function DocPage({
)}
</div>
<div className="flex flex-col items-start gap-2 text-sm text-gray-500 md:items-end">
{doc.updatedAt && <span>Updated {formatDate(doc.updatedAt)}</span>}
{doc.updatedAt && (
<span suppressHydrationWarning>
Updated <ClientTime isoString={doc.updatedAt} />
</span>
)}
{doc.estimatedMinutes && <span>Approx. {doc.estimatedMinutes} minute read</span>}
</div>
</div>
{viewOptions.length > 0 && (
<div className="mt-6 grid gap-3 sm:grid-cols-2">
{viewOptions.map((view) => {
const isActive = activeView?.id === view.id
const Icon = view.icon
return (
<Link
key={view.id}
href={`/docs/${doc.slug}?view=${view.id}`}
className={`flex items-start gap-3 rounded-2xl border p-4 transition hover:border-purple-400 hover:shadow ${
isActive ? 'border-purple-500 bg-purple-50/60 shadow' : 'border-gray-200'
}`}
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${
isActive ? 'bg-purple-600 text-white' : 'bg-purple-100 text-purple-600'
}`}
>
<Icon className="h-4 w-4" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
{view.label}
{isActive && <span className="text-xs font-medium text-purple-600">Selected</span>}
</div>
<p className="text-xs text-gray-600">{view.description}</p>
</div>
</Link>
)
})}
</div>
)}
{activeView && (
<div className="mt-6 flex flex-wrap items-center gap-3 text-sm">
{ActiveIcon && (
<span className="inline-flex items-center gap-2 rounded-full bg-purple-100 px-4 py-2 font-medium text-purple-700">
<ActiveIcon className="h-4 w-4 text-purple-600" />
{activeView.label} view selected
</span>
)}
<a
href={activeView.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:border-purple-400 hover:text-purple-600"
>
<ExternalLink className="h-4 w-4" /> Open in new tab
</a>
</div>
)}
</section>
<section className="overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm">
{activeView ? (
<iframe
key={activeView.id}
src={activeView.url}
className="h-[80vh] w-full"
title={`${doc.title} (${activeView.label})`}
/>
) : (
<div className="p-10 text-center text-gray-500">
This resource does not provide an embeddable format yet. Use the download buttons above instead.
</div>
)}
</section>
<DocViewSection docTitle={doc.title} options={viewOptions} />
</div>
</main>
)

View File

@ -5,7 +5,7 @@ const feature = createFeatureFlag({
title: 'Documentation Library',
description: 'Knowledge base resources sourced from dl.svc.plus',
envVar: 'NEXT_PUBLIC_FEATURE_DOCS',
defaultEnabled: false,
defaultEnabled: true,
})
export default feature

View File

@ -1,10 +1,12 @@
export const dynamic = 'error'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ArrowUpRight } from 'lucide-react'
import { formatDate } from '../../lib/format'
import { getDocResources } from './resources'
import feature from './feature.config'
import ClientTime from '../components/ClientTime'
function formatMeta({
category,
@ -29,8 +31,8 @@ export default async function DocsHome() {
const manifest = await getDocResources()
const resources = [...manifest].sort((a, b) => {
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
const aTime = a.updatedAt ? Date.parse(a.updatedAt) : 0
const bTime = b.updatedAt ? Date.parse(b.updatedAt) : 0
return bTime - aTime
})
@ -71,7 +73,11 @@ export default async function DocsHome() {
)}
</div>
<div className="flex items-center justify-between text-xs text-purple-500">
{resource.updatedAt && <span>Updated {formatDate(resource.updatedAt)}</span>}
{resource.updatedAt && (
<span suppressHydrationWarning>
Updated <ClientTime isoString={resource.updatedAt} />
</span>
)}
{resource.estimatedMinutes && <span>{resource.estimatedMinutes} min read</span>}
</div>
</div>

View File

@ -1,8 +1,7 @@
import 'server-only'
import { cache } from 'react'
import feature from './feature.config'
import docsIndex from '../../public/_build/docs_index.json'
export interface DocResource {
slug: string
@ -38,38 +37,11 @@ interface RawDocResource {
pathSegments?: unknown
}
const FALLBACK_DOCS: DocResource[] = []
const RAW_DOCS = Array.isArray(docsIndex) ? (docsIndex as RawDocResource[]) : []
const DOCS_MANIFEST_URL = process.env.DOCS_MANIFEST_URL || 'https://dl.svc.plus/docs/all.json'
const loadManifest = cache(async (): Promise<DocResource[]> => {
try {
const response = await fetch(DOCS_MANIFEST_URL, {
cache: 'force-cache',
headers: {
accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch docs manifest: ${response.status} ${response.statusText}`)
}
const payload = (await response.json()) as unknown
if (!Array.isArray(payload)) {
throw new Error('Docs manifest payload must be an array')
}
const resources = payload
.map((item) => normalizeResource(item as RawDocResource))
.filter((item): item is DocResource => item !== null)
return resources
} catch (error) {
console.error('[docs] Unable to load manifest, using fallback dataset.', error)
return FALLBACK_DOCS
}
})
const DOCS_DATASET = RAW_DOCS.map((item) => normalizeResource(item as RawDocResource)).filter(
(item): item is DocResource => item !== null,
)
function normalizeResource(item: RawDocResource): DocResource | null {
if (!item || typeof item !== 'object') {
@ -156,7 +128,7 @@ export async function getDocResources(): Promise<DocResource[]> {
return []
}
return loadManifest()
return DOCS_DATASET
}
export async function getDocResource(slug: string): Promise<DocResource | undefined> {
@ -164,6 +136,5 @@ export async function getDocResource(slug: string): Promise<DocResource | undefi
return undefined
}
const resources = await loadManifest()
return resources.find((doc) => doc.slug === slug)
return DOCS_DATASET.find((doc) => doc.slug === slug)
}

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import DownloadListingContent from '../../../components/download/DownloadListingContent'
import DownloadNotFound from '../../../components/download/DownloadNotFound'
import {
@ -8,13 +10,15 @@ import {
} from '../../../lib/download-data'
import { getDownloadListings } from '../../../lib/download-manifest'
import type { DirListing } from '../../../types/download'
import docsPaths from '../../../public/_build/docs_paths.json'
const allListings = getDownloadListings()
const DOWNLOAD_PATHS = (docsPaths as string[]).filter((path) => typeof path === 'string')
export async function generateStaticParams() {
return allListings
.filter((listing) => listing.path)
.map((listing) => ({ segments: listing.path.split('/').filter(Boolean) }))
export function generateStaticParams() {
return DOWNLOAD_PATHS.filter((path) => path.trim().length > 0).map((path) => ({
segments: path.split('/').filter(Boolean),
}))
}
export const dynamicParams = false
@ -22,7 +26,7 @@ export const dynamicParams = false
function getLatestModified(listing: DirListing): string | undefined {
let latest: string | undefined
for (const entry of listing.entries) {
if (entry.lastModified && (!latest || new Date(entry.lastModified).getTime() > new Date(latest).getTime())) {
if (entry.lastModified && (!latest || entry.lastModified > latest)) {
latest = entry.lastModified
}
}

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import DownloadBrowser from '../../components/download/DownloadBrowser'
import DownloadSummary from '../../components/download/DownloadSummary'
import { buildDownloadSections, countFiles, findListing } from '../../lib/download-data'

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import { notFound, redirect } from 'next/navigation'
import feature from './feature.config'

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import Hero from '@components/Hero'
import Features from '@components/Features'
import OpenSource from '@components/OpenSource'

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import Card from '../components/Card'
export default function AccountPage() {

View File

@ -1,6 +1,6 @@
import Card from '../components/Card'
export const dynamic = 'error'
export const dynamic = 'force-static'
import Card from '../components/Card'
export default function AgentPage() {
return (

View File

@ -1,6 +1,6 @@
import Card from '../components/Card'
export const dynamic = 'error'
export const dynamic = 'force-static'
import Card from '../components/Card'
export default function APIPage() {
return (

View File

@ -1,5 +1,7 @@
import Link from 'next/link'
export const dynamic = 'error'
import Card from '../components/Card'
export default function LdpPage() {

View File

@ -1,6 +1,6 @@
import Card from './components/Card'
export const dynamic = 'error'
export const dynamic = 'force-static'
import Card from './components/Card'
export default function PanelHome() {
return (

View File

@ -1,6 +1,6 @@
import Card from '../components/Card'
export const dynamic = 'error'
export const dynamic = 'force-static'
import Card from '../components/Card'
export default function SubscriptionPage() {
return (

View File

@ -1,6 +1,6 @@
import Card from '../components/Card'
export const dynamic = 'error'
export const dynamic = 'force-static'
import Card from '../components/Card'
export default function XRayPage() {
return (

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import { notFound, redirect } from 'next/navigation'
import feature from './feature.config'

View File

@ -1,4 +1,5 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
import demoFeature from '../app/demo/feature.config'
import docsFeature from '../app/docs/feature.config'
@ -117,7 +118,14 @@ export default function Navbar() {
<nav className="fixed top-0 w-full z-50 bg-white/70 backdrop-blur border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<a href="#" className="text-xl font-bold text-gray-900 flex items-center gap-2">
<img src="/icons/cloudnative_32.png" alt="logo" className="w-6 h-6" />
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
CloudNative Suite
</a>

View File

@ -8,7 +8,7 @@ import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { formatDate } from '@lib/format'
import { formatSegmentLabel, type DownloadSection } from '@lib/download-data'
import type { DirListing } from '@types/download'
import type { DirListing } from '../../types/download'
type DownloadListingContentProps = {
segments: string[]

View File

@ -35,7 +35,7 @@ type PreviewBuilder = (context: Context) => PreviewItem[]
type CostBuilder = (context: Context) => CostItem[]
type OutputBuilder = (context: Context) => OutputItem[]
const SPEC_PRESETS: Record<CategoryKey, SpecBuilder> = {
const SPEC_PRESETS: Partial<Record<CategoryKey, SpecBuilder>> = {
compute: () => [
{ label: '实例规格', defaultValue: 't3.medium', description: '默认双核 4GB适合通用业务与测试环境。' },
{ label: '节点数量', defaultValue: '2', description: '可按需调整 Auto Scaling 期望容量。' },
@ -93,7 +93,7 @@ const SPEC_PRESETS: Record<CategoryKey, SpecBuilder> = {
],
}
const RESOURCE_PRESETS: Record<CategoryKey, PreviewBuilder> = {
const RESOURCE_PRESETS: Partial<Record<CategoryKey, PreviewBuilder>> = {
compute: ({ productName }) => [
{ title: 'Auto Scaling Group', description: '跨 2 个可用区部署,关联弹性伸缩策略与健康检查。' },
{ title: productName, description: '默认创建 2 台计算节点,挂载业务安全组与监控代理。' },
@ -151,7 +151,7 @@ const RESOURCE_PRESETS: Record<CategoryKey, PreviewBuilder> = {
],
}
const COST_PRESETS: Record<CategoryKey, CostBuilder> = {
const COST_PRESETS: Partial<Record<CategoryKey, CostBuilder>> = {
compute: () => [
{ title: '计算实例', amount: '~$120', unit: '每月', description: '2 台按量付费 t3.medium 实例(含折扣)。' },
{ title: '块存储', amount: '~$18', unit: '每月', description: '100 GiB 通用型 SSD 存储与快照。' },
@ -209,7 +209,7 @@ const COST_PRESETS: Record<CategoryKey, CostBuilder> = {
],
}
const OUTPUT_PRESETS: Record<CategoryKey, OutputBuilder> = {
const OUTPUT_PRESETS: Partial<Record<CategoryKey, OutputBuilder>> = {
compute: () => [
{ title: '实例私网 IP', value: '10.0.1.12 / 10.0.2.15', description: '可用于上游注册与服务发现。' },
{ title: '弹性伸缩组 ARN', value: 'arn:aws:autoscaling:…:group/app-asg', description: '便于监控、告警或后续自动化引用。' },
@ -267,18 +267,54 @@ const OUTPUT_PRESETS: Record<CategoryKey, OutputBuilder> = {
],
}
const DEFAULT_SPEC_BUILDER: SpecBuilder = () => [
{
label: '自定义参数',
defaultValue: '按服务需求配置',
description: '该服务类别暂未提供默认模板,请根据实际情况补充配置。',
},
]
const DEFAULT_PREVIEW_BUILDER: PreviewBuilder = () => [
{
title: '服务概览',
description: '此类别尚未定义示例资源,请结合产品文档规划部署。',
},
]
const DEFAULT_COST_BUILDER: CostBuilder = () => [
{
title: '预估成本',
amount: '按使用计费',
unit: 'USD/月',
description: '根据配置选择与使用时长,参考各云厂商最新报价。',
},
]
const DEFAULT_OUTPUT_BUILDER: OutputBuilder = () => [
{
title: '部署结果',
value: '待填充',
description: '可在 GitOps 或 IaC 管道中补充具体输出变量。',
},
]
export function getSpecRows(context: Context): SpecRow[] {
return SPEC_PRESETS[context.category](context)
const builder = SPEC_PRESETS[context.category] ?? DEFAULT_SPEC_BUILDER
return builder(context)
}
export function getResourcePreview(context: Context): PreviewItem[] {
return RESOURCE_PRESETS[context.category](context)
const builder = RESOURCE_PRESETS[context.category] ?? DEFAULT_PREVIEW_BUILDER
return builder(context)
}
export function getCostPreview(context: Context): CostItem[] {
return COST_PRESETS[context.category](context)
const builder = COST_PRESETS[context.category] ?? DEFAULT_COST_BUILDER
return builder(context)
}
export function getOutputPreview(context: Context): OutputItem[] {
return OUTPUT_PRESETS[context.category](context)
const builder = OUTPUT_PRESETS[context.category] ?? DEFAULT_OUTPUT_BUILDER
return builder(context)
}

View File

@ -10,6 +10,10 @@ export type CategoryKey =
| 'data_service'
| 'security'
| 'iam'
| 'dns_cdn'
| 'edge_iot'
| 'observability'
| 'api_integration'
export type ProviderKey = 'aws' | 'gcp' | 'azure' | 'aliyun'

View File

@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // 生成最小可运行产物(适合 1c1G
output: 'export', // 直接生成静态文件,便于部署到 S3 / Nginx
trailingSlash: true,
reactStrictMode: true,
compress: false, // 压缩交给 Nginx省 Node CPU
images: { unoptimized: true },// 关闭服务端图片处理

View File

@ -4,8 +4,9 @@
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "tsx ../../scripts/fetch-dl-index.ts",
"prebuild": "tsx ../../scripts/export-slugs.ts && tsx ../../scripts/scan-md.ts && tsx ../../scripts/fetch-dl-index.ts",
"build": "next build",
"build:static": "yarn prebuild && node ../../scripts/check-build.js && next build",
"export": "next build",
"start": "next start",
"lint": "next lint"

19
ui/homepage/pages/500.tsx Normal file
View File

@ -0,0 +1,19 @@
import Link from 'next/link'
export default function ErrorPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 py-24 text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-purple-600">500</p>
<h1 className="mt-4 text-4xl font-bold text-gray-900">Something went wrong</h1>
<p className="mt-3 max-w-md text-sm text-gray-600">
An unexpected error occurred while preparing this static page. The incident has been logged and will be investigated.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center rounded-full bg-purple-600 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700"
>
Back to homepage
</Link>
</main>
)
}

View File

@ -0,0 +1,322 @@
{
"providers": [
{
"key": "aws",
"label": "AWS"
},
{
"key": "gcp",
"label": "GCP"
},
{
"key": "azure",
"label": "Azure"
},
{
"key": "aliyun",
"label": "阿里云"
}
],
"services": [
{
"provider": "aliyun",
"service": "ack",
"category": "container"
},
{
"provider": "aliyun",
"service": "aliyun-emr",
"category": "data_service"
},
{
"provider": "aliyun",
"service": "aliyun-iot",
"category": "edge_iot"
},
{
"provider": "aliyun",
"service": "apigateway-dataworks",
"category": "api_integration"
},
{
"provider": "aliyun",
"service": "apsaradb-rds",
"category": "database"
},
{
"provider": "aliyun",
"service": "apsaradb-redis",
"category": "cache"
},
{
"provider": "aliyun",
"service": "cloudmonitor-eventbridge",
"category": "observability"
},
{
"provider": "aliyun",
"service": "dns-acceleration",
"category": "dns_cdn"
},
{
"provider": "aliyun",
"service": "ecs",
"category": "compute"
},
{
"provider": "aliyun",
"service": "mns",
"category": "queue"
},
{
"provider": "aliyun",
"service": "oss",
"category": "storage"
},
{
"provider": "aliyun",
"service": "ram",
"category": "iam"
},
{
"provider": "aliyun",
"service": "security-group",
"category": "security"
},
{
"provider": "aliyun",
"service": "slb",
"category": "load_balancer"
},
{
"provider": "aliyun",
"service": "vpc",
"category": "network"
},
{
"provider": "aws",
"service": "alb",
"category": "load_balancer"
},
{
"provider": "aws",
"service": "api-gateway-appflow",
"category": "api_integration"
},
{
"provider": "aws",
"service": "aws-iot",
"category": "edge_iot"
},
{
"provider": "aws",
"service": "cloudwatch-eventbridge",
"category": "observability"
},
{
"provider": "aws",
"service": "ec2",
"category": "compute"
},
{
"provider": "aws",
"service": "eks",
"category": "container"
},
{
"provider": "aws",
"service": "elasticache",
"category": "cache"
},
{
"provider": "aws",
"service": "emr",
"category": "data_service"
},
{
"provider": "aws",
"service": "iam",
"category": "iam"
},
{
"provider": "aws",
"service": "rds",
"category": "database"
},
{
"provider": "aws",
"service": "route53-cloudfront",
"category": "dns_cdn"
},
{
"provider": "aws",
"service": "s3",
"category": "storage"
},
{
"provider": "aws",
"service": "security-group",
"category": "security"
},
{
"provider": "aws",
"service": "sqs",
"category": "queue"
},
{
"provider": "aws",
"service": "vpc",
"category": "network"
},
{
"provider": "azure",
"service": "aks",
"category": "container"
},
{
"provider": "azure",
"service": "apim-data-factory",
"category": "api_integration"
},
{
"provider": "azure",
"service": "azure-ad",
"category": "iam"
},
{
"provider": "azure",
"service": "azure-database",
"category": "database"
},
{
"provider": "azure",
"service": "azure-dns-front-door",
"category": "dns_cdn"
},
{
"provider": "azure",
"service": "azure-iot",
"category": "edge_iot"
},
{
"provider": "azure",
"service": "azure-load-balancer",
"category": "load_balancer"
},
{
"provider": "azure",
"service": "blob-storage",
"category": "storage"
},
{
"provider": "azure",
"service": "monitor-event-grid",
"category": "observability"
},
{
"provider": "azure",
"service": "network-security-group",
"category": "security"
},
{
"provider": "azure",
"service": "redis-cache",
"category": "cache"
},
{
"provider": "azure",
"service": "service-bus",
"category": "queue"
},
{
"provider": "azure",
"service": "synapse",
"category": "data_service"
},
{
"provider": "azure",
"service": "virtual-machines",
"category": "compute"
},
{
"provider": "azure",
"service": "virtual-network",
"category": "network"
},
{
"provider": "gcp",
"service": "apigateway-integration",
"category": "api_integration"
},
{
"provider": "gcp",
"service": "cloud-dns-cdn",
"category": "dns_cdn"
},
{
"provider": "gcp",
"service": "cloud-iam",
"category": "iam"
},
{
"provider": "gcp",
"service": "cloud-load-balancing",
"category": "load_balancer"
},
{
"provider": "gcp",
"service": "cloud-sql",
"category": "database"
},
{
"provider": "gcp",
"service": "cloud-storage",
"category": "storage"
},
{
"provider": "gcp",
"service": "compute-engine",
"category": "compute"
},
{
"provider": "gcp",
"service": "dataproc",
"category": "data_service"
},
{
"provider": "gcp",
"service": "gcp-iot",
"category": "edge_iot"
},
{
"provider": "gcp",
"service": "gke",
"category": "container"
},
{
"provider": "gcp",
"service": "memorystore",
"category": "cache"
},
{
"provider": "gcp",
"service": "operations-eventarc",
"category": "observability"
},
{
"provider": "gcp",
"service": "pubsub",
"category": "queue"
},
{
"provider": "gcp",
"service": "vpc",
"category": "network"
},
{
"provider": "gcp",
"service": "vpc-firewall",
"category": "security"
}
]
}

View File

@ -0,0 +1,11 @@
[
{
"slug": "getting-started",
"title": "Getting Started",
"description": "Welcome to the docs! This is a placeholder document.",
"updatedAt": "2025-09-19T00:53:13.962Z",
"pathSegments": [
"getting-started"
]
}
]

View File

@ -0,0 +1,35 @@
[
"deb",
"docs",
"offline-package",
"offline-package/apisix-gateway",
"offline-package/k3s",
"offline-package/kong-gateway",
"offline-package/nginx-ingress",
"offline-package/sealos",
"otel",
"otel/OpenTelemetry",
"otel/OpenTelemetry/v0.133.0",
"rpm",
"xray-core",
"xray-core/v25.8.29",
"xray-core/v25.8.3",
"xray-core/v25.8.31",
"xstream",
"xstream/android",
"xstream/android/latest",
"xstream/ios",
"xstream/ios/latest",
"xstream/linux",
"xstream/linux/latest",
"xstream/linux/stable",
"xstream/macos",
"xstream/macos/docs",
"xstream/macos/latest",
"xstream/macos/stable",
"xstream/windows",
"xstream/windows/latest",
"xstream/windows/stable",
"xstream/windows/win10",
"xstream/windows/win11"
]