Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88b300f0b |
@@ -22,18 +22,3 @@ coverage
|
|||||||
|
|
||||||
# Local authoring assets are uploaded via APIs, never application build inputs.
|
# Local authoring assets are uploaded via APIs, never application build inputs.
|
||||||
/content
|
/content
|
||||||
|
|
||||||
# Apply local metadata, secrets and generated-output exclusions at every depth.
|
|
||||||
**/.git
|
|
||||||
**/.worktrees
|
|
||||||
**/.pnpm-store
|
|
||||||
**/.turbo
|
|
||||||
**/.nx
|
|
||||||
**/coverage
|
|
||||||
**/artifacts
|
|
||||||
**/.pytest_cache
|
|
||||||
**/*.egg-info
|
|
||||||
**/*.tsbuildinfo
|
|
||||||
**/.env
|
|
||||||
**/.env.*
|
|
||||||
!**/.env.example
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
name: Unified application CI and deployment
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'apps/waterfall-wiki/**'
|
|
||||||
- 'packages/brand/**'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
- 'compose.yml'
|
|
||||||
- 'pnpm-workspace.yaml'
|
|
||||||
- '.gitea/workflows/app.yml'
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
paths:
|
|
||||||
- 'apps/waterfall-wiki/**'
|
|
||||||
- 'packages/brand/**'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
- 'compose.yml'
|
|
||||||
- 'pnpm-workspace.yaml'
|
|
||||||
- '.gitea/workflows/app.yml'
|
|
||||||
concurrency:
|
|
||||||
group: unified-app-${{ gitea.event.pull_request.number || gitea.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
jobs:
|
|
||||||
static-checks:
|
|
||||||
runs-on: ces-control
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Check patch, syntax and source contracts without building
|
|
||||||
run: |
|
|
||||||
git diff --check HEAD^ HEAD
|
|
||||||
for script in scripts/*.sh scripts/preview/*.sh; do bash -n "$script"; done
|
|
||||||
node scripts/check-studio-contracts.mjs
|
|
||||||
node --check scripts/preview/fingerprint.mjs
|
|
||||||
node --check scripts/verify-studio-visual.cjs
|
|
||||||
node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs
|
|
||||||
node --check apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs
|
|
||||||
python3 -c 'import ast,pathlib; ast.parse(pathlib.Path("scripts/wiki-image-lifecycle.py").read_text())'
|
|
||||||
verify-and-release:
|
|
||||||
needs: static-checks
|
|
||||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
|
||||||
runs-on: ces-control
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Build and verify the unified application once
|
|
||||||
run: |
|
|
||||||
docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
|
|
||||||
docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
|
|
||||||
- name: Verify candidate using persistent test infrastructure
|
|
||||||
env:
|
|
||||||
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
|
|
||||||
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
|
|
||||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
|
||||||
run: bash scripts/integration.sh
|
|
||||||
- name: Deploy unified Web and worker; verify production
|
|
||||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
|
||||||
run: bash scripts/deploy.sh "${{ gitea.sha }}"
|
|
||||||
- name: Save production acceptance evidence
|
|
||||||
id: evidence
|
|
||||||
if: always() && gitea.event_name == 'push'
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: unified-production-acceptance
|
|
||||||
path: artifacts/studio-acceptance/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
- name: Remove uploaded local evidence
|
|
||||||
if: always() && steps.evidence.outcome == 'success'
|
|
||||||
run: rm -rf artifacts/studio-acceptance
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
name: Remote development preview source sync
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['**']
|
|
||||||
paths:
|
|
||||||
- 'apps/waterfall-wiki/**'
|
|
||||||
- 'packages/brand/**'
|
|
||||||
- 'pnpm-workspace.yaml'
|
|
||||||
- 'scripts/preview/**'
|
|
||||||
- '.gitea/workflows/preview.yml'
|
|
||||||
concurrency:
|
|
||||||
group: development-preview
|
|
||||||
cancel-in-progress: false
|
|
||||||
jobs:
|
|
||||||
sync-source:
|
|
||||||
# One preview slot follows one branch. Setup changes use an explicit refresh.
|
|
||||||
if: gitea.ref == format('refs/heads/{0}', vars.PREVIEW_BRANCH || 'codex/unified-web')
|
|
||||||
runs-on: ces-control
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
- name: Initialize once, otherwise synchronize files only
|
|
||||||
env:
|
|
||||||
SOURCE_SHA: ${{ gitea.sha }}
|
|
||||||
SOURCE_BRANCH: ${{ gitea.ref_name }}
|
|
||||||
run: |
|
|
||||||
if [ ! -f /opt/prompt-preview/ready ]; then
|
|
||||||
bash scripts/preview/init.sh "$PWD" waterfall-wiki-tests:6654a1456bdcef77d83ca84373dc36188e15bd12
|
|
||||||
fi
|
|
||||||
bash scripts/preview/sync.sh "$PWD" "$SOURCE_SHA" "$SOURCE_BRANCH"
|
|
||||||
@@ -1,36 +1,20 @@
|
|||||||
name: python CI and deployment
|
name: python CI and deployment
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- '.dockerignore'
|
|
||||||
- 'apps/python/**'
|
- 'apps/python/**'
|
||||||
- 'scripts/deploy-python.sh'
|
- 'scripts/deploy-python.sh'
|
||||||
- '.gitea/workflows/python.yml'
|
- '.gitea/workflows/python.yml'
|
||||||
push:
|
pull_request:
|
||||||
branches: ["**"]
|
|
||||||
paths:
|
paths:
|
||||||
- '.dockerignore'
|
|
||||||
- 'apps/python/**'
|
- 'apps/python/**'
|
||||||
- 'scripts/deploy-python.sh'
|
- 'scripts/deploy-python.sh'
|
||||||
- '.gitea/workflows/python.yml'
|
- '.gitea/workflows/python.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
static-checks:
|
|
||||||
runs-on: ces-control
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Check Python and deployment syntax without executing tests
|
|
||||||
run: |
|
|
||||||
git diff --check HEAD^ HEAD
|
|
||||||
bash -n scripts/deploy-python.sh
|
|
||||||
python3 -c 'import ast,pathlib; [ast.parse(p.read_text(),filename=str(p)) for p in pathlib.Path("apps/python").rglob("*.py")]'
|
|
||||||
verify-and-deploy:
|
verify-and-deploy:
|
||||||
needs: static-checks
|
|
||||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
|
||||||
runs-on: ces-control
|
runs-on: ces-control
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
@@ -40,5 +24,5 @@ jobs:
|
|||||||
- name: Build and verify python
|
- name: Build and verify python
|
||||||
run: docker build -f apps/python/Dockerfile -t "prompt-python:${{ gitea.sha }}" .
|
run: docker build -f apps/python/Dockerfile -t "prompt-python:${{ gitea.sha }}" .
|
||||||
- name: Release python
|
- name: Release python
|
||||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||||
run: bash scripts/deploy-python.sh "${{ gitea.sha }}"
|
run: bash scripts/deploy-python.sh "${{ gitea.sha }}"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: studio CI and deployment
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'apps/studio/**'
|
||||||
|
- 'scripts/deploy-studio.sh'
|
||||||
|
- 'scripts/verify-studio-production.sh'
|
||||||
|
- 'scripts/verify-studio-library.mjs'
|
||||||
|
- 'scripts/verify-studio-visual.cjs'
|
||||||
|
- '.gitea/workflows/studio.yml'
|
||||||
|
- 'scripts/*studio*'
|
||||||
|
- 'content/studio-examples/**'
|
||||||
|
- 'content/studio-tools/layouts-api.md'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'apps/studio/**'
|
||||||
|
- 'scripts/deploy-studio.sh'
|
||||||
|
- 'scripts/verify-studio-production.sh'
|
||||||
|
- 'scripts/verify-studio-library.mjs'
|
||||||
|
- 'scripts/verify-studio-visual.cjs'
|
||||||
|
- '.gitea/workflows/studio.yml'
|
||||||
|
- 'scripts/*studio*'
|
||||||
|
- 'content/studio-examples/**'
|
||||||
|
- 'content/studio-tools/layouts-api.md'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify-and-deploy:
|
||||||
|
runs-on: ces-control
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Check shared content and documentation contracts
|
||||||
|
run: |
|
||||||
|
node scripts/check-studio-contracts.mjs
|
||||||
|
node --check scripts/export-studio-series.cjs
|
||||||
|
node --check scripts/save-studio-series.mjs
|
||||||
|
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
|
||||||
|
- name: Build and verify studio
|
||||||
|
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
|
||||||
|
- name: Release studio
|
||||||
|
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||||
|
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
|
||||||
|
- name: Verify the deployed Studio API and native pages
|
||||||
|
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||||
|
run: bash scripts/verify-studio-production.sh
|
||||||
|
- name: Save server acceptance evidence
|
||||||
|
id: acceptance_evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: studio-production-acceptance
|
||||||
|
path: artifacts/studio-acceptance/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
- name: Remove temporary acceptance output after upload
|
||||||
|
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||||
|
run: rm -rf artifacts/studio-acceptance
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: waterfall-wiki CI and deployment
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'apps/waterfall-wiki/**'
|
||||||
|
- '.gitea/workflows/waterfall-wiki.yml'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'compose.yml'
|
||||||
|
- 'scripts/deploy.sh'
|
||||||
|
- 'scripts/infra.sh'
|
||||||
|
- 'scripts/auth-admin.sh'
|
||||||
|
- 'scripts/integration.sh'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'apps/waterfall-wiki/**'
|
||||||
|
- '.gitea/workflows/waterfall-wiki.yml'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'compose.yml'
|
||||||
|
- 'scripts/deploy.sh'
|
||||||
|
- 'scripts/infra.sh'
|
||||||
|
- 'scripts/auth-admin.sh'
|
||||||
|
- 'scripts/integration.sh'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify-and-deploy:
|
||||||
|
runs-on: ces-control
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Validate configuration
|
||||||
|
run: docker compose --env-file apps/waterfall-wiki/.env.example config --quiet
|
||||||
|
- name: Build and verify waterfall-wiki
|
||||||
|
run: docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
|
||||||
|
- name: Verify retained character originals
|
||||||
|
run: docker run --rm --entrypoint node "waterfall-wiki-tests:${{ gitea.sha }}" --test tests/character-pack.test.mjs
|
||||||
|
- name: Build runtime
|
||||||
|
run: docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
|
||||||
|
- name: Integration stack
|
||||||
|
env:
|
||||||
|
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
|
||||||
|
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
|
||||||
|
run: bash scripts/integration.sh
|
||||||
|
- name: Deploy waterfall-wiki
|
||||||
|
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||||
|
run: bash scripts/deploy.sh "${{ gitea.sha }}"
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
name: Wiki image retention checks
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'scripts/wiki-image-lifecycle.py'
|
|
||||||
- 'tests/test_wiki_image_lifecycle.py'
|
|
||||||
- '.gitea/workflows/wiki-image-retention-checks.yml'
|
|
||||||
jobs:
|
|
||||||
retention-policy:
|
|
||||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
|
||||||
runs-on: ces-control
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Check retention without building or accessing the Docker daemon
|
|
||||||
run: |
|
|
||||||
bash -n scripts/deploy.sh
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
|
|
||||||
@@ -10,21 +10,3 @@ dist/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.venv/
|
.venv/
|
||||||
|
|
||||||
# Content images belong in S3; metadata lives in managed-media.json.
|
|
||||||
*.png
|
|
||||||
*.jpg
|
|
||||||
*.jpeg
|
|
||||||
*.webp
|
|
||||||
*.gif
|
|
||||||
*.avif
|
|
||||||
|
|
||||||
# Approved, versioned brand resources are package source assets.
|
|
||||||
!packages/brand/assets/**/*.png
|
|
||||||
!packages/brand/docs/logo-preview.png
|
|
||||||
/apps/studio/public/brand/
|
|
||||||
/apps/studio/public/favicon.ico
|
|
||||||
/apps/studio/public/apple-touch-icon.png
|
|
||||||
/apps/waterfall-wiki/public/brand/
|
|
||||||
/apps/waterfall-wiki/public/favicon.ico
|
|
||||||
/apps/waterfall-wiki/public/apple-touch-icon.png
|
|
||||||
|
|||||||
@@ -14,10 +14,3 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
|||||||
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
|
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
|
||||||
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
|
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
|
||||||
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
|
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
|
||||||
|
|
||||||
## 啡跃 Endoleap 品牌与主题
|
|
||||||
|
|
||||||
- 共享品牌包位于 [`packages/brand`](packages/brand/README.md)。修改品牌色、主题或标志前,阅读包内的 [品牌边界](packages/brand/docs/overview.md)、[配色与交互规范](packages/brand/docs/colors.md)、[Tailwind / shadcn 接入](packages/brand/docs/integration.md) 和 [Logo 规则](packages/brand/docs/logo.md)。不在仓库根 docs 重复维护一套品牌规范。
|
|
||||||
- 四套正式主题为 home-light、home-dark、canvas-light、canvas-dark;不创建 drafts,不恢复 proposed 命名。画布 Light / Dark 背景保持中性近白 / 近黑,用户素材与作品不整体染色。
|
|
||||||
- 基础色只在 tokens/palette.json 定义,主题引用色板键;Logo 轮廓只在 tokens/geometry.json 定义。保持 CSS、类型声明、资产与清单一致,不通过图片模型重画已固定的图形。
|
|
||||||
- 本地仅运行不生成产物的轻量读取检查。品牌导出、Tailwind/TypeScript 编译和 SVG 栅格化检查必须通过已确认的 ces-control Docker CI 执行,不在本机启动预览或构建。
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
FROM node:24-bookworm-slim AS base
|
FROM node:24-bookworm-slim AS base
|
||||||
LABEL io.entroia.project="waterfall-wiki"
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -7,40 +6,32 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM base AS dependencies
|
FROM base AS dependencies
|
||||||
WORKDIR /repo/apps/waterfall-wiki
|
|
||||||
LABEL io.entroia.wiki.build="true"
|
|
||||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||||
# Download cache depends on lockfile and workspace policy, not package scripts.
|
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||||
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
|
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||||
COPY packages/brand/package.json /repo/packages/brand/package.json
|
|
||||||
COPY apps/waterfall-wiki/package.json ./
|
COPY apps/waterfall-wiki/package.json ./
|
||||||
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
|
RUN pnpm install --offline --frozen-lockfile
|
||||||
|
|
||||||
FROM dependencies AS build
|
FROM dependencies AS build
|
||||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||||
# Brand source changes invalidate compilation, not dependency installation.
|
|
||||||
COPY packages/brand/ /repo/packages/brand/
|
|
||||||
COPY apps/waterfall-wiki/ .
|
COPY apps/waterfall-wiki/ .
|
||||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||||
|
|
||||||
FROM build AS integration
|
FROM build AS integration
|
||||||
ENV VERIFY_CANVAS_UI=1
|
|
||||||
USER node
|
USER node
|
||||||
CMD ["npm", "run", "test:integration"]
|
CMD ["npm", "run", "test:integration"]
|
||||||
|
|
||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
WORKDIR /app/apps/waterfall-wiki
|
|
||||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||||
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
||||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
|
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
|
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
|
||||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
|
COPY --from=build --chown=node:node /app/db/ ./db/
|
||||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
||||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||||
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
|
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源
|
|||||||
| 路径 | 职责 |
|
| 路径 | 职责 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
|
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
|
||||||
| `apps/waterfall-wiki/src/studio` | 合并后的创作工具、排版渲染与编辑器模块 |
|
| `apps/studio` | 独立 Next.js 创作工具与封面渲染 API |
|
||||||
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
|
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
|
||||||
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
|
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
|
||||||
| `scripts` | CI 部署、运维、API 发布与作者工具 |
|
| `scripts` | CI 部署、运维、API 发布与作者工具 |
|
||||||
@@ -71,8 +71,8 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm --dir apps/waterfall-wiki typecheck
|
pnpm --dir apps/waterfall-wiki typecheck
|
||||||
pnpm --dir apps/waterfall-wiki test
|
pnpm --dir apps/waterfall-wiki test
|
||||||
node scripts/check-studio-contracts.mjs
|
pnpm --dir apps/studio typecheck
|
||||||
for script in scripts/*.sh; do bash -n "$script"; done
|
bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
|
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
|
||||||
@@ -81,14 +81,14 @@ for script in scripts/*.sh; do bash -n "$script"; done
|
|||||||
|
|
||||||
## 部署与持久化
|
## 部署与持久化
|
||||||
|
|
||||||
Web 应用由 `.gitea/workflows/app.yml` 统一验证与发布。Wiki 和 Studio 已合并成一个 Next.js 应用,一次编译、一个 Web 运行镜像,Web 与后台 worker 使用同一镜像。PostgreSQL/S3 基础服务长期运行,不在日常发布中重建。工作分支 push/PR 仅做轻量静态检查;只有 main push 才构建、运行测试并更新生产。完整步骤见 [统一部署流程](docs/unified-deployment.md)。
|
Gitea 工作流按应用路径触发:`waterfall-wiki.yml`、`studio.yml` 和 `python.yml`。waterfall-wiki 完成类型检查、单元测试、生产构建、冒烟和真实 PostgreSQL/S3 集成测试后部署;Studio 独立构建和部署。文档与作者数据本身不触发应用部署。
|
||||||
|
|
||||||
| 服务 | 入口/端口 |
|
| 服务 | 入口/端口 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
|
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
|
||||||
| PostgreSQL | `127.0.0.1:18132` |
|
| PostgreSQL | `127.0.0.1:18132` |
|
||||||
| 对象存储 | `127.0.0.1:18133` |
|
| 对象存储 | `127.0.0.1:18133` |
|
||||||
| 编辑器 | 与知识站共享 `18131`;原生路由 `/graphic`、`/infinite_canvas`,历史 `/studio` 页面与 API 保留兼容 |
|
| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 |
|
||||||
| Python | 无网络端口,按需运行 |
|
| Python | 无网络端口,按需运行 |
|
||||||
|
|
||||||
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
|
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
FROM python:3.12-slim AS base
|
FROM python:3.12-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM base AS dependencies
|
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN python -m venv /opt/venv
|
|
||||||
COPY apps/python/pyproject.toml ./
|
|
||||||
# Read the pinned requirements from the package metadata before copying source.
|
|
||||||
RUN python -c 'import subprocess, tomllib; p = tomllib.load(open("pyproject.toml", "rb")); subprocess.check_call(["python", "-m", "pip", "install", "--no-cache-dir", *p["build-system"]["requires"], *p["project"]["dependencies"]])'
|
|
||||||
|
|
||||||
FROM dependencies AS build
|
|
||||||
COPY apps/python/ .
|
COPY apps/python/ .
|
||||||
RUN pip install --no-cache-dir --no-deps --no-build-isolation . && python -m unittest discover -s tests -v
|
RUN pip install --no-cache-dir . && python -m unittest discover -s tests -v
|
||||||
|
|
||||||
FROM base AS runtime
|
|
||||||
WORKDIR /app
|
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
|
||||||
COPY --from=build /opt/venv /opt/venv
|
|
||||||
RUN useradd --create-home exporter
|
RUN useradd --create-home exporter
|
||||||
USER exporter
|
USER exporter
|
||||||
ENTRYPOINT ["prompt-export"]
|
ENTRYPOINT ["prompt-export"]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:24-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||||
|
COPY apps/studio/pnpm-lock.yaml ./
|
||||||
|
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||||
|
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||||
|
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||||
|
COPY apps/studio/package.json ./
|
||||||
|
RUN pnpm install --offline --frozen-lockfile
|
||||||
|
COPY apps/studio/ .
|
||||||
|
# Compare the two public parameter validators inside the isolated CI build.
|
||||||
|
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts
|
||||||
|
COPY apps/waterfall-wiki/src/lib/series.ts /waterfall-wiki/src/lib/series.ts
|
||||||
|
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||||
|
FROM node:24-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
|
||||||
|
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||||
|
COPY --from=build --chown=node:node /app/.next/static/ ./.next/static/
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
const config: NextConfig = { basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false };
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@prompt/studio",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3001",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "next typegen && tsc --noEmit",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"test:smoke": "node scripts/smoke.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.4.2",
|
||||||
|
"react": "19.3.1",
|
||||||
|
"react-dom": "19.3.1",
|
||||||
|
"lucide-react": "1.61.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"@types/react": "19.3.0",
|
||||||
|
"@types/react-dom": "19.3.0",
|
||||||
|
"tailwindcss": "4.3.3",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@11.25.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { "@tailwindcss/postcss": {} },
|
||||||
|
};
|
||||||
@@ -6,13 +6,13 @@ const standardId='00000000-0000-4000-8000-000000000003';
|
|||||||
const seriesId='00000000-0000-4000-8000-000000000004';
|
const seriesId='00000000-0000-4000-8000-000000000004';
|
||||||
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
|
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
|
||||||
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
|
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
|
||||||
let reads=0,mediaReads=0;
|
let reads=0;
|
||||||
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
||||||
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
|
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
|
||||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/apps/waterfall-wiki/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',AUTH_DISABLED_FOR_TESTS:'true',AUTH_CONFIG_FILE:'',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
||||||
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/workspace');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const library=await fetch(base+'/studio').then(r=>r.text());assert.ok(library.includes('standard-01'));assert.ok(library.includes('ref-27'));const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
||||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||||
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
|
assert.equal(reads,0,'Default pages never load a saved design');
|
||||||
const headers={cookie:'session=smoke-test'};
|
const headers={cookie:'session=smoke-test'};
|
||||||
for(const id of [designId,standardId]){
|
for(const id of [designId,standardId]){
|
||||||
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
|
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {referencePreview} from '../lib/layout-images-client';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import {SourceCover,selections,type CoverValues} from './SourceCover';
|
||||||
|
import {tutorialPost} from '../content/tool-links';
|
||||||
|
export function LayoutSelection(){
|
||||||
|
const [active,setActive]=useState(0),[values,setValues]=useState<CoverValues[]>(selections.map(l=>({fields:Object.fromEntries(Object.entries(l.fields).filter(([,v])=>v!==undefined)) as Record<string,string>,color:'#83918a',accent:'#c3cec5'}))),[guides,setGuides]=useState(false),[status,setStatus]=useState('');
|
||||||
|
const layout=selections[active],current=values[active];
|
||||||
|
function update(patch:Partial<CoverValues>){setValues(old=>old.map((v,i)=>i===active?{...v,...patch}:v));}
|
||||||
|
async function download(format:'svg'|'json'){
|
||||||
|
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
|
||||||
|
}
|
||||||
|
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { ThemeToggle } from "./ThemeToggle";
|
|
||||||
import { Brand } from "./Brand";
|
|
||||||
import {CurvedCopy} from './NativeCurvedCopy';
|
import {CurvedCopy} from './NativeCurvedCopy';
|
||||||
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
|
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
|
||||||
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react';
|
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react';
|
||||||
@@ -57,7 +55,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
|
|||||||
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
|
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
|
||||||
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
|
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
|
||||||
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
|
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
|
||||||
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><a href="/workspace" className="studio-mark"><Brand compact/></a><ThemeToggle/><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
|
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><span className="studio-mark">P<span>STUDIO</span></span><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
|
||||||
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
|
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
|
||||||
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
|
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
|
||||||
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}
|
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}
|
||||||
@@ -12,8 +12,8 @@ export function TemplateTrial({idea}:{idea:Idea}){
|
|||||||
const download=(text:string,type:string,name:string)=>{const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),500)};
|
const download=(text:string,type:string,name:string)=>{const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),500)};
|
||||||
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
|
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
|
||||||
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
|
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
|
||||||
return <main className="template-trial"><Head><title>{idea.title} · 啡跃 Endoleap</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
||||||
<header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
<header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||||
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
|
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
|
||||||
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
|
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
|
||||||
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
|
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type {NextApiRequest} from 'next';
|
||||||
|
import type {Parameters} from './layout-parameters';
|
||||||
|
/** Only fetch bounded previews from the authenticated Wiki service, never caller URLs. */
|
||||||
|
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
|
||||||
|
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
|
||||||
|
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||||
|
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
|
||||||
|
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
|
||||||
|
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number};
|
||||||
|
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number};
|
||||||
|
export type DecorationValue={color?:string;hidden?:boolean};
|
||||||
|
export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean};
|
||||||
|
export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'};
|
||||||
|
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string;layers?:Record<string,LayerValue>;added?:AddedSlot[]};
|
||||||
|
export class LayoutParameterError extends Error{}
|
||||||
|
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
|
||||||
|
function fail(message:string):never{throw new LayoutParameterError(message)}
|
||||||
|
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
|
||||||
|
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
|
||||||
|
function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail(`数值范围 ${min}–${max}`);return v}
|
||||||
|
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
|
||||||
|
export function parameters(layout:Layout,input:unknown):Parameters{
|
||||||
|
const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}};
|
||||||
|
const added:AddedSlot[]=[];
|
||||||
|
if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added}
|
||||||
|
const elements=[...layout.elements,...added];
|
||||||
|
if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record<string,number|boolean>={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record<string,number[]>={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}}
|
||||||
|
if(raw.paper!==undefined)result.paper=color(raw.paper);
|
||||||
|
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
|
||||||
|
const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id));
|
||||||
|
for(const [id,value] of Object.entries(entries)){const v=object(value);
|
||||||
|
if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t;
|
||||||
|
}else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image;
|
||||||
|
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters';
|
||||||
|
export type Typography=Required<Pick<TextValue,'fontFamily'|'fontSize'|'fontWeight'|'lineHeight'|'letterSpacing'>>;
|
||||||
|
export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record<string,'title'|'body'|'caption'>;parameters:Parameters}[]};
|
||||||
|
function fail(message:string):never{throw new LayoutParameterError(message)}
|
||||||
|
const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record<string,unknown>};
|
||||||
|
function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o}
|
||||||
|
function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string}
|
||||||
|
function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail('作品数值超出范围');return v as number}
|
||||||
|
/** Closed, bounded content document. The same validator runs in persistence and SSR. */
|
||||||
|
export function series(input:unknown,layouts:Layout[]):Series{
|
||||||
|
const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本');
|
||||||
|
const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000);
|
||||||
|
const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80);
|
||||||
|
const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB');
|
||||||
|
const typography=exact(style.typography,['title','body','caption']);
|
||||||
|
for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)}
|
||||||
|
const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大');
|
||||||
|
if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条');
|
||||||
|
const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]});
|
||||||
|
if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页');
|
||||||
|
const seen=new Set<string>();
|
||||||
|
const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key);
|
||||||
|
const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')}
|
||||||
|
// Every visible text is authored; template placeholder copy must never leak into a finished series.
|
||||||
|
for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素');
|
||||||
|
for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程');
|
||||||
|
return {...p,parameters:params,bindings} as Series['pages'][number]});
|
||||||
|
return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages};
|
||||||
|
}
|
||||||
|
/** Style units use a 120-wide reference canvas; explicit element values always win. */
|
||||||
|
export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{
|
||||||
|
const p=page.parameters,s=doc.style,k=layout.width/120;
|
||||||
|
const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}};
|
||||||
|
for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}}
|
||||||
|
for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}}
|
||||||
|
if(layout.id==='standard-01'){
|
||||||
|
const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m;
|
||||||
|
const boxes:Record<string,number[]>={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]};
|
||||||
|
for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { AppProps } from "next/app";
|
||||||
|
import "../styles/globals.css";
|
||||||
|
export default function App({Component,pageProps}:AppProps){return <Component {...pageProps}/>;}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
|
|
||||||
import {renderToStaticMarkup} from 'react-dom/server';
|
import {renderToStaticMarkup} from 'react-dom/server';
|
||||||
import {serveApiDocumentation} from '../../../../studio/lib/api-documentation';
|
import {serveApiDocumentation} from '../../../lib/api-documentation';
|
||||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||||
import {ideas,ideaMarkdown} from '../../../../studio/content/ideas';
|
import {ideas,ideaMarkdown} from '../../../content/ideas';
|
||||||
import {selections} from '../../../../studio/components/SourceCover';
|
import {selections} from '../../../components/SourceCover';
|
||||||
import {renderCover} from '../../../../studio/lib/render-cover';
|
import {renderCover} from '../../../lib/render-cover';
|
||||||
import {tutorialPost} from '../../../../studio/content/tool-links';
|
import {tutorialPost} from '../../../content/tool-links';
|
||||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||||
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
|
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
|
||||||
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
|
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
|
||||||
const slug=String(req.query.slug||''),idea=ideas.find(i=>i.slug===slug);if(!idea)return res.status(404).end();
|
const slug=String(req.query.slug||''),idea=ideas.find(i=>i.slug===slug);if(!idea)return res.status(404).end();
|
||||||
@@ -18,7 +17,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
|||||||
const result=renderCover(slug,input);res.setHeader('Cache-Control','no-store');
|
const result=renderCover(slug,input);res.setHeader('Cache-Control','no-store');
|
||||||
res.setHeader('Content-Disposition',`attachment; filename="${slug}.${req.query.format==='json'?'json':'svg'}"`);
|
res.setHeader('Content-Disposition',`attachment; filename="${slug}.${req.query.format==='json'?'json':'svg'}"`);
|
||||||
if(req.query.format==='json')return res.json(result.data);
|
if(req.query.format==='json')return res.json(result.data);
|
||||||
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(result.node)));
|
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(result.node));
|
||||||
}catch(error){return res.status(400).json({error:error instanceof Error?error.message:'无效参数'});}
|
}catch(error){return res.status(400).json({error:error instanceof Error?error.message:'无效参数'});}
|
||||||
}
|
}
|
||||||
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea)+'\n\n使用教程:'+tutorialPost+(idea.tool?'\n\n```json\n'+JSON.stringify(selections,null,2)+'\n```':''));}
|
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea)+'\n\n使用教程:'+tutorialPost+(idea.tool?'\n\n```json\n'+JSON.stringify(selections,null,2)+'\n```':''));}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import {serveApiDocumentation} from '../../../studio/lib/api-documentation';
|
import {serveApiDocumentation} from '../../lib/api-documentation';
|
||||||
import type {NextApiRequest,NextApiResponse} from "next";
|
import type {NextApiRequest,NextApiResponse} from "next";
|
||||||
export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/health'))return;res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});}
|
export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/health'))return;res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||||
|
import {ideas} from '../../content/ideas';
|
||||||
|
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
|
|
||||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||||
import {embedLayoutImages} from '../../../../studio/lib/layout-images';
|
import {embedLayoutImages} from '../../../lib/layout-images';
|
||||||
import {createElement} from 'react';
|
import {createElement} from 'react';
|
||||||
import {renderToStaticMarkup} from 'react-dom/server';
|
import {renderToStaticMarkup} from 'react-dom/server';
|
||||||
import layouts from '../../../../studio/content/templates/layouts.json';
|
import layouts from '../../../content/templates/layouts.json';
|
||||||
import standardLayout from '../../../../studio/content/standard-layout.json';
|
import standardLayout from '../../../content/standard-layout.json';
|
||||||
import {LayoutCanvas} from '../../../../studio/components/LayoutCanvas';
|
import {LayoutCanvas} from '../../../components/LayoutCanvas';
|
||||||
import {parameters,LayoutParameterError} from '../../../../studio/lib/layout-parameters';
|
import {parameters,LayoutParameterError} from '../../../lib/layout-parameters';
|
||||||
const workspaceSchema={
|
const workspaceSchema={
|
||||||
added:'可选数组,最多100项:{id:"user-唯一ID",kind:"text|image|decoration",role:"名称",box:[x,y,width,height],direction:"horizontal",text:"文字或null",reading:"custom",shape:"rectangle|ellipse(装饰可选)"}',
|
added:'可选数组,最多100项:{id:"user-唯一ID",kind:"text|image|decoration",role:"名称",box:[x,y,width,height],direction:"horizontal",text:"文字或null",reading:"custom",shape:"rectangle|ellipse(装饰可选)"}',
|
||||||
layers:'按原生或新增元素ID索引:x/y -10000..10000,width/height 0.1..10000,rotation -360..360,opacity 0..1,radius 0..5000,z -1000..1000,hidden/locked 布尔值;坐标和字号均为模板单位',
|
layers:'按原生或新增元素ID索引:x/y -10000..10000,width/height 0.1..10000,rotation -360..360,opacity 0..1,radius 0..5000,z -1000..1000,hidden/locked 布尔值;坐标和字号均为模板单位',
|
||||||
@@ -26,6 +25,6 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
|||||||
}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||||
}
|
}
|
||||||
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
|
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
|
||||||
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)}))))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||||
}
|
}
|
||||||
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
|
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||||
import fixed from '../../../../studio/content/templates/layouts.json';
|
import fixed from '../../../content/templates/layouts.json';
|
||||||
import standard from '../../../../studio/content/standard-layout.json';
|
import standard from '../../../content/standard-layout.json';
|
||||||
export default function handler(req:NextApiRequest,res:NextApiResponse){
|
export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||||
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}
|
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}
|
||||||
const entries=[standard,...fixed].map(l=>({id:l.id,name:l.name,family:l.id==='standard-01'?'standard':'fixed',url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id}));
|
const entries=[standard,...fixed].map(l=>({id:l.id,name:l.name,family:l.id==='standard-01'?'standard':'fixed',url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id}));
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type {GetServerSideProps} from 'next';
|
import type {GetServerSideProps} from 'next';
|
||||||
import {readDesign} from '../../../studio/lib/design-server';
|
import {readDesign} from '../../lib/design-server';
|
||||||
import {designUrl} from '../../../studio/lib/native-layout';
|
import {designUrl} from '../../lib/native-layout';
|
||||||
export default function DesignPage(){return null}
|
export default function DesignPage(){return null}
|
||||||
export const getServerSideProps:GetServerSideProps=async({params,req,res})=>{
|
export const getServerSideProps:GetServerSideProps=async({params,req,res})=>{
|
||||||
res.setHeader('Cache-Control','private, no-store');
|
res.setHeader('Cache-Control','private, no-store');
|
||||||
const design=await readDesign(params?.id,req);
|
const design=await readDesign(params?.id,req);
|
||||||
if(!design)return {notFound:true};
|
if(!design)return {notFound:true};
|
||||||
// The unified application has no basePath; keep the full historical URL.
|
// Next adds the configured /studio basePath to server redirects.
|
||||||
return {redirect:{destination:designUrl(design.layoutId,design.id),permanent:false}};
|
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type {GetStaticPaths,GetStaticProps} from 'next';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {LayoutSelection} from '../../components/LayoutSelection';
|
||||||
|
import {tutorialPost} from '../../content/tool-links';
|
||||||
|
import {TemplateTrial} from '../../components/TemplateTrial';
|
||||||
|
import {ideas,type Idea} from '../../content/ideas';
|
||||||
|
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← PROMPT 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||||
|
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
|
||||||
|
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
import fixed from '../content/templates/layouts.json';
|
||||||
|
import standard from '../content/standard-layout.json';
|
||||||
|
import {nativeLayout} from '../lib/native-layout';
|
||||||
|
import {NativePreview as Preview} from '../components/NativePreview';
|
||||||
|
export default function StudioEntry(){return <main className="studio-library"><Head><title>Studio · 可视化排版 API 库</title></Head><nav className="library-nav"><a href="/">PROMPT / STUDIO</a><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档与工具箱</a><a href="/api/studio/designs?agent_mode=True">API 契约</a></nav><h1>用参数,编排画面。</h1><p className="library-intro">给 coding agent 文字、图片与创作意图。选择标准图文或固定构图,通过 HTTP 保存渲染描述,得到一个可持续更新的原生页面。</p><div className="library-heading"><h2>标准排版</h2><a href="/studio/api/layouts/standard-01">参数</a></div><div className="library-standard"><a href="/studio/layouts/standard-01"><Preview layout={standard}/></a><div><h2>一页,一件事。</h2><p>沿用 Studio 标题、正文与页脚的图文结构,增加可配置图片位。文字、颜色、图片和几何坐标共用一套参数,也可添加原生元素。</p><code>layoutId: standard-01</code><p><a href="/studio/layouts/standard-01">打开原生页面</a></p></div></div><div className="library-heading"><h2>固定排版 · 27 套</h2><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档</a></div><div className="library-grid">{fixed.map(l=><article className="library-item" key={l.id}><a href={'/studio/layouts/'+l.id}><Preview layout={nativeLayout(l)}/><h3>{l.name}</h3><p>{l.id.toUpperCase()}</p></a></article>)}</div></main>}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type {GetServerSideProps} from 'next';
|
import type {GetServerSideProps} from 'next';
|
||||||
import {NativeResult} from '../../../studio/components/NativeResult';
|
import {NativeResult} from '../../components/NativeResult';
|
||||||
import type {Layout} from '../../../studio/lib/layout-parameters';
|
import type {Layout} from '../../lib/layout-parameters';
|
||||||
import fixedLayouts from '../../../studio/content/templates/layouts.json';
|
import fixedLayouts from '../../content/templates/layouts.json';
|
||||||
import standardLayout from '../../../studio/content/standard-layout.json';
|
import standardLayout from '../../content/standard-layout.json';
|
||||||
const layouts=[...fixedLayouts,standardLayout];
|
const layouts=[...fixedLayouts,standardLayout];
|
||||||
import {nativeLayout,designUrl} from '../../../studio/lib/native-layout';
|
import {nativeLayout,designUrl} from '../../lib/native-layout';
|
||||||
import {readDesign} from '../../../studio/lib/design-server';
|
import {readDesign} from '../../lib/design-server';
|
||||||
export default function LayoutPage({layout}:{layout:Layout}){return <NativeResult layout={layout} design={null}/>}
|
export default function LayoutPage({layout}:{layout:Layout}){return <NativeResult layout={layout} design={null}/>}
|
||||||
export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{
|
export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{
|
||||||
res.setHeader('Cache-Control','private, no-store');
|
res.setHeader('Cache-Control','private, no-store');
|
||||||
@@ -14,7 +14,7 @@ export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({param
|
|||||||
if(query.id!==undefined){
|
if(query.id!==undefined){
|
||||||
const design=await readDesign(query.id,req);
|
const design=await readDesign(query.id,req);
|
||||||
if(!design||design.layoutId!==layout.id)return {notFound:true};
|
if(!design||design.layoutId!==layout.id)return {notFound:true};
|
||||||
return {redirect:{destination:designUrl(design.layoutId,design.id),permanent:false}};
|
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
|
||||||
}
|
}
|
||||||
return {props:{layout:nativeLayout(layout)}};
|
return {props:{layout:nativeLayout(layout)}};
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type {GetServerSideProps} from 'next';
|
import type {GetServerSideProps} from 'next';
|
||||||
import {NativeSeries} from '../../studio/components/NativeSeries';
|
import {NativeSeries} from '../components/NativeSeries';
|
||||||
import {NativeResult} from '../../studio/components/NativeResult';
|
import {NativeResult} from '../components/NativeResult';
|
||||||
import fixedLayouts from '../../studio/content/templates/layouts.json';
|
import fixedLayouts from '../content/templates/layouts.json';
|
||||||
import standardLayout from '../../studio/content/standard-layout.json';
|
import standardLayout from '../content/standard-layout.json';
|
||||||
import {readDesign} from '../../studio/lib/design-server';
|
import {readDesign} from '../lib/design-server';
|
||||||
import {nativeLayout,type Design} from '../../studio/lib/native-layout';
|
import {nativeLayout,type Design} from '../lib/native-layout';
|
||||||
import type {Layout} from '../../studio/lib/layout-parameters';
|
import type {Layout} from '../lib/layout-parameters';
|
||||||
|
|
||||||
type Props={layout:Layout;design:Design;page:number|null;quality:boolean};
|
type Props={layout:Layout;design:Design;page:number|null;quality:boolean};
|
||||||
export default function TemplateLayout(props:Props){return props.design.document?<NativeSeries design={props.design} page={props.page} quality={props.quality}/>:<NativeResult {...props}/>}
|
export default function TemplateLayout(props:Props){return props.design.document?<NativeSeries design={props.design} page={props.page} quality={props.quality}/>:<NativeResult {...props}/>}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
|
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
|
||||||
import {parameters} from '../../src/studio/lib/layout-parameters.ts';
|
import {parameters} from '../src/lib/layout-parameters.ts';
|
||||||
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
|
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
|
||||||
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
|
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^data:image\/png;base64,/);if(s.kind==='image')assert.match(s.shape,/^data:image\/png;base64,/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
|
||||||
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
|
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
|
||||||
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
|
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
|
||||||
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
|
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {readFileSync} from 'node:fs';
|
import {readFileSync} from 'node:fs';
|
||||||
import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../../src/studio/lib/native-layout.ts';
|
import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../src/lib/native-layout.ts';
|
||||||
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
|
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
|
||||||
test('all 27 native pages are independent of source images and supply actual text',()=>{
|
test('all 27 native pages are independent of source images and supply actual text',()=>{
|
||||||
for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000);
|
for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000);
|
||||||
for(const slot of layout.elements.filter(s=>s.kind==='text')){assert.ok(defaultText(layout,slot).length);for(const text of [defaultText(layout,slot),'','新的文字\n第二行','<script>plain text</script>']){const size=textSize(layout,slot,text);assert.ok(Number.isFinite(size)&&size>0)}}
|
for(const slot of layout.elements.filter(s=>s.kind==='text')){assert.ok(defaultText(layout,slot).length);for(const text of [defaultText(layout,slot),'','新的文字\n第二行','<script>plain text</script>']){const size=textSize(layout,slot,text);assert.ok(Number.isFinite(size)&&size>0)}}
|
||||||
@@ -3,9 +3,10 @@ import assert from 'node:assert/strict';
|
|||||||
import {readFileSync} from 'node:fs';
|
import {readFileSync} from 'node:fs';
|
||||||
import {registerHooks} from 'node:module';
|
import {registerHooks} from 'node:module';
|
||||||
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
|
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
|
||||||
const {series,pageParameters}=await import('../../src/studio/lib/series.ts');
|
const {series,pageParameters}=await import('../src/lib/series.ts');
|
||||||
const layout=JSON.parse(readFileSync(new URL('../../src/studio/content/standard-layout.json',import.meta.url)));
|
const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url)));
|
||||||
// Fixture copied into the CI image; no build is performed by these tests.
|
// Fixture copied into the CI image; no build is performed by these tests.
|
||||||
const input=JSON.parse(readFileSync(new URL('../../fixtures/studio/autumn-journal.json',import.meta.url)));
|
const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url)));
|
||||||
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
|
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
|
||||||
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
|
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
|
||||||
|
test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8')));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -6,39 +6,32 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM base AS dependencies
|
FROM base AS dependencies
|
||||||
WORKDIR /repo/apps/waterfall-wiki
|
|
||||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||||
# Download cache depends on lockfile and workspace policy, not package scripts.
|
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||||
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
|
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||||
COPY packages/brand/package.json /repo/packages/brand/package.json
|
|
||||||
COPY apps/waterfall-wiki/package.json ./
|
COPY apps/waterfall-wiki/package.json ./
|
||||||
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
|
RUN pnpm install --offline --frozen-lockfile
|
||||||
|
|
||||||
FROM dependencies AS build
|
FROM dependencies AS build
|
||||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||||
# Brand source changes invalidate compilation, not dependency installation.
|
|
||||||
COPY packages/brand/ /repo/packages/brand/
|
|
||||||
COPY apps/waterfall-wiki/ .
|
COPY apps/waterfall-wiki/ .
|
||||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||||
|
|
||||||
FROM build AS integration
|
FROM build AS integration
|
||||||
ENV VERIFY_CANVAS_UI=1
|
|
||||||
USER node
|
USER node
|
||||||
CMD ["npm", "run", "test:integration"]
|
CMD ["npm", "run", "test:integration"]
|
||||||
|
|
||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
WORKDIR /app/apps/waterfall-wiki
|
|
||||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||||
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
||||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
|
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
|
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
|
||||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
|
COPY --from=build --chown=node:node /app/db/ ./db/
|
||||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
||||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||||
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
|
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
CREATE TABLE guidance_documents(id text PRIMARY KEY CHECK(id ~ '^user-[a-z0-9-]{1,74}$'), owner_id uuid NOT NULL REFERENCES account_users(id), latest integer NOT NULL DEFAULT 0, deleted_at timestamptz, CHECK(id LIKE 'user-' || owner_id::text || '-%'));
|
|
||||||
CREATE TABLE guidance_revisions(document_id text NOT NULL REFERENCES guidance_documents(id), revision integer NOT NULL CHECK(revision>0), content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(document_id,revision));
|
|
||||||
CREATE FUNCTION guidance_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Published revisions are immutable'; END $$;
|
|
||||||
CREATE TRIGGER guidance_immutable BEFORE UPDATE OR DELETE ON guidance_revisions FOR EACH ROW EXECUTE FUNCTION guidance_immutable();
|
|
||||||
INSERT INTO schema_migrations(version) VALUES('015_guidance_documents');
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
CREATE TABLE canvas_documents (
|
|
||||||
id uuid PRIMARY KEY, owner_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
|
|
||||||
name text NOT NULL DEFAULT '未命名画布' CHECK(length(name) BETWEEN 1 AND 200),
|
|
||||||
revision integer NOT NULL DEFAULT 1 CHECK(revision>0), document_key text NOT NULL,
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX canvas_owner_updated ON canvas_documents(owner_id,updated_at DESC);
|
|
||||||
CREATE TABLE canvas_agent_links (
|
|
||||||
digest text PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
|
|
||||||
expires_at timestamptz NOT NULL, last_seen_at timestamptz
|
|
||||||
);
|
|
||||||
CREATE INDEX canvas_link_expiry ON canvas_agent_links(canvas_id,expires_at);
|
|
||||||
CREATE TABLE canvas_tasks (
|
|
||||||
id uuid PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
|
|
||||||
target_id text NOT NULL, source_revision integer NOT NULL,
|
|
||||||
status text NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','succeeded','failed')),
|
|
||||||
request_key text NOT NULL, result_digest text, error text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX canvas_target_active_task ON canvas_tasks(canvas_id,target_id) WHERE status IN ('queued','running');
|
|
||||||
INSERT INTO schema_migrations(version) VALUES ('016_canvas_workspace');
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_kind_check;
|
|
||||||
ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_kind_check CHECK (kind IN ('verify','session','reset'));
|
|
||||||
INSERT INTO schema_migrations(version) VALUES ('017_password_reset');
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,29 +1,17 @@
|
|||||||
import { resolve } from "node:path";
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import managedMedia from "./src/content/managed-media.json";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: { root: resolve(process.cwd(), "../..") },
|
|
||||||
serverExternalPackages: ["playwright-core"],
|
serverExternalPackages: ["playwright-core"],
|
||||||
async redirects(){return [
|
|
||||||
{source:'/studio/graphic',destination:'/graphic',permanent:true},
|
|
||||||
{source:'/studio/infinite_canvas',destination:'/infinite_canvas',permanent:true},
|
|
||||||
{source:'/studio',destination:'/workspace',permanent:false},
|
|
||||||
]},
|
|
||||||
async rewrites(){return [
|
async rewrites(){return [
|
||||||
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
|
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
|
||||||
{source:"/studio/api/:path*",destination:"/api/studio-ui/:path*"},
|
{source:"/index.md",destination:"/api/guidance"},
|
||||||
{source:"/studio/brand/:path*",destination:"/brand/:path*"},
|
|
||||||
{source:"/studio/favicon.ico",destination:"/favicon.ico"},
|
|
||||||
{source:"/studio/apple-touch-icon.png",destination:"/apple-touch-icon.png"},
|
|
||||||
{source:"/index.md",destination:"/api/agent-index"},
|
|
||||||
{source:"/skills/index.md",destination:"/api/guidance/skills"},
|
{source:"/skills/index.md",destination:"/api/guidance/skills"},
|
||||||
{source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"},
|
{source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"},
|
||||||
{source:"/agent",destination:"/api/agent-index"},
|
{source:"/agent",destination:"/api/agent-index"},
|
||||||
{source:"/agent/index.md",destination:"/api/agent-index"},
|
{source:"/agent/index.md",destination:"/api/agent-index"},
|
||||||
{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"},
|
{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"},
|
||||||
]},
|
]},
|
||||||
output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."),
|
output: "standalone", outputFileTracingRoot: process.cwd(),
|
||||||
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
|
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,17 +6,16 @@
|
|||||||
"node": ">=24 <25"
|
"node": ">=24 <25"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm brand:prepare && next dev",
|
"dev": "next dev",
|
||||||
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
|
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "next typegen && tsc --noEmit",
|
"typecheck": "next typegen && tsc --noEmit",
|
||||||
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts && node --test tests/studio/*.test.mjs",
|
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts",
|
||||||
"test:smoke": "node scripts/smoke.mjs && node scripts/studio-smoke.mjs",
|
"test:smoke": "node scripts/smoke.mjs",
|
||||||
"storage:prepare": "tsx scripts/bootstrap.ts",
|
"storage:prepare": "tsx scripts/bootstrap.ts",
|
||||||
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
|
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
|
||||||
"worker": "tsx src/worker.ts",
|
"worker": "tsx src/worker.ts",
|
||||||
"test:integration": "tsx --test tests/integration.test.ts",
|
"test:integration": "tsx --test tests/integration.test.ts"
|
||||||
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.1155.0",
|
"@aws-sdk/client-s3": "3.1155.0",
|
||||||
@@ -33,8 +32,7 @@
|
|||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"remark-gfm": "4.0.1",
|
"remark-gfm": "4.0.1",
|
||||||
"sharp": "0.35.5",
|
"sharp": "0.35.5",
|
||||||
"zod": "4.6.7",
|
"zod": "4.6.7"
|
||||||
"@endoleap/brand": "workspace:*"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ importers:
|
|||||||
'@aws-sdk/client-s3':
|
'@aws-sdk/client-s3':
|
||||||
specifier: 3.1155.0
|
specifier: 3.1155.0
|
||||||
version: 3.1155.0
|
version: 3.1155.0
|
||||||
'@endoleap/brand':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../packages/brand
|
|
||||||
formidable:
|
formidable:
|
||||||
specifier: 3.5.4
|
specifier: 3.5.4
|
||||||
version: 3.5.4
|
version: 3.5.4
|
||||||
|
|||||||
|
After Width: | Height: | Size: 204 KiB |
@@ -1,11 +1,9 @@
|
|||||||
# 织页 · Linked Guidance
|
# PROMPT
|
||||||
|
|
||||||
> 独立使用、按需连接的 Agent 指导文档。名称与 XML 协议仍处于实验阶段。
|
> 创作素材与可相互引用的指导文档。
|
||||||
|
|
||||||
- [Agent 阅读入口](/agent/index.md)
|
- [网站 Markdown 入口](/index.md)
|
||||||
- [真实用户文档摘要(JSON)](/api/documents)
|
- [全部指导文档](/skills/index.md)
|
||||||
- [协议样例摘要(JSON)](/api/guides)
|
|
||||||
- [协议说明](/protocol)
|
|
||||||
- [人类入口](/)
|
- [人类入口](/)
|
||||||
|
|
||||||
从单篇文档开始;只有当前任务需要时才沿条件链接读取其他文档。固定版本引用,限制读取深度与篇数。集合不是强制安装包,链接不授予执行权限。
|
每篇指导文档的网页地址加 /index.md,即为一一对应的 Markdown。沿文档链接继续阅读,可直接进入任意一篇,不必经过某个技能入口。
|
||||||
|
|||||||
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -47,16 +47,10 @@ async function main() {
|
|||||||
if (command === 'verify' || command === 'exercise') {
|
if (command === 'verify' || command === 'exercise') {
|
||||||
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
|
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
|
||||||
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
|
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
|
||||||
for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path);
|
assert.equal((await request('/')).status,200,'Public knowledge homepage');
|
||||||
for(const path of ['/favicon.ico','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/apple-touch-icon.png']) assert.equal((await request(path)).status,200,'Public brand asset '+path);
|
|
||||||
const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace');
|
|
||||||
for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)}
|
|
||||||
assert.equal((await request('/account')).status,200,'Public account page');
|
assert.equal((await request('/account')).status,200,'Public account page');
|
||||||
const homeData = await request(`/_next/data/${buildId}/index.json`);
|
|
||||||
assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages');
|
|
||||||
assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base));
|
|
||||||
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
|
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
|
||||||
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) {
|
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/index.json`]) {
|
||||||
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
|
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
|
||||||
}
|
}
|
||||||
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
|
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
// Only invoked inside the dedicated test network with this run's bucket name.
|
|
||||||
import {S3Client,ListObjectsV2Command,DeleteObjectsCommand,DeleteBucketCommand} from '@aws-sdk/client-s3';
|
|
||||||
const bucket=process.env.S3_BUCKET;
|
|
||||||
if (!/^ci-[1-9][0-9]*$/.test(bucket || '')) throw Error('Refusing non-CI bucket');
|
|
||||||
const client=new S3Client({endpoint:'http://objects:9000',region:'us-east-1',forcePathStyle:true,credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}});
|
|
||||||
try {
|
|
||||||
// Re-list the first page after each deletion so no pagination token can skip keys.
|
|
||||||
for (;;) {
|
|
||||||
const result=await client.send(new ListObjectsV2Command({Bucket:bucket,MaxKeys:1000}));
|
|
||||||
const keys=(result.Contents || []).map(({Key})=>({Key}));
|
|
||||||
if (!keys.length) break;
|
|
||||||
const deleted=await client.send(new DeleteObjectsCommand({Bucket:bucket,Delete:{Objects:keys,Quiet:true}}));
|
|
||||||
if (deleted.Errors?.length) throw Error('CI object cleanup failed');
|
|
||||||
}
|
|
||||||
await client.send(new DeleteBucketCommand({Bucket:bucket}));
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== 'NoSuchBucket') throw error;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const {chromium}=require('playwright-core');
|
|
||||||
const assert=require('node:assert/strict');
|
|
||||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
|
||||||
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
|
|
||||||
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
|
|
||||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
|
||||||
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
|
|
||||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
|
||||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
|
||||||
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
|
|
||||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
|
||||||
await page.getByRole('button',{name:'添加文字',exact:true}).click();
|
|
||||||
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
|
|
||||||
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
|
|
||||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
|
||||||
await page.getByRole('button',{name:'重做',exact:true}).click();
|
|
||||||
await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor();
|
|
||||||
assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始')));
|
|
||||||
const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg'));
|
|
||||||
await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});
|
|
||||||
await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true});
|
|
||||||
await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
|
||||||
assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.');
|
|
||||||
}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import {readFile} from 'node:fs/promises';
|
import {readFile,readdir} from 'node:fs/promises';
|
||||||
import {fileURLToPath} from 'node:url';
|
import {fileURLToPath} from 'node:url';
|
||||||
import {join,dirname} from 'node:path';
|
import {join,dirname} from 'node:path';
|
||||||
import {createHash} from 'node:crypto';
|
import {createHash} from 'node:crypto';
|
||||||
import {requiredPackImages} from './verify-character-pack.mjs';
|
import {requiredPackImages} from './verify-character-pack.mjs';
|
||||||
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
|
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
|
||||||
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
|
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
|
||||||
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
|
|
||||||
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
|
|
||||||
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
|
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
|
||||||
async function verify(pack){
|
async function verify(pack){
|
||||||
let cursor='',post;const seen=new Set();
|
let cursor='',post;const seen=new Set();
|
||||||
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
|
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
|
||||||
if(!post)throw Error('Character pack missing: '+pack.slug);
|
if(!post)throw Error('Character pack missing: '+pack.slug);
|
||||||
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
|
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
|
||||||
const hashes=files.map(([,asset])=>asset.sha256);
|
const hashes=await Promise.all(files.map(async file=>createHash('sha256').update(await readFile(join(root,pack.directory,file))).digest('hex')));
|
||||||
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
|
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
|
||||||
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
|
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
|
||||||
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
|
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
|
||||||
@@ -22,14 +20,10 @@ for(const pack of packs){
|
|||||||
const existing=await fetch(`${base}/p/${pack.slug}`);
|
const existing=await fetch(`${base}/p/${pack.slug}`);
|
||||||
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
|
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
|
||||||
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
|
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
|
||||||
const files=packFiles(pack);
|
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();
|
||||||
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
|
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
|
||||||
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
|
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
|
||||||
for(const [name,asset] of files){
|
for(const file of files)data.append('files',new Blob([await readFile(join(root,pack.directory,file))],{type:'image/png'}),file);
|
||||||
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
|
|
||||||
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
|
|
||||||
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
|
|
||||||
}
|
|
||||||
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
|
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
|
||||||
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
|
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
|
||||||
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
|
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
// Isolated browser context against the designated deployed server only.
|
|
||||||
const {chromium}=require('playwright-core');
|
|
||||||
const assert=require('node:assert/strict');
|
|
||||||
const fs=require('node:fs/promises');
|
|
||||||
(async()=>{
|
|
||||||
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
|
|
||||||
assert.equal(base,'http://8.211.154.134:18131');
|
|
||||||
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
|
|
||||||
let page;
|
|
||||||
try{
|
|
||||||
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
|
|
||||||
page=await context.newPage();const errors=[];let choosers=0;
|
|
||||||
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
|
|
||||||
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
|
|
||||||
const node=id=>page.locator(`[data-node-id="${id}"]`);
|
|
||||||
const content=id=>node(id).locator('[data-node-content]');
|
|
||||||
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
|
|
||||||
const composer=page.getByRole('region',{name:'节点编辑器'});
|
|
||||||
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
|
|
||||||
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
|
|
||||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
|
|
||||||
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
|
|
||||||
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
|
|
||||||
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
|
|
||||||
await page.locator('button[data-theme-ready="true"]').waitFor();
|
|
||||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
|
||||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
|
||||||
await content('brief').click({position:{x:20,y:20}});
|
|
||||||
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
|
||||||
await page.waitForTimeout(700);
|
|
||||||
await content('brief').click({position:{x:20,y:20}});
|
|
||||||
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
|
|
||||||
await waitField('brief','text','选择后再次点击进入编辑');
|
|
||||||
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
|
|
||||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
|
|
||||||
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
|
|
||||||
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
|
|
||||||
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
|
|
||||||
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
|
|
||||||
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
|
|
||||||
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
|
|
||||||
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
|
|
||||||
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
|
|
||||||
await capture('canvas-text-format-desktop');
|
|
||||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
|
||||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
const before=(await snapshot()).cards.find(n=>n.id==='brief');
|
|
||||||
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
|
|
||||||
await drag(content('brief'),45,25);
|
|
||||||
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
|
|
||||||
assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
|
||||||
|
|
||||||
await content('reference').click();assert.equal(await composer.count(),0);
|
|
||||||
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
|
|
||||||
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
|
|
||||||
await capture('canvas-composer-desktop');
|
|
||||||
await page.getByRole('button',{name:'缩小',exact:true}).click();
|
|
||||||
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
|
|
||||||
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
|
|
||||||
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
|
|
||||||
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
|
|
||||||
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
|
|
||||||
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
|
|
||||||
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
|
|
||||||
await unobstructed(upscale.id);
|
|
||||||
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
|
|
||||||
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
|
|
||||||
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
|
||||||
await content('brief').click({position:{x:20,y:20}});
|
|
||||||
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
|
|
||||||
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
|
|
||||||
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
|
|
||||||
await unobstructed(script.id);
|
|
||||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
|
|
||||||
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
|
|
||||||
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
|
|
||||||
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
|
|
||||||
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
|
||||||
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
|
|
||||||
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
|
||||||
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
|
|
||||||
await page.getByRole('button',{name:'删除节点',exact:true}).click();
|
|
||||||
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
|
|
||||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await content('brief').click({position:{x:20,y:20}});
|
|
||||||
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
|
|
||||||
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
|
|
||||||
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
|
|
||||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
|
|
||||||
await page.getByRole('button',{name:'重做',exact:true}).click();
|
|
||||||
await page.getByRole('button',{name:'移动画布',exact:true}).click();
|
|
||||||
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
|
|
||||||
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
|
|
||||||
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
|
|
||||||
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
|
|
||||||
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
|
|
||||||
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
|
|
||||||
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
|
|
||||||
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
|
|
||||||
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
|
|
||||||
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
|
|
||||||
assert.ok(['light','dark'].includes(initialMode));
|
|
||||||
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
|
|
||||||
await themeToggle.click();
|
|
||||||
const matchesTheme=mode=>{
|
|
||||||
const root=document.documentElement,canvas=document.querySelector('main');
|
|
||||||
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
|
|
||||||
};
|
|
||||||
await page.waitForFunction(matchesTheme,mode);
|
|
||||||
await page.reload({waitUntil:'commit'});
|
|
||||||
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
|
|
||||||
await page.waitForFunction(matchesTheme,mode);
|
|
||||||
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
|
|
||||||
await capture('canvas-theme-'+mode);
|
|
||||||
}
|
|
||||||
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
|
|
||||||
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
|
||||||
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
|
|
||||||
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
|
|
||||||
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload, both persistent root themes and mobile bounds');
|
|
||||||
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
|
|
||||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cp,mkdir,access } from 'node:fs/promises';
|
import { cp,mkdir,access } from 'node:fs/promises';
|
||||||
await mkdir('.next/standalone/apps/waterfall-wiki/.next',{recursive:true});
|
await mkdir('.next/standalone/.next',{recursive:true});
|
||||||
await cp('.next/static','.next/standalone/apps/waterfall-wiki/.next/static',{recursive:true});
|
await cp('.next/static','.next/standalone/.next/static',{recursive:true});
|
||||||
try{await access('public');await cp('public','.next/standalone/apps/waterfall-wiki/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
|
try{await access('public');await cp('public','.next/standalone/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import {writeFile,unlink} from 'node:fs/promises';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { setTimeout } from "node:timers/promises";
|
import { setTimeout } from "node:timers/promises";
|
||||||
@@ -17,12 +15,8 @@ async function availablePort() {
|
|||||||
|
|
||||||
const port = process.env.SMOKE_PORT || await availablePort();
|
const port = process.env.SMOKE_PORT || await availablePort();
|
||||||
const release = "smoke-test";
|
const release = "smoke-test";
|
||||||
const previewFixture='.next/standalone/apps/waterfall-wiki/public/__smoke-preview.png';
|
const child = spawn(process.execPath, [".next/standalone/server.js"], {
|
||||||
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
|
env: { ...process.env, PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
|
||||||
const child = spawn(process.execPath, [".next/standalone/apps/waterfall-wiki/server.js"], {
|
|
||||||
// This loopback-only child checks retained resources without production credentials.
|
|
||||||
// Production keeps failing closed; the bypass requires both explicit test settings.
|
|
||||||
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
|
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -46,17 +40,10 @@ try {
|
|||||||
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
|
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
|
||||||
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
|
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
|
||||||
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
|
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
|
||||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
|
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/skills/shadow-puppet/egret-example.png')}&width=480`);
|
||||||
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
|
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
|
||||||
const home=await fetch(`http://127.0.0.1:${port}/`).then(r=>r.text());
|
console.log("Production smoke test passed");
|
||||||
for(const href of ['/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes(`href="${href}"`),href);
|
|
||||||
for(const [legacy,canonical] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
|
|
||||||
const redirect=await fetch(`http://127.0.0.1:${port}${legacy}`,{redirect:'manual'});
|
|
||||||
assert.equal(redirect.status,308);assert.equal(redirect.headers.get('location'),canonical);
|
|
||||||
}
|
|
||||||
console.log("Production smoke test passed, including landing entry points and canonical redirects");
|
|
||||||
} finally {
|
} finally {
|
||||||
await unlink(previewFixture);
|
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
const exited = once(child, "exit");
|
const exited = once(child, "exit");
|
||||||
child.kill("SIGTERM");
|
child.kill("SIGTERM");
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 813 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 854 KiB |
|
After Width: | Height: | Size: 884 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 1020 KiB |
|
After Width: | Height: | Size: 866 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1,4 +0,0 @@
|
|||||||
/** The approved mark is copied from @endoleap/brand during the remote build. */
|
|
||||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
|
||||||
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
|
|
||||||
}
|
|
||||||