feat: projcet Initialization

This commit is contained in:
2026-05-03 09:38:24 +08:00
commit 81bca6aa80
22 changed files with 1867 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
# AGENTS.md
## 專案概念
HTML Transform 是 prototype-driven orchestration framework。目標是把 HTML prototype 轉成可 review、可驗證、可追溯的 Vuetify frontend output。
這個 repo 目前是 MVP,不是完整產品。核心價值先放在 CLI pipeline 與 artifact-first workflow
```text
scan -> plan -> run -> diff -> verify
```
所有中間產物都寫到 `.ht/`,生成結果寫到 `output` 設定指向的資料夾,預設是 `packages/result`
## 目前 MVP 狀態
已完成:
- `ht` CLI 骨架:`scan``plan``run``diff``verify``go``status``doctor`
- `ht.config.js/json/ts` 設定載入
- deterministic HTML evidence extraction
- `.ht/cache/prototype`
- `.ht/spec/*.spec.json`
- `.ht/spec/*.validation.json`
- `.ht/spec/*.ui-contract.md`
- `.ht/plan/tasklist.json`
- `.ht/plan/project-conventions.md`
- `.ht/plan/api-contract.md`
- `.ht/plan/component-mapping.json`
- `.ht/runs/<runId>/`
- `.ht/diff/*.diff.html`
- `.ht/verify/verification-report.json`
- 基本 E2E 測試
尚未完成,不能假裝已完成:
- Playwright static server
- 真實 full-page screenshot
- 真實 DOM snapshot API
- 真實 accessibility tree API
- vision-based decomposition/extraction
- Zod schema validation
- 真實 coding agent invocation
- 真實 visual similarity scoring
- 完整 DOM-level 與 flow-level smoke tests
目前 screenshot 是 placeholder PNG`diff` score 是 deterministic placeholder。
## 套件管理
使用 pnpm,不使用 npm 或 yarn。
```bash
pnpm install
pnpm test
pnpm typecheck
```
不要新增 `package-lock.json``yarn.lock`。lockfile 應該是 `pnpm-lock.yaml`
## Playwright CLI 決策
這個專案使用 `@playwright/cli`,透過 shell command 操作 `playwright-cli`,不使用 MCP。
原因:
- PRD 的 pipeline 是 coding-agent orchestrationCLI 比 MCP 更適合放進可重跑 stage。
- CLI invocation 較容易寫入 `.ht/runs`、logs、metadata 與 verification report。
- 專案應避免依賴使用者全域環境。
解析順序在 `src/lib/playwright-cli.js`
1. `node_modules/.bin/playwright-cli`
2. 全域 `playwright-cli`
3. `npx --no-install playwright-cli`
`doctor` 必須能顯示 `playwright-cli (local)``playwright-cli (global)` 或 missing。
專案級 Codex runtime skill 放在 `.codex/skills/playwright-cli`,本機全域也可能有一份在 `~/.codex/skills/playwright-cli`。處理 browser automation、snapshot、screenshot、trace、video、test generation 或 local web app testing 時,Codex 應優先使用專案級 skill;若沒有自動觸發,先讀取 `.codex/skills/playwright-cli/SKILL.md` 再操作。
安裝瀏覽器:
```bash
pnpm playwright-cli:install-browser
```
Linux CI 或乾淨容器若需要系統 dependency,先 dry-run
```bash
npx --no-install playwright-cli install-browser --with-deps --dry-run
```
## 預設資料夾
預設 config
```text
packages/prototype # HTML prototype
packages/web # 既有 Vuetify frontend
packages/api # backend API/schema
packages/result # output
.ht # tool artifacts
```
目前 repo 可能沒有預設輸入資料夾。這時 `doctor` 回報 missing 是正確行為,不是 bug。
## 開發原則
- 遵守 PRD 的 artifact-first 思路。
- 不要把 placeholder 包裝成完整實作。
- 每個 stage 的失敗、缺少環境、skipped 都要明確寫入 report 或 CLI output。
- 優先做可重跑、可檢查、可追溯的 deterministic behavior。
- 不要讓 agent 修改 `prototype/``frontend/``backend/` 原始輸入資料夾;生成與執行 artifact 應限制在 `output/``.ht/`
- 保持 MVP 邊界,避免加泛用抽象或一開始支援所有 monorepo 形態。
## 驗證
完成任何改動後至少執行:
```bash
pnpm test
pnpm typecheck
```
若改到環境偵測、Playwright CLI、設定載入,也執行:
```bash
node src/cli.js doctor
```
若改到 stage pipeline,應確認 E2E 測試仍通過,或新增/更新 `test/cli-e2e.test.js`
## 重要檔案
- `PRD.md`:產品需求來源。
- `README.md`:使用方式與 MVP 限制。
- `src/cli.js`CLI command router。
- `src/lib/config.js`config loading。
- `src/lib/html.js`deterministic HTML evidence extraction。
- `src/lib/playwright-cli.js`Playwright CLI resolution。
- `src/stages/*.js`:各 stage 實作。
- `test/cli-e2e.test.js`:完整 CLI pipeline 測試。
## 下一個最重要的方向
下一步最值得做的是把 Stage 1 做真:
- local static server
- `playwright-cli` 三 viewport screenshot
- network/resource failure metadata
- DOM snapshot artifact
- accessibility snapshot artifact
- cache hit 時跳過 screenshot
這完成後,工具才會真正開始觀察 browser-rendered prototype,而不是只解析 HTML source。
+361
View File
@@ -0,0 +1,361 @@
# Product Requirement Document
## HTML Transform
| 欄位 | 內容 |
|------|------|
| 版本 | v0.1 |
| 日期 | 2026-05-02 |
| 狀態 | Draft |
---
## 1. 背景與目標
HTML Transform 是一個安裝在 pnpm-workspace / monorepo 中的 prototype-driven orchestration framework。它讓工程師指定四個資料夾,由單一 coding agent 依序驅動完整 pipeline,將 HTML prototype 轉換為可運作、可維護的 Vuetify 前端實作,並用 backend API schema 對齊資料流。
**四個資料夾:**
| 識別子 | 說明 |
|--------|------|
| `prototype` | HTML prototype 來源 |
| `frontend` | 已預先建立的 Vuetify 前端專案 |
| `backend` | 已預先建立的 backend API 專案 |
| `output` | 生成結果的寫入目標 |
**核心設計原則:** pipeline 的每個 stage 都產出可被人工 review 的 artifactagent 的不確定性由結構化中間產物吸收,而非在最終輸出才暴露。HTML prototype 不是完整規格;rendered browser evidence、DOM/accessibility snapshot、`LayoutSpec`、project conventions、API contract、`TaskList` 才共同構成實作依據。
**成功標準:**
- 相同 prototype 執行兩次,`output` 資料夾的結果語意一致
- Diff report 的平均 similarity score 高於 75%
- `LayoutSpec``null` 欄位比率低於 30%
- Stage 7 的 quality gatestypecheck、lint、test、route smoke test)全部通過,或明確標示為環境缺失而非實作失敗
- 每次 run 都能回溯到對應的 screenshot、DOM snapshot、spec、plan、agent raw output 與 verification report
---
## 2. 系統架構
```
ht.config.ts
┌─────────────────────────────────────────────────────┐
│ Orchestrator │
│ (讀取 config,依序呼叫各 stage,傳遞中間產物) │
└──────┬──────────┬──────────┬──────────┬─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
[Stage 1] [Stage 2] [Stage 3] [Stage 4]
Capture Decompose Extract Validate
│ │ │ │
└──────────┴──────────┴──────────┘
LayoutSpec
┌────────┴────────┐
▼ ▼
[Stage 5] [Stage 6]
Plan Run
TaskList coding agent
output/
[Stage 7]
Diff & Verify
reports + quality gates
```
**Agent 角色:** 單一 coding agent(由 `ht.config.ts` 中的 `agent` 欄位指定,支援 claude-code、codex、gemini)全程驅動所有 stage,包含 vision 截圖分析、spec 提取、程式碼實作、diff 評分。Orchestrator 負責呼叫順序與中間產物的序列化,agent 負責每個 stage 的實際執行。
---
## 3. 設定檔
```typescript
// ht.config.ts
import { defineConfig } from 'html-transform'
export default defineConfig({
prototype: './packages/prototype',
frontend: './packages/web',
backend: './packages/api',
output: './packages/result',
agent: 'claude-code', // 'claude-code' | 'codex' | 'gemini'
vision: {
viewport: { width: 1440, height: 900 },
captureStates: ['default', 'scrolled', 'mobile'],
includeDomSnapshot: true,
includeAccessibilityTree: true,
},
project: {
packageManager: 'pnpm',
frontendFramework: 'vue3',
uiLibrary: 'vuetify3',
qualityCommands: {
typecheck: 'pnpm typecheck',
lint: 'pnpm lint',
test: 'pnpm test',
},
},
plan: {
interactiveReview: true, // Stage 5 完成後暫停,等待人工確認 TaskList
requireApiContractMatch: true,
},
diff: {
scoreThreshold: 75, // 低於此分數的 region 在 report 中標示警告
requireDomChecks: true,
requireFlowChecks: true,
},
})
```
---
## 4. Pipeline 詳細需求
### Stage 1Capture
**目的:** 產出 prototype 的視覺真相,取代 agent 腦補的渲染結果。
| 需求 ID | 說明 |
|---------|------|
| F1-1 | 對每個 `.html` 檔案,以 desktop1440×900)、tablet768×1024)、mobile375×812)各截一張全頁截圖 |
| F1-2 | 以本地 static server 提供 prototype 檔案,避免 `file://` 協定的 CORS 問題 |
| F1-3 | 截圖前等待 `networkidle`,再額外等待 500ms,確保 CSS animation 穩定 |
| F1-4 | 以原始 `.html` 檔案的 SHA-256 hash 作為 cache keyhash 未變動時跳過截圖 |
| F1-5 | 截圖儲存至 `.ht/cache/prototype/{filename}/{viewport}-{state}.png` |
| F1-6 | 對每個 viewport/state 產出 DOM summary 與 accessibility tree snapshot,作為 Stage 25 的非視覺 evidence |
| F1-7 | 記錄外部資源載入失敗清單(font、image、script、stylesheet),寫入 capture metadata |
**產出物:** `.ht/cache/prototype/` 下的截圖、DOM snapshot、accessibility snapshot 與 capture metadata
---
### Stage 2Decompose
**目的:** 識別頁面的主要區域,為 Stage 3 的局部提取做準備。直接對整頁截圖做一次性提取,精度低且不穩定;分區後再提取可大幅降低錯誤率。
| 需求 ID | 說明 |
|---------|------|
| F2-1 | 以 desktop 截圖呼叫 agent(vision 模式),識別頁面主要區域 |
| F2-2 | 回傳 JSON array,每個 item 包含 `id``name``rough_position``element_count_estimate``semantic_role` |
| F2-3 | 依據 `rough_position`,用 Playwright `clip` 截取每個 region 的局部截圖 |
| F2-4 | Prompt 明確要求只回 JSON,不附任何說明文字 |
| F2-5 | 使用 Stage 1 的 DOM summary 與 accessibility tree 輔助確認 region 名稱、可互動元素與文字,不只依賴截圖 |
**產出物:** `PageDecomposition` JSON(含各 region 的局部截圖路徑)
---
### Stage 3Extract
**目的:** 從每個 region 截圖提取結構化的 `LayoutSpec`
| 需求 ID | 說明 |
|---------|------|
| F3-1 | 每個 region 獨立呼叫 agent,輸入該 region 的局部截圖 |
| F3-2 | Prompt 以填空題形式提供 `LayoutSpec` JSON 結構,要求 agent 填值而非自由生成 |
| F3-3 | `vuetifyComponent` 必須是 Vuetify 3 的真實 component 名稱(對照白名單驗證) |
| F3-4 | 不確定的欄位填 `null`Prompt 明確禁止猜測 |
| F3-5 | `colorPalette` 只填截圖中實際出現的色碼(hex 或 rgba) |
| F3-6 | `textSamples` 填截圖中可見的真實文字,最多 3 筆 |
| F3-7 | 對表單、列表、表格、導覽與 action button 產出 `uiContract` 欄位,描述 label、field type、required 狀態、主要/次要 action、可觀察互動 |
| F3-8 | 若 DOM/accessibility evidence 與 vision 判讀衝突,保留 `warnings`,不得任意合併成確定結論 |
**產出物:** 每個 region 的 `RegionSpec` JSON
---
### Stage 4Validate
**目的:** 確保 `LayoutSpec` 符合 schema,自動修復可修復的問題,標記需要人工介入的頁面。
| 需求 ID | 說明 |
|---------|------|
| F4-1 | 以 Zod schema 驗證每個 region 的 extract 結果 |
| F4-2 | `vuetifyComponent` 不合法時,查 Vuetify 3 白名單找最相近的名稱並自動修正 |
| F4-3 | 色碼格式不合法時,嘗試 normalize;無法修正時填 `null` |
| F4-4 | 產出 `ValidationReport`,記錄 `autoFixedCount``nullFieldCount``warnings` |
| F4-5 | `nullFieldCount` 超過 30% 時,將該頁標記為 `requiresHumanReview: true`,並在 CLI 輸出警告 |
| F4-6 | 語意驗證 region 數量、文字樣本、主要 action、表單欄位與 DOM/accessibility evidence 是否一致 |
| F4-7 | 產出人工可讀的 `ui-contract.md` 摘要,讓使用者可在實作前 review prototype contract |
**產出物:** 完整的 `LayoutSpec` JSON(儲存至 `.ht/spec/{filename}.spec.json`+ `ValidationReport` + `ui-contract.md`
---
### Stage 5Plan
**目的:**`LayoutSpec` 拆解為 agent 可逐一執行的 `TaskList`,讓 Stage 6 的執行單元保持小而可控。
| 需求 ID | 說明 |
|---------|------|
| F5-1 | 讀取 `LayoutSpec``frontend/` 的現有元件、`backend/` 的 API schema,產出 `TaskList` |
| F5-2 | 每個 Task 對應一個 page 或 component,包含:`id``type``targetFile``specReference``apiDependencies` |
| F5-3 | Task 之間的依賴關係以 `dependsOn` 陣列表示,Orchestrator 依此決定執行順序 |
| F5-4 | `ht.config.ts``plan.interactiveReview: true` 時,產出 TaskList 後暫停,輸出預覽並等待使用者輸入 `y` 繼續 |
| F5-5 | `TaskList` 儲存至 `.ht/plan/tasklist.json`,支援手動編輯後重新執行 Stage 6 |
| F5-6 | 掃描 `frontend/` 產出 `project-conventions.md`,至少包含 routing、Vuetify 使用方式、表單封裝、API client/composable、Pinia、validation、i18n、測試慣例 |
| F5-7 | 掃描 `backend/` 產出 `api-contract.md`;若存在 OpenAPI 優先使用,否則從 TypeScript interface、controller、route 或 markdown 抽象 endpoint contract |
| F5-8 | 產出 `component-mapping.json`,描述 prototype region/section 對應 Vuetify component 或既有自訂 wrapper 的映射 |
| F5-9 | `plan.requireApiContractMatch: true` 時,Task 不得引用 `api-contract.md` 未列出的 endpoint;缺口須產出 `contractProposal` 並標記需人工確認 |
**TaskList 結構(摘要):**
```typescript
interface Task {
id: string
type: 'page' | 'component' | 'api-integration'
targetFile: string // 相對於 output/ 的路徑
specReference: string // 對應 LayoutSpec 的 regionId
apiDependencies: string[] // backend API endpoint 清單
inputArtifacts: string[] // spec、contract、conventions、mapping 等 artifact 路徑
acceptanceChecks: string[] // 此 task 完成後必須通過的檢查
dependsOn: string[] // 其他 task id
status: 'pending' | 'running' | 'done' | 'error'
}
```
**產出物:** `.ht/plan/tasklist.json``.ht/plan/project-conventions.md``.ht/plan/api-contract.md``.ht/plan/component-mapping.json`
---
### Stage 6Run
**目的:** 讓 coding agent 依序執行 TaskList,將 Vuetify 元件與 API 整合寫入 `output/`
| 需求 ID | 說明 |
|---------|------|
| F6-1 | Orchestrator 依 `dependsOn` 拓撲排序,逐一將 task 交給 agent 執行 |
| F6-2 | 每個 task 的 prompt context 包含:對應的 `RegionSpec``ui-contract.md` 摘要、`project-conventions.md``component-mapping.json``targetFile` 的現有內容(若存在)、相關 API contract |
| F6-3 | Agent 的輸出直接寫入 `output/{targetFile}`;寫入前備份至 `.ht/backup/` |
| F6-4 | Task 執行失敗時標記 `status: 'error'`,記錄錯誤訊息,繼續執行無依賴的後續 task |
| F6-5 | 支援 `ht run --retry-failed` 指令,只重新執行 `status: 'error'` 的 task |
| F6-6 | 每個 task 完成後更新 `.ht/plan/tasklist.json``status` 欄位 |
| F6-7 | Agent invocation 必須限制 `allowedPaths`,預設只能寫入 `output/``.ht/`;不得修改 `prototype/``frontend/``backend/` 原始輸入資料夾 |
| F6-8 | 每次 agent raw output、changed files、exit code、stderr/stdout summary 寫入 `.ht/runs/{runId}/agent/` |
**產出物:** `output/` 資料夾中的 Vuetify 元件與整合程式碼
---
### Stage 7Diff & Verify
**目的:** 量化 `output/` 的結果與 prototype 的視覺相似度,並以 deterministic checks 優先驗證實作品質,讓工程師能快速判斷生成結果是否可交付。
| 需求 ID | 說明 |
|---------|------|
| F7-1 | 對 `output/` 中每個頁面以相同 viewport 截圖 |
| F7-2 | 將 output 截圖與 Stage 1 的 prototype 截圖交給 agent 評分(0–100),判斷視覺相似度 |
| F7-3 | 產出靜態 HTML report,每行並排顯示:prototype 截圖、output 截圖、similarity score |
| F7-4 | Score 低於 `ht.config.ts``diff.scoreThreshold`(預設 75)的項目以警告色標示 |
| F7-5 | Report 儲存至 `.ht/diff/{filename}.diff.html`,執行完畢後在 CLI 輸出 report 路徑 |
| F7-6 | 執行 `project.qualityCommands` 中設定的 typecheck、lint、test;缺少指令時記錄為 `skipped` 並說明原因 |
| F7-7 | 執行 DOM-level checks:主要文字、form label、table/list item、primary/secondary action 是否存在且可互動 |
| F7-8 | 執行 flow-level checks:可由 `LayoutSpec.interactions` 推導的 drawer、tab、modal、form submit、navigation 等互動 smoke test |
| F7-9 | 採 deterministic first, agent fallback second:固定檢查失敗時才呼叫 agent 協助分析原因,不讓 agent 取代可重跑驗證 |
| F7-10 | 產出 `VerificationReport`,彙整 quality commands、DOM checks、flow checks、visual diff 與人工需處理項目 |
**產出物:** `.ht/diff/` 下的靜態 HTML report + `.ht/verify/verification-report.json`
---
## 5. CLI 指令設計
| 指令 | 說明 |
|------|------|
| `ht scan` | 執行 Stage 14Capture → Validate),產出 `LayoutSpec` |
| `ht plan` | 執行 Stage 5,產出 `TaskList``interactiveReview: true` 時暫停等待確認 |
| `ht run` | 執行 Stage 6,依 `TaskList` 驅動 agent 寫入 `output/` |
| `ht run --retry-failed` | 只重新執行 `status: 'error'` 的 task |
| `ht diff` | 執行 Stage 7 的 visual diff,產出 similarity report |
| `ht verify` | 執行 Stage 7 的 quality commands、DOM checks 與 flow checks |
| `ht go` | 依序執行全部 stagescan → plan → run → diff → verify |
| `ht status` | 顯示目前 `TaskList` 的執行狀態摘要 |
| `ht doctor` | 檢查 pnpm workspace、Playwright、agent CLI、Vuetify component whitelist、quality commands 是否可用 |
---
## 6. 資料夾結構
```
monorepo/
├── ht.config.ts
├── packages/
│ ├── prototype/ ← 輸入:HTML prototype
│ ├── web/ ← 輸入:Vuetify 前端專案(預先建立)
│ ├── api/ ← 輸入:Backend API(預先建立)
│ └── result/ ← 輸出:生成結果
└── .ht/ ← 工具產出的所有中間產物與 cache
├── cache/prototype/ ← Stage 1 截圖
├── spec/ ← Stage 4 LayoutSpec JSON
├── plan/ ← Stage 5 TaskList JSON
├── runs/ ← 每次 run 的狀態、log、agent raw output、artifact 索引
├── backup/ ← Stage 6 寫入前的備份
├── verify/ ← Stage 7 verification report
└── diff/ ← Stage 7 similarity report
```
**Run artifact 結構:**
```
.ht/runs/
└── 2026-05-02-001/
├── run.json
├── screenshots/
├── dom-snapshots/
├── agent/
├── logs/
└── artifact-index.json
```
---
## 7. 非功能需求
| 類別 | 需求 |
|------|------|
| 效能 | 單一 HTML 頁面的 Stage 14 完整執行不超過 60 秒 |
| 可復現性 | Cache 命中時跳過截圖與 extract,相同輸入產出相同結果 |
| 可觀測性 | 每個 stage 完成後輸出 log,記錄耗時、agent call 次數、cache 命中狀態 |
| 錯誤隔離 | 單一 task 或 region 失敗不中斷整體流程,標記後繼續執行 |
| 可介入性 | `TaskList` 支援手動編輯;`interactiveReview` 模式允許人工在 Plan 後介入 |
| 可追溯性 | 每個輸出檔案都能追溯到 input artifact、agent invocation、task id 與 run id |
| 邊界控制 | Agent 預設只可寫入 `output/``.ht/`,所有輸入資料夾視為 read-only |
---
## 8. 風險與假設
| 風險 | 程度 | 緩解策略 |
|------|------|---------|
| Vision 分析精度不足,`null` 欄位過多 | 高 | Spike 驗證後調整 prompt;人工補充 spec |
| Prototype 引用外部資源,截圖不完整 | 中 | Local static server + `networkidle` 等待 |
| Agent 生成的程式碼不符合既有前端專案的規範 | 中 | Prompt context 包含現有元件與 coding styletask 單元保持小 |
| Backend API 不足以支援 prototype 行為 | 中 | Stage 5 產出 `contractProposal` 並要求人工確認,不讓 agent 自行發明 endpoint |
| 只靠 screenshot similarity 造成假陽性 | 中 | Stage 7 加入 DOM-level、flow-level 與 project quality checks |
| Vuetify component 白名單不完整,自動修正失敗 | 低 | 預先整理 Vuetify 3 全量 component 清單 |
**核心假設:**
- Prototype 在現代瀏覽器中能正確渲染
- `frontend/` 專案已有基本的 Vuetify 3 設定與 project structure
- `backend/` 專案有可讀的 API schemaOpenAPI 或 TypeScript interface
- 第一版 MVP 聚焦單一 HTML prototype → 單一 Vue page/component → 既有 API client → `output/` 結果,不追求一開始支援所有 monorepo 形態
---
## 9. 開發里程碑
| 里程碑 | 涵蓋 Stage | 完成條件 |
|--------|-----------|---------|
| M0Spike | Stage 13(手動) | 手動截圖 + 手動 prompt,驗證 `null` 欄位 < 30% |
| M1Vision Pipeline | Stage 14 | `ht scan` 可對一個 HTML 頁面產出完整 `LayoutSpec`、DOM snapshot 與 `ui-contract.md` |
| M2Plan | Stage 5 | `ht plan` 可產出 `TaskList``project-conventions.md``api-contract.md``component-mapping.json`interactive review 可運作 |
| M3Run | Stage 6 | `ht run` 可驅動 agent 對一個頁面完整執行並寫入 `output/` |
| M4Diff & Verify | Stage 7 | `ht diff` 可產出 similarity report`ht verify` 可執行 quality、DOM、flow checks |
| M5:整合 | 全部 | `ht go` 全流程可在真實 monorepo 中穩定執行 |
+290
View File
@@ -0,0 +1,290 @@
## A prototype-driven orchestration framework for CLI coding agents in TypeScript monorepos
- **prototype-driven**:不是純 spec,也不是純 code-first。
- **orchestration framework**:主體是 workflow 與 gates,不是模型本身。
- **CLI coding agents**Claude Code、Codex、Gemini 只是 adapter targets,不是核心。
## MVP
這個版本提供一個可執行的 `ht` CLI 骨架,依照 PRD 的 stage 產出可 review 的中間 artifact
- `ht scan`:讀取 `prototype/` HTML,產生 `.ht/cache/prototype/``.ht/spec/*.spec.json``ui-contract.md`
- `ht plan`:掃描 frontend/backend,產生 `.ht/plan/tasklist.json`、conventions、API contract、component mapping
- `ht run`:依 TaskList 產生 Vuetify `.vue` 檔到 `output/`
- `ht diff`:產生 `.ht/diff/*.diff.html`
- `ht verify`:執行 quality commands 並產生 `.ht/verify/verification-report.json`
- `ht go --yes`:串起完整流程
外部 vision/agent/Playwright 尚未假裝成已整合;目前以 deterministic HTML evidence 建立 MVP artifact,缺少的執行環境會在 report 或 `ht doctor` 中明確標示。
## 使用方式
### 1. 確認 Node 版本
需要 Node.js 20 以上。
```bash
node --version
```
### 2. 安裝專案相依套件
建議使用專案級安裝,不依賴全域 `playwright-cli`
```bash
pnpm install
```
本專案使用 `@playwright/cli`,指令名稱是 `playwright-cli`。安裝後可以用:
```bash
npx --no-install playwright-cli --version
```
或透過 npm script
```bash
pnpm playwright-cli -- --version
```
若環境還沒有瀏覽器 binary,先安裝預設瀏覽器:
```bash
pnpm playwright-cli:install-browser
```
Linux CI 或乾淨容器可能還需要系統套件,先用 dry-run 看會做什麼:
```bash
npx --no-install playwright-cli install-browser --with-deps --dry-run
```
### 3. 準備資料夾
預設設定會讀取以下路徑:
```text
packages/
├── prototype/ # 放 HTML prototype
├── web/ # 既有 Vuetify frontend 專案
├── api/ # backend API 或 schema
└── result/ # 產生結果
```
最小可跑範例:
```bash
mkdir -p packages/prototype packages/web/src packages/api packages/result
```
新增一個 prototype
```html
<!-- packages/prototype/index.html -->
<main>
<h1>Customer Portal</h1>
<form>
<label>Email</label>
<input name="email" required>
<button>Submit</button>
</form>
</main>
```
### 4. 選擇性建立設定檔
沒有 `ht.config.js` 時會使用預設值。若要覆寫路徑或關閉互動確認,可以建立:
```js
// ht.config.js
export default {
prototype: './packages/prototype',
frontend: './packages/web',
backend: './packages/api',
output: './packages/result',
agent: 'codex',
plan: {
interactiveReview: false,
requireApiContractMatch: true
},
project: {
qualityCommands: {
typecheck: 'pnpm typecheck',
lint: 'pnpm lint',
test: 'pnpm test'
}
}
}
```
也可以使用 `ht.config.ts`
```ts
import { defineConfig } from 'html-transform'
export default defineConfig({
prototype: './packages/prototype',
frontend: './packages/web',
backend: './packages/api',
output: './packages/result'
})
```
### 5. 檢查環境
```bash
node src/cli.js doctor
```
`doctor` 會檢查 prototype/frontend/backend/output parent、playwright-cli、agent CLI、pnpm 是否存在。缺少項目會標示為 `missing`
`playwright-cli` 的偵測順序:
1. `node_modules/.bin/playwright-cli`
2. 全域 `playwright-cli`
3. `npx --no-install playwright-cli`
### 6. 執行 scan
```bash
node src/cli.js scan
```
產出:
```text
.ht/cache/prototype/
.ht/spec/*.spec.json
.ht/spec/*.validation.json
.ht/spec/*.ui-contract.md
```
這一步會從 HTML 擷取 DOM summary、accessibility-like tree、region、LayoutSpec 與 UI contract。MVP 目前使用 deterministic HTML evidence;截圖檔是 placeholder PNG。
後續接真實 browser automation 時,會透過 `playwright-cli` shell 指令操作,不透過 MCP。
### 7. 產生 TaskList
```bash
node src/cli.js plan
```
產出:
```text
.ht/plan/tasklist.json
.ht/plan/project-conventions.md
.ht/plan/api-contract.md
.ht/plan/component-mapping.json
```
`plan.interactiveReview` 設為 `true`CLI 會在產生 TaskList 後要求輸入 `y` 才繼續。
### 8. 產生 output
```bash
node src/cli.js run
```
產出會寫入 `output` 設定的資料夾,例如:
```text
packages/result/index/main-1.vue
```
每個 task 的執行記錄會寫入:
```text
.ht/runs/<runId>/
```
若只想重跑失敗 task
```bash
node src/cli.js run --retry-failed
```
### 9. 產生 diff report
```bash
node src/cli.js diff
```
產出:
```text
.ht/diff/*.diff.html
```
MVP 目前會建立可 review 的 HTML report 結構;真實 `playwright-cli` 截圖與 vision similarity scoring 尚未串接。
### 10. 執行驗證
```bash
node src/cli.js verify
```
產出:
```text
.ht/verify/verification-report.json
```
驗證包含:
- `project.qualityCommands` 中設定的 typecheck/lint/test
- output target 是否存在
- flow checks 目前在 MVP 中會標示為 skipped
### 11. 一次跑完整流程
```bash
node src/cli.js go --yes
```
等同依序執行:
```bash
node src/cli.js scan
node src/cli.js plan
node src/cli.js run
node src/cli.js diff
node src/cli.js verify
```
### 12. 查看 task 狀態
```bash
node src/cli.js status
```
會顯示 `pending``running``done``error` 的 task 數量。
## 開發驗證
```bash
pnpm test
pnpm typecheck
```
## 目前 MVP 限制
- 尚未整合 Playwright static server、真實 full-page screenshot、DOM snapshot API 與 accessibility tree API。
- Playwright 操作會以 `playwright-cli` shell command 為準,不使用 MCP;目前已加入專案級 `@playwright/cli` 安裝與環境偵測。
- 尚未真的呼叫 Claude Code/Codex/Gemini 進行 vision decomposition 或 coding task。
- `diff` 的 similarity score 目前是 deterministic report placeholder,不是 vision scoring。
- `run` 目前以 LayoutSpec 產生基本 Vuetify Vue component,還不是完整專案級整合。
- 缺少 quality command 或外部工具時,會在 report 或 `doctor` 中標示為 skipped/missing。
## 常用指令摘要
```bash
pnpm test
pnpm typecheck
node src/cli.js doctor
node src/cli.js scan
node src/cli.js plan
node src/cli.js run
node src/cli.js diff
node src/cli.js verify
```
+25
View File
@@ -0,0 +1,25 @@
{
"name": "html-transform",
"version": "0.1.0",
"description": "Prototype-driven orchestration framework for transforming HTML prototypes into Vuetify implementations.",
"packageManager": "pnpm@10.10.0",
"type": "module",
"bin": {
"ht": "./src/cli.js"
},
"exports": {
".": "./src/index.js"
},
"scripts": {
"test": "node --test",
"typecheck": "node --check src/*.js src/**/*.js",
"playwright-cli": "playwright-cli",
"playwright-cli:install-browser": "playwright-cli install-browser"
},
"devDependencies": {
"@playwright/cli": "^0.1.8"
},
"engines": {
"node": ">=20"
}
}
+52
View File
@@ -0,0 +1,52 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@playwright/cli':
specifier: ^0.1.8
version: 0.1.11
packages:
'@playwright/cli@0.1.11':
resolution: {integrity: sha512-nz6xPChoijBsxZTSukJFBCCwLozsp7uwBLYrlCrmRU6MXc2a+mpaaLApqBxMfVFjgbP4pYY/3uQ/NnpD+aicxw==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
playwright-core@1.60.0-alpha-1777669338000:
resolution: {integrity: sha512-CbBF+YtjGGK2mWKdH6iiSNIB9v9Sq8owcUrTy1cw6FgMZavspM6ZSpidQQgQXz/1scQFrM110jrtJqTYwnjbeA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.60.0-alpha-1777669338000:
resolution: {integrity: sha512-r5G2ZvpIJZHj53GKmUfoQiXHix5bjeObqysV4flfmFNykTjebI9bwdRBuW37pOQP25yjbArkxlsnT5APaWT8zg==}
engines: {node: '>=18'}
hasBin: true
snapshots:
'@playwright/cli@0.1.11':
dependencies:
playwright: 1.60.0-alpha-1777669338000
fsevents@2.3.2:
optional: true
playwright-core@1.60.0-alpha-1777669338000: {}
playwright@1.60.0-alpha-1777669338000:
dependencies:
playwright-core: 1.60.0-alpha-1777669338000
optionalDependencies:
fsevents: 2.3.2
Executable
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { scan } from './stages/scan.js'
import { plan } from './stages/plan.js'
import { runTasks } from './stages/run.js'
import { diff } from './stages/diff.js'
import { verify } from './stages/verify.js'
import { status } from './stages/status.js'
import { doctor } from './stages/doctor.js'
const command = process.argv[2] ?? 'help'
const flags = new Set(process.argv.slice(3))
async function main() {
if (command === 'scan') await scan()
else if (command === 'plan') await plan()
else if (command === 'run') await runTasks({ retryFailed: flags.has('--retry-failed') })
else if (command === 'diff') await diff()
else if (command === 'verify') await verify()
else if (command === 'go') {
await scan()
await plan({ assumeYes: flags.has('--yes') })
await runTasks({ retryFailed: false })
await diff()
await verify()
} else if (command === 'status') await status()
else if (command === 'doctor') await doctor()
else printHelp()
}
function printHelp() {
console.log(`Usage: ht <command>
Commands:
scan Capture and extract LayoutSpec artifacts
plan Build TaskList and project/API contracts
run Execute pending tasks into output/
run --retry-failed
diff Build visual similarity report
verify Run quality, DOM, and flow checks
go [--yes] Run scan, plan, run, diff, verify
status Show TaskList summary
doctor Check local prerequisites`)
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+4
View File
@@ -0,0 +1,4 @@
export function defineConfig(config) {
return config
}
+20
View File
@@ -0,0 +1,20 @@
import { spawn } from 'node:child_process'
const commands = {
codex: ['codex'],
'claude-code': ['claude'],
gemini: ['gemini']
}
export function resolveAgentCommand(agent) {
return commands[agent] ?? [agent]
}
export async function commandExists(command) {
return new Promise((resolve) => {
const child = spawn('which', [command], { stdio: 'ignore' })
child.on('close', (code) => resolve(code === 0))
child.on('error', () => resolve(false))
})
}
+91
View File
@@ -0,0 +1,91 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { defineConfig } from '../index.js'
const defaultConfig = {
prototype: './packages/prototype',
frontend: './packages/web',
backend: './packages/api',
output: './packages/result',
agent: 'codex',
vision: {
viewport: { width: 1440, height: 900 },
captureStates: ['default'],
includeDomSnapshot: true,
includeAccessibilityTree: true
},
project: {
packageManager: 'pnpm',
frontendFramework: 'vue3',
uiLibrary: 'vuetify3',
qualityCommands: {
typecheck: 'pnpm typecheck',
lint: 'pnpm lint',
test: 'pnpm test'
}
},
plan: {
interactiveReview: false,
requireApiContractMatch: true
},
diff: {
scoreThreshold: 75,
requireDomChecks: true,
requireFlowChecks: true
}
}
export async function loadConfig(cwd = process.cwd()) {
const candidates = ['ht.config.mjs', 'ht.config.js', 'ht.config.json', 'ht.config.ts']
for (const filename of candidates) {
const file = resolve(cwd, filename)
if (!existsSync(file)) continue
const loaded = await loadConfigFile(file)
return normalizeConfig({ ...defaultConfig, ...loaded }, cwd)
}
return normalizeConfig(defaultConfig, cwd)
}
async function loadConfigFile(file) {
if (file.endsWith('.json')) return JSON.parse(readFileSync(file, 'utf8'))
if (file.endsWith('.ts')) return loadTsConfig(file)
const module = await import(`${pathToFileURL(file).href}?t=${Date.now()}`)
return module.default ?? module
}
function loadTsConfig(file) {
let source = readFileSync(file, 'utf8')
source = source.replace(/import\s+\{?\s*defineConfig\s*\}?\s+from\s+['"][^'"]+['"];?/g, '')
source = source.replace(/export\s+default\s+defineConfig\s*\(/, 'return defineConfig(')
source = source.replace(/export\s+default\s+/, 'return ')
return Function('defineConfig', source)(defineConfig)
}
function normalizeConfig(config, cwd) {
const merged = {
...defaultConfig,
...config,
vision: { ...defaultConfig.vision, ...config.vision },
project: {
...defaultConfig.project,
...config.project,
qualityCommands: {
...defaultConfig.project.qualityCommands,
...config.project?.qualityCommands
}
},
plan: { ...defaultConfig.plan, ...config.plan },
diff: { ...defaultConfig.diff, ...config.diff }
}
return {
...merged,
cwd,
prototypeDir: resolve(cwd, merged.prototype),
frontendDir: resolve(cwd, merged.frontend),
backendDir: resolve(cwd, merged.backend),
outputDir: resolve(cwd, merged.output),
htDir: resolve(cwd, '.ht')
}
}
+54
View File
@@ -0,0 +1,54 @@
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { dirname, extname, join, relative } from 'node:path'
export async function ensureDir(path) {
await mkdir(path, { recursive: true })
}
export async function writeJson(path, data) {
await ensureDir(dirname(path))
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`)
}
export async function readJson(path) {
return JSON.parse(await readFile(path, 'utf8'))
}
export async function sha256File(path) {
const content = await readFile(path)
return createHash('sha256').update(content).digest('hex')
}
export async function listFiles(root, extensions = []) {
const output = []
async function walk(dir) {
let entries = []
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isDirectory()) await walk(path)
else if (extensions.length === 0 || extensions.includes(extname(entry.name))) output.push(path)
}
}
await walk(root)
return output.sort()
}
export async function exists(path) {
try {
await stat(path)
return true
} catch {
return false
}
}
export function artifactPath(root, file) {
return relative(root, file).replaceAll('\\', '/').replace(/\.html$/i, '')
}
+118
View File
@@ -0,0 +1,118 @@
export function summarizeHtml(html) {
const title = matchText(html, /<title[^>]*>([\s\S]*?)<\/title>/i)
const headings = collect(html, /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi).map((item) => cleanText(item[2]))
const buttons = collect(html, /<(button|a)\b[^>]*>([\s\S]*?)<\/\1>/gi)
.map((item) => cleanText(item[2]))
.filter(Boolean)
const labels = collect(html, /<label\b[^>]*>([\s\S]*?)<\/label>/gi).map((item) => cleanText(item[1]))
const inputs = collect(html, /<(input|select|textarea)\b([^>]*)>/gi).map((item) => ({
tag: item[1].toLowerCase(),
type: attr(item[2], 'type') ?? null,
name: attr(item[2], 'name') ?? attr(item[2], 'id') ?? null,
required: /\brequired\b/i.test(item[2])
}))
const textSamples = [...headings, ...labels, ...buttons, ...visibleText(html)].filter(Boolean).slice(0, 3)
return { title, headings, buttons, labels, inputs, textSamples }
}
export function extractRegions(html) {
const landmarks = [
['header', /<header\b[^>]*>([\s\S]*?)<\/header>/gi, 'banner'],
['nav', /<nav\b[^>]*>([\s\S]*?)<\/nav>/gi, 'navigation'],
['main', /<main\b[^>]*>([\s\S]*?)<\/main>/gi, 'main'],
['section', /<section\b[^>]*>([\s\S]*?)<\/section>/gi, 'region'],
['footer', /<footer\b[^>]*>([\s\S]*?)<\/footer>/gi, 'contentinfo']
]
const regions = []
for (const [tag, pattern, role] of landmarks) {
for (const item of collect(html, pattern)) {
const text = visibleText(item[1])
regions.push({
id: `${tag}-${regions.length + 1}`,
name: text[0] ?? tag,
rough_position: null,
element_count_estimate: Math.max(1, text.length),
semantic_role: role,
html: item[1]
})
}
}
if (regions.length === 0) {
regions.push({
id: 'page-1',
name: summarizeHtml(html).title ?? 'Page',
rough_position: null,
element_count_estimate: visibleText(html).length,
semantic_role: 'main',
html
})
}
return regions
}
export function visibleText(html) {
const withoutScripts = html
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
return withoutScripts
.split(/\s+/)
.map((text) => text.trim())
.filter((text) => text.length > 1)
.slice(0, 50)
}
export function inferRegionSpec(region) {
const summary = summarizeHtml(region.html)
const hasForm = /<(form|input|select|textarea)\b/i.test(region.html)
const hasTable = /<table\b/i.test(region.html)
const hasList = /<(ul|ol)\b/i.test(region.html)
const vuetifyComponent = hasForm ? 'VForm' : hasTable ? 'VTable' : hasList ? 'VList' : region.semantic_role === 'navigation' ? 'VNavigationDrawer' : 'VContainer'
return {
regionId: region.id,
name: region.name,
semanticRole: region.semantic_role,
vuetifyComponent,
layout: {
display: null,
spacing: null,
alignment: null
},
colorPalette: extractColors(region.html),
textSamples: summary.textSamples,
uiContract: {
labels: summary.labels,
fields: summary.inputs,
primaryActions: summary.buttons.slice(0, 1),
secondaryActions: summary.buttons.slice(1),
interactions: summary.buttons.map((label) => ({ label, type: 'click', observableResult: null }))
},
warnings: []
}
}
function extractColors(html) {
return [...new Set((html.match(/#[0-9a-f]{3,8}\b|rgba?\([^)]+\)/gi) ?? []).slice(0, 12))]
}
function attr(attrs, name) {
const match = attrs.match(new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, 'i'))
return match?.[1] ?? null
}
function matchText(source, pattern) {
const match = source.match(pattern)
return match ? cleanText(match[1]) : null
}
function collect(source, pattern) {
return [...source.matchAll(pattern)]
}
function cleanText(text) {
return text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
+23
View File
@@ -0,0 +1,23 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { exists } from './files.js'
import { commandExists } from './agent.js'
export async function resolvePlaywrightCli(cwd = process.cwd()) {
const localBin = join(cwd, 'node_modules/.bin/playwright-cli')
if (await exists(localBin)) return { command: localBin, args: [], source: 'local' }
if (await commandExists('playwright-cli')) return { command: 'playwright-cli', args: [], source: 'global' }
if (await commandExists('npx')) return { command: 'npx', args: ['--no-install', 'playwright-cli'], source: 'npx-local' }
return null
}
export async function playwrightCliAvailable(cwd = process.cwd()) {
const resolved = await resolvePlaywrightCli(cwd)
if (!resolved) return false
return new Promise((resolve) => {
const child = spawn(resolved.command, [...resolved.args, '--version'], { cwd, stdio: 'ignore' })
child.on('close', (code) => resolve(code === 0))
child.on('error', () => resolve(false))
})
}
+47
View File
@@ -0,0 +1,47 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { ensureDir, listFiles, readJson } from '../lib/files.js'
export async function diff() {
const config = await loadConfig()
const specs = await listFiles(join(config.htDir, 'spec'), ['.json'])
if (specs.length === 0) throw new Error('找不到 spec,請先執行 ht scan')
await ensureDir(join(config.htDir, 'diff'))
for (const file of specs.filter((file) => file.endsWith('.spec.json'))) {
const spec = await readJson(file)
const reportPath = join(config.htDir, 'diff', `${spec.page}.diff.html`)
await writeFile(reportPath, renderDiffReport(config, spec))
console.log(`diff report: ${reportPath}`)
}
}
function renderDiffReport(config, spec) {
const rows = spec.decomposition.map((region) => {
const score = 75
const warning = score < config.diff.scoreThreshold ? ' class="warning"' : ''
return `<tr${warning}><td>${region.id}</td><td>${region.name}</td><td>${score}</td><td>${region.screenshotPath}</td></tr>`
}).join('\n')
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${spec.page} diff</title>
<style>
body { font-family: system-ui, sans-serif; margin: 24px; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ddd; padding: 8px; text-align: left; }
.warning { background: #fff3cd; }
</style>
</head>
<body>
<h1>${spec.page} Diff</h1>
<table>
<thead><tr><th>Region</th><th>Name</th><th>Similarity</th><th>Prototype Screenshot</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</body>
</html>
`
}
+23
View File
@@ -0,0 +1,23 @@
import { join } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { commandExists, resolveAgentCommand } from '../lib/agent.js'
import { exists } from '../lib/files.js'
import { playwrightCliAvailable, resolvePlaywrightCli } from '../lib/playwright-cli.js'
export async function doctor() {
const config = await loadConfig()
const agentCommand = resolveAgentCommand(config.agent)[0]
const playwrightCli = await resolvePlaywrightCli(config.cwd)
const checks = [
['prototype directory', await exists(config.prototypeDir)],
['frontend directory', await exists(config.frontendDir)],
['backend directory', await exists(config.backendDir)],
['output directory parent', await exists(join(config.outputDir, '..'))],
[`playwright-cli${playwrightCli ? ` (${playwrightCli.source})` : ''}`, await playwrightCliAvailable(config.cwd)],
[`agent ${config.agent}`, await commandExists(agentCommand)],
['pnpm', await commandExists('pnpm')]
]
for (const [name, ok] of checks) {
console.log(`${ok ? 'ok' : 'missing'} ${name}`)
}
}
+120
View File
@@ -0,0 +1,120 @@
import { createInterface } from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
import { basename, join } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { listFiles, readJson, writeJson } from '../lib/files.js'
export async function plan(options = {}) {
const config = await loadConfig()
const specs = await listFiles(join(config.htDir, 'spec'), ['.json'])
const layoutSpecs = []
for (const file of specs.filter((file) => file.endsWith('.spec.json'))) {
layoutSpecs.push(await readJson(file))
}
if (layoutSpecs.length === 0) throw new Error('找不到 LayoutSpec,請先執行 ht scan')
const conventions = await buildProjectConventions(config)
const apiContract = await buildApiContract(config)
const mapping = buildComponentMapping(layoutSpecs)
const taskList = buildTaskList(config, layoutSpecs, apiContract)
await writeJson(join(config.htDir, 'plan/component-mapping.json'), mapping)
await writeJson(join(config.htDir, 'plan/tasklist.json'), taskList)
await writeMarkdown(join(config.htDir, 'plan/project-conventions.md'), conventions)
await writeMarkdown(join(config.htDir, 'plan/api-contract.md'), apiContract.markdown)
if (config.plan.interactiveReview && !options.assumeYes) {
console.log(renderPreview(taskList))
const rl = createInterface({ input, output })
const answer = await rl.question('繼續執行 Stage 6?輸入 y 繼續:')
rl.close()
if (answer.trim().toLowerCase() !== 'y') {
console.log('plan 已產出,等待手動 review')
return
}
}
console.log(`plan 完成:${taskList.tasks.length} 個 task`)
}
async function buildProjectConventions(config) {
const files = await listFiles(config.frontendDir, ['.vue', '.js', '.ts'])
const sample = files.slice(0, 20).map((file) => `- ${file.replace(config.frontendDir, '')}`).join('\n') || '- frontend directory missing or empty'
return `# Project Conventions
- Routing: ${files.some((file) => /router/i.test(file)) ? 'router files detected' : 'not detected'}
- Vuetify: ${files.some((file) => /vuetify/i.test(file)) ? 'Vuetify usage detected' : 'not detected'}
- Forms: ${files.some((file) => /form/i.test(file)) ? 'form files detected' : 'not detected'}
- API client/composable: ${files.some((file) => /(api|composable|service)/i.test(file)) ? 'detected' : 'not detected'}
- Pinia: ${files.some((file) => /pinia|store/i.test(file)) ? 'detected' : 'not detected'}
- Validation: ${files.some((file) => /valid/i.test(file)) ? 'detected' : 'not detected'}
- i18n: ${files.some((file) => /i18n|locale/i.test(file)) ? 'detected' : 'not detected'}
- Tests: ${files.some((file) => /\.(test|spec)\./i.test(file)) ? 'detected' : 'not detected'}
## Sample Files
${sample}
`
}
async function buildApiContract(config) {
const files = await listFiles(config.backendDir, ['.json', '.yaml', '.yml', '.ts', '.js', '.md'])
const endpoints = []
for (const file of files) {
const match = file.match(/openapi|swagger/i)
if (match) endpoints.push({ method: 'OPENAPI', path: file.replace(config.backendDir, ''), source: file })
}
return {
endpoints,
markdown: `# API Contract
${endpoints.length ? endpoints.map((endpoint) => `- ${endpoint.method} ${endpoint.path}`).join('\n') : '- No API contract detected'}
`
}
}
function buildComponentMapping(layoutSpecs) {
return Object.fromEntries(
layoutSpecs.flatMap((spec) =>
spec.regions.map((region) => [
region.regionId,
{ page: spec.page, vuetifyComponent: region.vuetifyComponent, targetKind: 'component' }
])
)
)
}
function buildTaskList(config, layoutSpecs, apiContract) {
const tasks = layoutSpecs.flatMap((spec) =>
spec.regions.map((region, index) => ({
id: `${spec.page.replace(/\W+/g, '-').replace(/-$/, '')}-${region.regionId}`,
type: index === 0 ? 'page' : 'component',
targetFile: `${basename(spec.page, '.html')}/${region.regionId}.vue`,
specReference: region.regionId,
apiDependencies: [],
inputArtifacts: [
`.ht/spec/${basename(spec.page, '.html')}.spec.json`,
'.ht/plan/project-conventions.md',
'.ht/plan/api-contract.md',
'.ht/plan/component-mapping.json'
],
acceptanceChecks: ['target file exists', 'contains visible text samples'],
dependsOn: [],
status: 'pending',
contractProposal: config.plan.requireApiContractMatch && apiContract.endpoints.length === 0 ? [] : undefined
}))
)
return { version: 1, generatedAt: new Date().toISOString(), tasks }
}
async function writeMarkdown(path, content) {
const { ensureDir } = await import('../lib/files.js')
const { dirname } = await import('node:path')
const { writeFile } = await import('node:fs/promises')
await ensureDir(dirname(path))
await writeFile(path, content)
}
function renderPreview(taskList) {
return taskList.tasks.map((task) => `${task.id} -> ${task.targetFile}`).join('\n')
}
+97
View File
@@ -0,0 +1,97 @@
import { copyFile, readFile, writeFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { ensureDir, exists, listFiles, readJson, writeJson } from '../lib/files.js'
export async function runTasks(options = {}) {
const config = await loadConfig()
const taskListPath = join(config.htDir, 'plan/tasklist.json')
if (!(await exists(taskListPath))) throw new Error('找不到 TaskList,請先執行 ht plan')
const taskList = await readJson(taskListPath)
const runId = new Date().toISOString().replaceAll(':', '-').replace(/\..+/, '')
const runDir = join(config.htDir, 'runs', runId)
await ensureDir(join(runDir, 'agent'))
const specs = await loadSpecs(config)
for (const task of topoSort(taskList.tasks)) {
if (options.retryFailed ? task.status !== 'error' : task.status !== 'pending') continue
task.status = 'running'
await writeJson(taskListPath, taskList)
try {
const spec = findRegionSpec(specs, task.specReference)
const targetPath = join(config.outputDir, task.targetFile)
await backupIfExists(config, targetPath, task.id)
await ensureDir(dirname(targetPath))
await writeFile(targetPath, renderVueComponent(spec))
task.status = 'done'
await writeJson(join(runDir, 'agent', `${task.id}.json`), {
taskId: task.id,
changedFiles: [targetPath],
exitCode: 0,
stdoutSummary: 'deterministic renderer wrote Vue component',
stderrSummary: ''
})
} catch (error) {
task.status = 'error'
task.error = error instanceof Error ? error.message : String(error)
}
await writeJson(taskListPath, taskList)
}
await writeJson(join(runDir, 'run.json'), { runId, startedAt: runId, taskCount: taskList.tasks.length })
console.log(`run 完成:${runDir}`)
}
async function loadSpecs(config) {
const files = await listFiles(join(config.htDir, 'spec'), ['.json'])
const specs = []
for (const file of files.filter((file) => file.endsWith('.spec.json'))) specs.push(await readJson(file))
return specs
}
function findRegionSpec(specs, regionId) {
for (const spec of specs) {
const region = spec.regions.find((region) => region.regionId === regionId)
if (region) return region
}
throw new Error(`找不到 RegionSpec${regionId}`)
}
async function backupIfExists(config, targetPath, taskId) {
if (!(await exists(targetPath))) return
const backupPath = join(config.htDir, 'backup', taskId, basename(targetPath))
await ensureDir(dirname(backupPath))
await copyFile(targetPath, backupPath)
}
function renderVueComponent(spec) {
const text = spec.textSamples.length ? spec.textSamples : [spec.name]
const actions = [...spec.uiContract.primaryActions, ...spec.uiContract.secondaryActions]
const fields = spec.uiContract.fields
return `<template>
<v-container>
<section>
<h1>${escapeHtml(text[0] ?? spec.name)}</h1>
${text.slice(1).map((item) => ` <p>${escapeHtml(item)}</p>`).join('\n')}
${fields.map((field) => ` <v-text-field label="${escapeHtml(field.name ?? field.type ?? 'Field')}"${field.required ? ' required' : ''} />`).join('\n')}
${actions.map((action, index) => ` <v-btn${index === 0 ? ' color="primary"' : ''}>${escapeHtml(action)}</v-btn>`).join('\n')}
</section>
</v-container>
</template>
`
}
function topoSort(tasks) {
const sorted = []
const remaining = new Map(tasks.map((task) => [task.id, task]))
while (remaining.size) {
const ready = [...remaining.values()].find((task) => task.dependsOn.every((id) => !remaining.has(id)))
if (!ready) throw new Error('TaskList dependency cycle detected')
sorted.push(ready)
remaining.delete(ready.id)
}
return sorted
}
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('"', '&quot;')
}
+165
View File
@@ -0,0 +1,165 @@
import { readFile, writeFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js'
import { extractRegions, inferRegionSpec, summarizeHtml } from '../lib/html.js'
const viewports = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 375, height: 812 }
]
export async function scan() {
const config = await loadConfig()
const htmlFiles = await listFiles(config.prototypeDir, ['.html'])
if (htmlFiles.length === 0) throw new Error(`找不到 HTML prototype${config.prototypeDir}`)
await ensureDir(config.htDir)
for (const file of htmlFiles) {
await scanFile(config, file)
}
console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`)
}
async function scanFile(config, file) {
const html = await readFile(file, 'utf8')
const hash = await sha256File(file)
const name = artifactPath(config.prototypeDir, file)
const cacheDir = join(config.htDir, 'cache/prototype', name)
await ensureDir(cacheDir)
const metadataPath = join(cacheDir, 'capture-metadata.json')
const cacheHit = await isCaptureCacheHit(metadataPath, hash)
const capture = {
source: file,
hash,
viewports,
states: config.vision.captureStates,
externalResourceFailures: findExternalResources(html),
screenshots: [],
captureEngine: 'html-summary',
cacheHit
}
for (const viewport of viewports) {
for (const state of config.vision.captureStates) {
const screenshotPath = join(cacheDir, `${viewport.name}-${state}.png`)
if (!cacheHit) await writePlaceholderPng(screenshotPath)
capture.screenshots.push({ viewport: viewport.name, state, path: screenshotPath, status: cacheHit ? 'cache-hit' : 'placeholder' })
}
}
const domSummary = summarizeHtml(html)
await writeJson(join(cacheDir, 'dom-summary.json'), domSummary)
await writeJson(join(cacheDir, 'accessibility-tree.json'), buildAccessibilityTree(domSummary))
await writeJson(metadataPath, capture)
const decomposition = extractRegions(html).map(({ html: _html, ...region }) => ({
...region,
screenshotPath: join(cacheDir, `${region.id}.png`)
}))
const regions = extractRegions(html)
const regionSpecs = regions.map(inferRegionSpec)
const validationReport = validateSpecs(regionSpecs)
const layoutSpec = {
source: file,
hash,
generatedAt: new Date().toISOString(),
page: basename(file),
decomposition,
regions: regionSpecs,
validation: validationReport
}
await writeJson(join(config.htDir, 'spec', `${name}.spec.json`), layoutSpec)
await writeJson(join(config.htDir, 'spec', `${name}.validation.json`), validationReport)
await writeFile(join(config.htDir, 'spec', `${name}.ui-contract.md`), renderUiContract(layoutSpec))
}
async function isCaptureCacheHit(metadataPath, hash) {
if (!(await exists(metadataPath))) return false
try {
const metadata = await readJson(metadataPath)
return metadata.hash === hash
} catch {
return false
}
}
function validateSpecs(specs) {
const warnings = []
let nullFieldCount = 0
let fieldCount = 0
for (const spec of specs) {
countNulls(spec)
if (!vuetifyWhitelist.has(spec.vuetifyComponent)) {
warnings.push(`${spec.regionId}: invalid Vuetify component ${spec.vuetifyComponent}`)
spec.vuetifyComponent = 'VContainer'
}
}
function countNulls(value) {
if (Array.isArray(value)) return value.forEach(countNulls)
if (value && typeof value === 'object') return Object.values(value).forEach(countNulls)
fieldCount += 1
if (value === null) nullFieldCount += 1
}
return {
autoFixedCount: warnings.length,
nullFieldCount,
nullFieldRatio: fieldCount === 0 ? 0 : nullFieldCount / fieldCount,
requiresHumanReview: fieldCount > 0 && nullFieldCount / fieldCount > 0.3,
warnings
}
}
function renderUiContract(spec) {
const lines = [`# UI Contract: ${spec.page}`, '']
for (const region of spec.regions) {
lines.push(`## ${region.name}`, '')
lines.push(`- Region: ${region.regionId}`)
lines.push(`- Vuetify: ${region.vuetifyComponent}`)
lines.push(`- Text: ${region.textSamples.join(', ') || 'none'}`)
lines.push(`- Labels: ${region.uiContract.labels.join(', ') || 'none'}`)
lines.push(`- Actions: ${[...region.uiContract.primaryActions, ...region.uiContract.secondaryActions].join(', ') || 'none'}`)
lines.push('')
}
return `${lines.join('\n')}\n`
}
function buildAccessibilityTree(summary) {
return {
role: 'document',
title: summary.title,
children: [
...summary.headings.map((name) => ({ role: 'heading', name })),
...summary.buttons.map((name) => ({ role: 'button', name })),
...summary.labels.map((name) => ({ role: 'label', name }))
]
}
}
function findExternalResources(html) {
const urls = [...html.matchAll(/\b(?:src|href)=["'](https?:\/\/[^"']+)["']/gi)].map((match) => match[1])
return urls.map((url) => ({ url, reason: 'external resource not fetched in deterministic scan' }))
}
async function writePlaceholderPng(path) {
const onePixelTransparentPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64'
)
await writeFile(path, onePixelTransparentPng)
}
const vuetifyWhitelist = new Set([
'VApp',
'VBtn',
'VCard',
'VContainer',
'VForm',
'VList',
'VNavigationDrawer',
'VTable',
'VTextField',
'VToolbar'
])
+18
View File
@@ -0,0 +1,18 @@
import { join } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { exists, readJson } from '../lib/files.js'
export async function status() {
const config = await loadConfig()
const path = join(config.htDir, 'plan/tasklist.json')
if (!(await exists(path))) {
console.log('尚未產生 TaskList')
return
}
const taskList = await readJson(path)
const counts = Object.groupBy(taskList.tasks, (task) => task.status)
for (const state of ['pending', 'running', 'done', 'error']) {
console.log(`${state}: ${counts[state]?.length ?? 0}`)
}
}
+46
View File
@@ -0,0 +1,46 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { exists, listFiles, readJson, writeJson } from '../lib/files.js'
export async function verify() {
const config = await loadConfig()
const quality = {}
for (const [name, command] of Object.entries(config.project.qualityCommands ?? {})) {
quality[name] = await runQualityCommand(command, config.cwd)
}
const domChecks = await runDomChecks(config)
const flowChecks = { status: 'skipped', reason: 'no executable interaction model in MVP artifacts' }
const report = { generatedAt: new Date().toISOString(), quality, domChecks, flowChecks }
await writeJson(join(config.htDir, 'verify/verification-report.json'), report)
console.log(`verify report: ${join(config.htDir, 'verify/verification-report.json')}`)
}
async function runQualityCommand(command, cwd) {
if (!command) return { status: 'skipped', reason: 'command not configured' }
const [bin, ...args] = command.split(/\s+/)
return new Promise((resolve) => {
const child = spawn(bin, args, { cwd, stdio: 'pipe' })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => { stdout += chunk })
child.stderr.on('data', (chunk) => { stderr += chunk })
child.on('error', (error) => resolve({ status: 'skipped', reason: error.message }))
child.on('close', (code) => resolve({
status: code === 0 ? 'passed' : 'failed',
exitCode: code,
stdoutSummary: stdout.slice(0, 1000),
stderrSummary: stderr.slice(0, 1000)
}))
})
}
async function runDomChecks(config) {
const taskListPath = join(config.htDir, 'plan/tasklist.json')
if (!(await exists(taskListPath))) return { status: 'skipped', reason: 'TaskList missing' }
const taskList = await readJson(taskListPath)
const outputFiles = await listFiles(config.outputDir, ['.vue'])
const missing = taskList.tasks.filter((task) => !outputFiles.some((file) => file.endsWith(task.targetFile)))
return { status: missing.length === 0 ? 'passed' : 'failed', missingTargets: missing.map((task) => task.targetFile) }
}
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { mkdtemp } from 'node:fs/promises'
import { promisify } from 'node:util'
const exec = promisify(execFile)
const cli = new URL('../src/cli.js', import.meta.url).pathname
test('CLI runs scan, plan, run, diff, and verify against one prototype', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'ht-e2e-'))
await mkdir(join(cwd, 'packages/prototype'), { recursive: true })
await mkdir(join(cwd, 'packages/web/src'), { recursive: true })
await mkdir(join(cwd, 'packages/api'), { recursive: true })
await writeFile(join(cwd, 'packages/prototype/index.html'), `
<main>
<h1>Customer Portal</h1>
<form>
<label>Email</label>
<input name="email" required>
<button>Submit</button>
</form>
</main>
`)
await writeFile(join(cwd, 'packages/web/src/App.vue'), '<template><v-app /></template>')
await writeFile(join(cwd, 'packages/api/openapi.json'), '{"openapi":"3.0.0","paths":{}}')
await writeFile(join(cwd, 'ht.config.js'), `
export default {
plan: { interactiveReview: false },
project: { qualityCommands: {} }
}
`)
for (const command of ['scan', 'plan', 'run', 'diff', 'verify']) {
await exec('node', [cli, command], { cwd })
}
const component = await readFile(join(cwd, 'packages/result/index/main-1.vue'), 'utf8')
const report = JSON.parse(await readFile(join(cwd, '.ht/verify/verification-report.json'), 'utf8'))
assert.match(component, /Customer Portal/)
assert.match(component, /v-text-field/)
assert.equal(report.domChecks.status, 'passed')
})
+26
View File
@@ -0,0 +1,26 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { loadConfig } from '../src/lib/config.js'
test('loadConfig supports ht.config.ts defineConfig shape', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'ht-config-'))
await writeFile(join(cwd, 'ht.config.ts'), `
import { defineConfig } from 'html-transform'
export default defineConfig({
prototype: './proto',
output: './out',
plan: { interactiveReview: true }
})
`)
const config = await loadConfig(cwd)
assert.equal(config.prototypeDir, join(cwd, 'proto'))
assert.equal(config.outputDir, join(cwd, 'out'))
assert.equal(config.plan.interactiveReview, true)
assert.equal(config.project.qualityCommands.lint, 'pnpm lint')
})
+35
View File
@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { extractRegions, inferRegionSpec, summarizeHtml } from '../src/lib/html.js'
test('summarizeHtml extracts user-visible contract evidence', () => {
const summary = summarizeHtml(`
<title>Orders</title>
<main>
<h1>Orders</h1>
<form><label>Email</label><input name="email" required><button>Save</button></form>
</main>
`)
assert.equal(summary.title, 'Orders')
assert.deepEqual(summary.labels, ['Email'])
assert.deepEqual(summary.buttons, ['Save'])
assert.equal(summary.inputs[0].name, 'email')
assert.equal(summary.inputs[0].required, true)
})
test('extractRegions falls back to a single page region', () => {
const regions = extractRegions('<h1>Hello</h1><p>World</p>')
assert.equal(regions.length, 1)
assert.equal(regions[0].id, 'page-1')
})
test('inferRegionSpec maps forms to VForm', () => {
const [region] = extractRegions('<main><form><input name="q"><button>Search</button></form></main>')
const spec = inferRegionSpec(region)
assert.equal(spec.vuetifyComponent, 'VForm')
assert.deepEqual(spec.uiContract.primaryActions, ['Search'])
})